From 2056b268ac6c0fbb2bef5d00bacb3f0add50e6c6 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 11:59:56 -0400 Subject: [PATCH 01/21] Add 'roles' param (add/remove/set) to user_role Introduce a new 'roles' dictionary parameter to lowlydba.sqlserver.user_role to manage multiple roles using add/remove/set semantics. Update PowerShell implementation to handle compatibility mode for the legacy single 'role' parameter (now deprecated and optional), perform validation, and return detailed membership changes. Update Python documentation, examples and return spec, add integration tests covering multi-role add/remove/set and edge cases, and include a changelog fragment noting deprecation of 'role' and the new 'roles' behavior. --- changelogs/fragments/user-role-roles-list.yml | 3 + plugins/modules/user_role.ps1 | 302 +++++++++++++++--- plugins/modules/user_role.py | 109 ++++++- .../targets/user_role/tasks/main.yml | 255 ++++++++++++++- 4 files changed, 601 insertions(+), 68 deletions(-) create mode 100644 changelogs/fragments/user-role-roles-list.yml diff --git a/changelogs/fragments/user-role-roles-list.yml b/changelogs/fragments/user-role-roles-list.yml new file mode 100644 index 00000000..89e212eb --- /dev/null +++ b/changelogs/fragments/user-role-roles-list.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - user_role - Added ``roles`` parameter with ``add``/``remove``/``set`` pattern to manage multiple roles. The existing ``role`` parameter is deprecated and will be removed in 3.0.0. \ No newline at end of file diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 8a9f7fe4..78a97947 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -15,107 +15,315 @@ $spec = @{ options = @{ database = @{type = 'str'; required = $true } username = @{type = 'str'; required = $true } - role = @{type = 'str'; required = $true } + roles = @{type = 'dict'; required = $false } + role = @{type = 'str'; required = $false } state = @{type = 'str'; required = $false; default = 'present'; choices = @('present', 'absent') } } + mutually_exclusive = @(@('role', 'roles'), @('roles', 'state')) + required_one_of = @(@('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 +$roles = $module.Params.roles $role = $module.Params.role $state = $module.Params.state $checkMode = $module.CheckMode $module.Result.changed = $false -$getUserSplat = @{ +$commonParamSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential Database = $database - User = $username EnableException = $true } -$getRoleSplat = @{ - SqlInstance = $sqlInstance - SqlCredential = $sqlCredential - Database = $database - Role = $role - EnableException = $true -} -$getRoleMemberSplat = @{ + +$outputProps = @{} +$addedRoles = @() +$removedRoles = @() + +$getUserSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential Database = $database - Role = $role - IncludeSystemUser = $true + User = $username EnableException = $true } - -# Verify user and role exist, DBATools currently fails silently $existingUser = Get-DbaDbUser @getUserSplat 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 ($null -ne $role) { + $compatibilityMode = $true + + $getRoleSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + Database = $database + Role = $role + EnableException = $true + } + $existingRole = Get-DbaDbRole @getRoleSplat + if ($null -eq $existingRole) { + $module.FailJson("Role [$role] does not exist in database [$database].") + } -if ($state -eq "absent") { - if ($existingRoleMembers.username -contains $username) { + if ($state -eq "absent") { try { - $removeRoleMemberSplat = @{ + $getRoleMemberSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential - User = $username Database = $database Role = $role + IncludeSystemUser = $true EnableException = $true - WhatIf = $checkMode - Confirm = $false } - $output = Remove-DbaDbRoleMember @removeRoleMemberSplat - $module.Result.changed = $true + $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat + + if ($existingRoleMembers.username -contains $username) { + $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 + if ($null -ne $output) { + $resultData = ConvertTo-SerializableObject -InputObject $output + $module.Result.data = $resultData + } + } } catch { $module.FailJson("Removing user [$username] from database role [$role] failed: $($_.Exception.Message)", $_) } } -} -elseif ($state -eq "present") { - # Add user to role - if ($existingRoleMembers.username -notcontains $username) { + elseif ($state -eq "present") { try { - $addRoleMemberSplat = @{ + $getRoleMemberSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential - User = $username Database = $database Role = $role + IncludeSystemUser = $true EnableException = $true - WhatIf = $checkMode - Confirm = $false } - $output = Add-DbaDbRoleMember @addRoleMemberSplat - $module.Result.changed = $true + $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat + + if ($existingRoleMembers.username -notcontains $username) { + $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 + if ($null -ne $output) { + $resultData = ConvertTo-SerializableObject -InputObject $output + $module.Result.data = $resultData + } + } } catch { $module.FailJson("Adding user [$username] to database role [$role] failed: $($_.Exception.Message)", $_) } } + $module.ExitJson() } -try { - if ($null -ne $output) { - $resultData = ConvertTo-SerializableObject -InputObject $output - $module.Result.data = $resultData +else { + $compatibilityMode = $false + + $rolesSetSpecified = $null -ne $roles['set'] + $rolesAddSpecified = $null -ne $roles['add'] + $rolesRemoveSpecified = $null -ne $roles['remove'] + + $hasSet = $rolesSetSpecified -and @($roles['set']).Count -gt 0 + $hasAdd = $rolesAddSpecified -and @($roles['add']).Count -gt 0 + $hasRemove = $rolesRemoveSpecified -and @($roles['remove']).Count -gt 0 + + if (-not ($hasSet -or $hasAdd -or $hasRemove) -and -not ($rolesSetSpecified -or $rolesAddSpecified -or $rolesRemoveSpecified)) { + $module.FailJson("When using the 'roles' parameter, you must specify at least one of: roles.set, roles.add, or roles.remove.") + } + + $queryMode = -not ($hasSet -or $hasAdd -or $hasRemove) + + 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)", $_) + } + + $desiredRoles = @() + + if ($hasSet) { + $desiredRoles = [array]($roles['set'] | Sort-Object) + $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject + $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject + + if ($toAdd.Count -gt 0) { + foreach ($roleToAdd in $toAdd) { + $existingRole = Get-DbaDbRole @commonParamSplat -Role $roleToAdd + if ($null -eq $existingRole) { + $module.FailJson("Role [$roleToAdd] does not exist in database [$database].") + } + + try { + $addRoleMemberSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + User = $username + Database = $database + Role = $roleToAdd + EnableException = $true + WhatIf = $checkMode + Confirm = $false + } + Add-DbaDbRoleMember @addRoleMemberSplat + $addedRoles += $roleToAdd + $module.Result.changed = $true + } + catch { + $module.FailJson("Adding user [$username] to database role [$roleToAdd] failed: $($_.Exception.Message)", $_) + } + } + } + + if ($toRemove.Count -gt 0) { + foreach ($roleToRemove in $toRemove) { + try { + $removeRoleMemberSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + User = $username + Database = $database + Role = $roleToRemove + EnableException = $true + WhatIf = $checkMode + Confirm = $false + } + Remove-DbaDbRoleMember @removeRoleMemberSplat + $removedRoles += $roleToRemove + $module.Result.changed = $true + } + catch { + $module.FailJson("Removing user [$username] from database role [$roleToRemove] failed: $($_.Exception.Message)", $_) + } + } + } + + 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)", $_) + } + } + else { + if ($hasAdd) { + $desiredRoles += $roles['add'] + } + if ($hasRemove) { + $desiredRoles += $roles['remove'] + } + $desiredRoles = [array]($desiredRoles | Sort-Object -Unique) + + if ($hasAdd) { + $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $roles['add'] | Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject + + if ($toAdd.Count -gt 0) { + foreach ($roleToAdd in $toAdd) { + $existingRole = Get-DbaDbRole @commonParamSplat -Role $roleToAdd + if ($null -eq $existingRole) { + $module.FailJson("Role [$roleToAdd] does not exist in database [$database].") + } + + try { + $addRoleMemberSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + User = $username + Database = $database + Role = $roleToAdd + EnableException = $true + WhatIf = $checkMode + Confirm = $false + } + Add-DbaDbRoleMember @addRoleMemberSplat + $addedRoles += $roleToAdd + $module.Result.changed = $true + } + catch { + $module.FailJson("Adding user [$username] to database role [$roleToAdd] failed: $($_.Exception.Message)", $_) + } + } + } + } + + if ($hasRemove) { + $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $roles['remove'] | Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject + + if ($toRemove.Count -gt 0) { + foreach ($roleToRemove in $toRemove) { + try { + $removeRoleMemberSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + User = $username + Database = $database + Role = $roleToRemove + EnableException = $true + WhatIf = $checkMode + Confirm = $false + } + Remove-DbaDbRoleMember @removeRoleMemberSplat + $removedRoles += $roleToRemove + $module.Result.changed = $true + } + catch { + $module.FailJson("Removing user [$username] from database role [$roleToRemove] failed: $($_.Exception.Message)", $_) + } + } + } + } + + 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)", $_) + } } + + $outputProps['roleMembership'] = $currentRoleMembership + if ($addedRoles.Count -gt 0) { $outputProps['added'] = $addedRoles } + if ($removedRoles.Count -gt 0) { $outputProps['removed'] = $removedRoles } + + $output = New-Object -TypeName PSCustomObject -Property $outputProps + + $resultData = ConvertTo-SerializableObject -InputObject $output + $module.Result.data = $resultData + $module.ExitJson() -} -catch { - $module.FailJson("Failure: $($_.Exception.Message)", $_) -} +} \ No newline at end of file diff --git a/plugins/modules/user_role.py b/plugins/modules/user_role.py index f120ba74..3af7f887 100644 --- a/plugins/modules/user_role.py +++ b/plugins/modules/user_role.py @@ -10,7 +10,8 @@ short_description: Configures a user's role in a database. description: - Adds or removes a user's role in a database. -version_added: 2.4.0 + - Use the I(roles) option to work with multiple roles at once using the add/remove/set pattern. +version_added: "2.4.0" options: database: description: @@ -22,11 +23,52 @@ - Name of the user. type: str required: true + roles: + description: + - A dictionary of roles to manage for the user. Supports three keys: add, remove, and set. + - Each key accepts a list of role names. + - add adds the user to the specified roles. + - remove removes the user from the specified roles. + - set replaces all current roles with the specified roles. + - When using I(roles), at least one of add, remove, or set must be specified. + type: dict + required: false + version_added: "2.8.0" + suboptions: + add: + description: + - A list of role names to add the user to. + type: list + elements: str + remove: + description: + - A list of role names to remove the user from. + type: list + elements: str + set: + description: + - A list of role names that replaces the user's current roles. + type: list + elements: str role: description: - The database role for the user to be modified. + - This is the legacy parameter. Use I(roles) for more advanced functionality. type: str - required: true + required: false + deprecated: + removed_in: "3.0.0" + why: Replaced by the more flexible I(roles) parameter that supports add/remove/set pattern. + alternative: Use I(roles) with add, remove, or set. + state: + description: + - Desired state of the user role membership. + type: str + choices: + - present + - absent + default: present + version_added: "2.8.0" author: "John McCall (@lowlydba)" requirements: - L(dbatools,https://www.powershellgallery.com/packages/dbatools/) PowerShell module @@ -34,37 +76,80 @@ - lowlydba.sqlserver.sql_credentials - lowlydba.sqlserver.attributes.check_mode - lowlydba.sqlserver.attributes.platform_all - - lowlydba.sqlserver.state ''' EXAMPLES = r''' -- name: Add a user to a fixed db role +- name: Add a user to a fixed db role (legacy) lowlydba.sqlserver.user_role: sql_instance: sql-01.myco.io username: TheIntern database: InternProject1 role: db_owner -- name: Remove a user from a fixed db role - lowlydba.sqlserver.login: +- name: Remove a user from a fixed db role (legacy) + 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: +- name: Add user to multiple roles + lowlydba.sqlserver.user_role: + sql_instance: sql-01.myco.io + username: TheIntern + database: InternProject1 + roles: + add: + - db_owner + - db_datareader + +- name: Remove user from multiple roles + lowlydba.sqlserver.user_role: sql_instance: sql-01.myco.io username: TheIntern database: InternProject1 - role: db_intern - state: absent + roles: + remove: + - db_owner + - db_datareader + +- name: Set user's roles (replace all current roles) + lowlydba.sqlserver.user_role: + sql_instance: sql-01.myco.io + username: TheIntern + database: InternProject1 + roles: + set: + - db_datareader + - db_datawriter + +- name: Combine add and remove operations + lowlydba.sqlserver.user_role: + sql_instance: sql-01.myco.io + username: TheIntern + database: InternProject1 + roles: + add: + - db_securityadmin + remove: + - db_owner ''' RETURN = r''' data: - description: Output from the C(Remove-DbaDbRoleMember), (Get-DbaDbRoleMember), or C(Add-DbaDbRoleMember) functions. + description: Output from the C(Add-DbaDbRoleMember), C(Remove-DbaDbRoleMember), or C(Get-DbaDbRoleMember) functions. returned: success, but not in check_mode. type: dict -''' + contains: + roleMembership: + description: List of roles the user is a member of. + type: list + sample: ["db_owner", "db_datareader"] + added: + description: List of roles that were added to the user. + type: list + removed: + description: List of roles that were removed from the user. + type: list +''' \ No newline at end of file diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index ec5936b7..babb03e3 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -41,8 +41,6 @@ sql_password: "{{ sqlserver_password }}" database: "{{ database }}" username: "{{ username }}" - role: "{{ role }}" - state: present tags: ["sqlserver.user"] block: - name: Create login @@ -66,16 +64,19 @@ - result.data.Login == login_name - result.data.Name == username - - name: Add user to database role + - name: Add user to database role (legacy) lowlydba.sqlserver.user_role: + role: "{{ role }}" + state: present register: result - assert: that: - result is changed - - name: Add user to non-existent database role + - name: Add user to non-existent database role (legacy) lowlydba.sqlserver.user_role: role: db_IMadeThisOneUp + state: present register: error_result failed_when: error_result.failed ignore_errors: true @@ -84,9 +85,11 @@ - error_result.failed == true - "'Role [db_IMadeThisOneUp] does not exist in database' in error_result.msg" - - name: Add non-existent user to database role + - name: Add non-existent user to database role (legacy) lowlydba.sqlserver.user_role: username: NewUserWhoThis + role: "{{ role }}" + state: present register: error_result failed_when: error_result.failed ignore_errors: true @@ -95,20 +98,254 @@ - error_result.failed == true - "'User [NewUserWhoThis] does not exist in database' in error_result.msg" - - name: Add user again to database role + - name: Add user again to database role (legacy - idempotency) lowlydba.sqlserver.user_role: + role: "{{ role }}" + state: present register: result - assert: that: - result is not changed - - name: Remove user from database role + - name: Remove user from database role (legacy) lowlydba.sqlserver.user_role: - state: "absent" + role: "{{ role }}" + state: absent + register: result + - assert: + that: + - result is changed + + - name: Add user to multiple roles using roles.add + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + add: + - db_owner + - db_datareader + register: result + - assert: + that: + - result is changed + - result.data.roleMembership | length == 2 + - "'db_owner' in result.data.roleMembership" + - "'db_datareader' in result.data.roleMembership" + - "'db_owner' in result.data.added" + - "'db_datareader' in result.data.added" + + - name: Add user again to multiple roles (idempotency) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + add: + - db_owner + - db_datareader + register: result + - assert: + that: + - result is not changed + + - name: Add user to additional role using roles.add + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + add: + - db_securityadmin + register: result + - assert: + that: + - result is changed + - result.data.roleMembership | length == 3 + - "'db_securityadmin' in result.data.roleMembership" + - "'db_securityadmin' in result.data.added" + + - name: Remove user from roles using roles.remove + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + remove: + - db_owner + - db_datareader register: result - assert: that: - result is changed + - result.data.roleMembership | length == 1 + - result.data.roleMembership[0] == "db_securityadmin" + - "'db_owner' in result.data.removed" + - "'db_datareader' in result.data.removed" + + - name: Remove user from role they're not in (idempotency) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + remove: + - db_owner + register: result + - assert: + that: + - result is not changed + + - name: Set user's roles (replace all) using roles.set + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + set: + - db_datareader + - db_datawriter + - db_ddladmin + register: result + - assert: + that: + - result is changed + - result.data.roleMembership | length == 3 + - "'db_datareader' in result.data.roleMembership" + - "'db_datawriter' in result.data.roleMembership" + - "'db_ddladmin' in result.data.roleMembership" + - "'db_securityadmin' in result.data.removed" + - "'db_datareader' in result.data.added" + - "'db_datawriter' in result.data.added" + - "'db_ddladmin' in result.data.added" + + - name: Set user's roles again (idempotency) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + set: + - db_datareader + - db_datawriter + - db_ddladmin + register: result + - assert: + that: + - result is not changed + + - name: Combine add and remove operations + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + add: + - db_owner + remove: + - db_ddladmin + register: result + - assert: + that: + - result is changed + - result.data.roleMembership | length == 3 + - "'db_owner' in result.data.roleMembership" + - "'db_datawriter' in result.data.roleMembership" + - "'db_datareader' in result.data.roleMembership" + - "'db_owner' in result.data.added" + - "'db_ddladmin' in result.data.removed" + + - name: Query current role membership (no changes - query mode) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + add: [] + register: result + - assert: + that: + - result is not changed + - result.data.roleMembership | length == 3 + + - name: Test using roles param with only empty lists (valid - query mode) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + add: [] + register: result + - assert: + that: + - result is not changed + - result.data.roleMembership | length == 3 + + - name: Test error: non-existent role in roles.add + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + add: + - db_NonExistentRole12345 + register: error_result + failed_when: error_result.failed + ignore_errors: true + - assert: + that: + - error_result.failed == true + - "'Role [db_NonExistentRole12345] does not exist in database' in error_result.msg" + + - name: Test error: non-existent role in roles.set + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + set: + - db_datareader + - db_NonExistentRole12345 + register: error_result + failed_when: error_result.failed + ignore_errors: true + - assert: + that: + - error_result.failed == true + - "'Role [db_NonExistentRole12345] does not exist in database' in error_result.msg" + + - name: Test error: cannot use both role and roles + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + role: db_datareader + roles: + add: + - db_datawriter + register: error_result + failed_when: error_result.failed + ignore_errors: true + - assert: + that: + - error_result.failed == true + - "'mutually exclusive' in error_result.msg" + + - name: Test roles parameter works without state (new feature) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + set: + - db_datareader + register: result + - assert: + that: + - result is changed + - result.data.roleMembership | length == 1 + + - name: Test error: cannot use state with roles parameter + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + state: absent + roles: + add: + - db_datawriter + register: error_result + failed_when: error_result.failed + ignore_errors: true + - assert: + that: + - error_result.failed == true + - "'mutually exclusive' in error_result.msg" always: - name: Drop user @@ -116,4 +353,4 @@ state: "absent" - name: Drop login lowlydba.sqlserver.login: - state: "absent" + state: "absent" \ No newline at end of file From 839a2e881e7eef4b4ec5e3b61cfcd7eba4e97027 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:02:09 -0400 Subject: [PATCH 02/21] Disallow state when using roles parameter Add explicit validation in plugins/modules/user_role.ps1 to reject using the 'state' parameter when 'roles' is supplied; the module now fails with a clear message instructing to use roles.add, roles.remove, or roles.set for membership changes. Remove the ('roles','state') entry from mutually_exclusive and update the integration test to assert the new error message. --- plugins/modules/user_role.ps1 | 6 +++++- tests/integration/targets/user_role/tasks/main.yml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 78a97947..84bbdc18 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -19,7 +19,7 @@ $spec = @{ role = @{type = 'str'; required = $false } state = @{type = 'str'; required = $false; default = 'present'; choices = @('present', 'absent') } } - mutually_exclusive = @(@('role', 'roles'), @('roles', 'state')) + mutually_exclusive = @(@('role', 'roles')) required_one_of = @(@('role', 'roles')) } @@ -33,6 +33,10 @@ $role = $module.Params.role $state = $module.Params.state $checkMode = $module.CheckMode +if ($null -ne $roles -and $state -ne 'present') { + $module.FailJson("The 'state' parameter is not supported when using the 'roles' parameter. Use roles.add, roles.remove, or roles.set to control membership changes.") +} + $module.Result.changed = $false $commonParamSplat = @{ diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index babb03e3..24d2a5b9 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -345,7 +345,7 @@ - assert: that: - error_result.failed == true - - "'mutually exclusive' in error_result.msg" + - "'state' parameter is not supported" in error_result.msg always: - name: Drop user From 0fbdab1a23922b13da1d56e45565bfb8f2ad3727 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:07:05 -0400 Subject: [PATCH 03/21] chore: reduce redundant params with defaults --- plugins/modules/user_role.ps1 | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 84bbdc18..ee142c1a 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -33,6 +33,8 @@ $role = $module.Params.role $state = $module.Params.state $checkMode = $module.CheckMode +$PSDefaultParameterValues = @{ "*:EnableException" = $true; "*:Confirm" = $false; "*:WhatIf" = $checkMode } + if ($null -ne $roles -and $state -ne 'present') { $module.FailJson("The 'state' parameter is not supported when using the 'roles' parameter. Use roles.add, roles.remove, or roles.set to control membership changes.") } @@ -43,7 +45,6 @@ $commonParamSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential Database = $database - EnableException = $true } $outputProps = @{} @@ -55,7 +56,6 @@ $getUserSplat = @{ SqlCredential = $sqlCredential Database = $database User = $username - EnableException = $true } $existingUser = Get-DbaDbUser @getUserSplat if ($null -eq $existingUser) { @@ -70,7 +70,6 @@ if ($null -ne $role) { SqlCredential = $sqlCredential Database = $database Role = $role - EnableException = $true } $existingRole = Get-DbaDbRole @getRoleSplat if ($null -eq $existingRole) { @@ -85,7 +84,6 @@ if ($null -ne $role) { Database = $database Role = $role IncludeSystemUser = $true - EnableException = $true } $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat @@ -96,9 +94,6 @@ if ($null -ne $role) { User = $username Database = $database Role = $role - EnableException = $true - WhatIf = $checkMode - Confirm = $false } $output = Remove-DbaDbRoleMember @removeRoleMemberSplat $module.Result.changed = $true @@ -120,7 +115,6 @@ if ($null -ne $role) { Database = $database Role = $role IncludeSystemUser = $true - EnableException = $true } $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat @@ -131,9 +125,6 @@ if ($null -ne $role) { User = $username Database = $database Role = $role - EnableException = $true - WhatIf = $checkMode - Confirm = $false } $output = Add-DbaDbRoleMember @addRoleMemberSplat $module.Result.changed = $true @@ -196,9 +187,6 @@ else { User = $username Database = $database Role = $roleToAdd - EnableException = $true - WhatIf = $checkMode - Confirm = $false } Add-DbaDbRoleMember @addRoleMemberSplat $addedRoles += $roleToAdd @@ -219,9 +207,6 @@ else { User = $username Database = $database Role = $roleToRemove - EnableException = $true - WhatIf = $checkMode - Confirm = $false } Remove-DbaDbRoleMember @removeRoleMemberSplat $removedRoles += $roleToRemove @@ -268,9 +253,6 @@ else { User = $username Database = $database Role = $roleToAdd - EnableException = $true - WhatIf = $checkMode - Confirm = $false } Add-DbaDbRoleMember @addRoleMemberSplat $addedRoles += $roleToAdd @@ -295,9 +277,6 @@ else { User = $username Database = $database Role = $roleToRemove - EnableException = $true - WhatIf = $checkMode - Confirm = $false } Remove-DbaDbRoleMember @removeRoleMemberSplat $removedRoles += $roleToRemove From 68e0b0fa81497a8e7921881280d67d73553c015e Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:18:12 -0400 Subject: [PATCH 04/21] Enforce roles mode and deprecate legacy role/state PowerShell module: remove default at param level for state and enforce compatibility behavior so state is only applied in legacy `role` mode (defaulted to 'present' when `role` is used). Reject `state` when using the new `roles` dictionary. Clean up spec array formatting (mutually_exclusive / required_one_of) and add explicit checks in both legacy and new code paths. Python docs: update option docs to clarify `roles` keys (add/remove/set) and mark `role` as deprecated with guidance; clarify that `state` applies only to the legacy `role` parameter and remove its module-level default. Tests: minor task name quoting updates to match linting/formatting. These changes prevent ambiguous parameter combinations and improve UX for the new roles API. --- plugins/modules/user_role.ps1 | 20 +++++++++++++++--- plugins/modules/user_role.py | 21 +++++++------------ .../targets/user_role/tasks/main.yml | 8 +++---- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index ee142c1a..d611f9ef 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -17,10 +17,14 @@ $spec = @{ username = @{type = 'str'; required = $true } roles = @{type = 'dict'; required = $false } role = @{type = 'str'; required = $false } - state = @{type = 'str'; required = $false; default = 'present'; choices = @('present', 'absent') } + state = @{type = 'str'; required = $false; choices = @('present', 'absent') } } - mutually_exclusive = @(@('role', 'roles')) - required_one_of = @(@('role', 'roles')) + mutually_exclusive = @( + , @('role', 'roles') + ) + required_one_of = @( + , @('role', 'roles') + ) } $module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-LowlyDbaSqlServerAuthSpec)) @@ -64,6 +68,11 @@ if ($null -eq $existingUser) { if ($null -ne $role) { $compatibilityMode = $true + + # Set default state for legacy mode if not specified + if ($null -eq $state) { + $state = 'present' + } $getRoleSplat = @{ SqlInstance = $sqlInstance @@ -142,6 +151,11 @@ if ($null -ne $role) { } else { $compatibilityMode = $false + + # Reject state parameter when using new roles mode + if ($null -ne $state) { + $module.FailJson("The 'state' parameter is not supported when using roles. Only 'role' parameter supports state.") + } $rolesSetSpecified = $null -ne $roles['set'] $rolesAddSpecified = $null -ne $roles['add'] diff --git a/plugins/modules/user_role.py b/plugins/modules/user_role.py index 3af7f887..847df9bb 100644 --- a/plugins/modules/user_role.py +++ b/plugins/modules/user_role.py @@ -25,12 +25,12 @@ required: true roles: description: - - A dictionary of roles to manage for the user. Supports three keys: add, remove, and set. - - Each key accepts a list of role names. - - add adds the user to the specified roles. - - remove removes the user from the specified roles. - - set replaces all current roles with the specified roles. - - When using I(roles), at least one of add, remove, or set must be specified. + - A dictionary of roles to manage for the user. + - Supports three keys C(add), C(remove), and C(set). + - C(add) adds the user to the specified roles. + - C(remove) removes the user from the specified roles. + - C(set) replaces all current roles with the specified roles. + - At least one of C(add), C(remove), or C(set) must be specified. type: dict required: false version_added: "2.8.0" @@ -53,22 +53,17 @@ role: description: - The database role for the user to be modified. - - This is the legacy parameter. Use I(roles) for more advanced functionality. + - "B(Deprecated:) This parameter is deprecated and will be removed in version 3.0.0. Use I(roles) instead." type: str required: false - deprecated: - removed_in: "3.0.0" - why: Replaced by the more flexible I(roles) parameter that supports add/remove/set pattern. - alternative: Use I(roles) with add, remove, or set. state: description: - Desired state of the user role membership. + - "Only applicable when using the I(role) parameter (legacy mode). Ignored when using I(roles)." type: str choices: - present - absent - default: present - version_added: "2.8.0" author: "John McCall (@lowlydba)" requirements: - L(dbatools,https://www.powershellgallery.com/packages/dbatools/) PowerShell module diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index 24d2a5b9..9664af91 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -271,7 +271,7 @@ - result is not changed - result.data.roleMembership | length == 3 - - name: Test error: non-existent role in roles.add + - name: "Test error: non-existent role in roles.add" lowlydba.sqlserver.user_role: username: "{{ username }}" database: "{{ database }}" @@ -286,7 +286,7 @@ - error_result.failed == true - "'Role [db_NonExistentRole12345] does not exist in database' in error_result.msg" - - name: Test error: non-existent role in roles.set + - name: "Test error: non-existent role in roles.set" lowlydba.sqlserver.user_role: username: "{{ username }}" database: "{{ database }}" @@ -302,7 +302,7 @@ - error_result.failed == true - "'Role [db_NonExistentRole12345] does not exist in database' in error_result.msg" - - name: Test error: cannot use both role and roles + - name: "Test error: cannot use both role and roles" lowlydba.sqlserver.user_role: username: "{{ username }}" database: "{{ database }}" @@ -331,7 +331,7 @@ - result is changed - result.data.roleMembership | length == 1 - - name: Test error: cannot use state with roles parameter + - name: "Test error: cannot use state with roles parameter" lowlydba.sqlserver.user_role: username: "{{ username }}" database: "{{ database }}" From 48f3465a7c34aa84c6bc5d18e7e1e131abd569ca Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:24:43 -0400 Subject: [PATCH 05/21] Refactor user_role scripts and formatting PowerShell: Clean up user_role.ps1 by constructing the state error message in a variable, removing unused compatibilityMode and queryMode assignments, and reflowing long Compare-Object pipelines for readability; no behavioral changes intended. Python: update copyright year in user_role.py (2022 -> 2026) and ensure trailing newline. Tests: add missing trailing newline to tests/integration/targets/user_role/tasks/main.yml. --- plugins/modules/user_role.ps1 | 22 +++++++++---------- plugins/modules/user_role.py | 4 ++-- .../targets/user_role/tasks/main.yml | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index d611f9ef..0482c50f 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -40,7 +40,9 @@ $checkMode = $module.CheckMode $PSDefaultParameterValues = @{ "*:EnableException" = $true; "*:Confirm" = $false; "*:WhatIf" = $checkMode } if ($null -ne $roles -and $state -ne 'present') { - $module.FailJson("The 'state' parameter is not supported when using the 'roles' parameter. Use roles.add, roles.remove, or roles.set to control membership changes.") + $msg = "The 'state' parameter is not supported when using the 'roles' parameter. " + $msg += "Use roles.add, roles.remove, or roles.set to control membership changes." + $module.FailJson($msg) } $module.Result.changed = $false @@ -67,8 +69,6 @@ if ($null -eq $existingUser) { } if ($null -ne $role) { - $compatibilityMode = $true - # Set default state for legacy mode if not specified if ($null -eq $state) { $state = 'present' @@ -150,8 +150,6 @@ if ($null -ne $role) { $module.ExitJson() } else { - $compatibilityMode = $false - # Reject state parameter when using new roles mode if ($null -ne $state) { $module.FailJson("The 'state' parameter is not supported when using roles. Only 'role' parameter supports state.") @@ -169,8 +167,6 @@ else { $module.FailJson("When using the 'roles' parameter, you must specify at least one of: roles.set, roles.add, or roles.remove.") } - $queryMode = -not ($hasSet -or $hasAdd -or $hasRemove) - try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) @@ -184,8 +180,10 @@ else { if ($hasSet) { $desiredRoles = [array]($roles['set'] | Sort-Object) - $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject - $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject + $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | + Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject + $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | + Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject if ($toAdd.Count -gt 0) { foreach ($roleToAdd in $toAdd) { @@ -251,7 +249,8 @@ else { $desiredRoles = [array]($desiredRoles | Sort-Object -Unique) if ($hasAdd) { - $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $roles['add'] | Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject + $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $roles['add'] | + Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject if ($toAdd.Count -gt 0) { foreach ($roleToAdd in $toAdd) { @@ -280,7 +279,8 @@ else { } if ($hasRemove) { - $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $roles['remove'] | Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject + $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $roles['remove'] | + Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject if ($toRemove.Count -gt 0) { foreach ($roleToRemove in $toRemove) { diff --git a/plugins/modules/user_role.py b/plugins/modules/user_role.py index 847df9bb..9853694d 100644 --- a/plugins/modules/user_role.py +++ b/plugins/modules/user_role.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# (c) 2022, John McCall (@lowlydba) +# (c) 2026, John McCall (@lowlydba) # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) DOCUMENTATION = r''' @@ -147,4 +147,4 @@ removed: description: List of roles that were removed from the user. type: list -''' \ No newline at end of file +''' diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index 9664af91..3b1d2c94 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -353,4 +353,4 @@ state: "absent" - name: Drop login lowlydba.sqlserver.login: - state: "absent" \ No newline at end of file + state: "absent" From 0d45b885eea45e4e4ea4451d9b16a9b2389c784d Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:31:14 -0400 Subject: [PATCH 06/21] Update main.yml --- tests/integration/targets/user_role/tasks/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index 3b1d2c94..16fcb34b 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -354,3 +354,4 @@ - name: Drop login lowlydba.sqlserver.login: state: "absent" + From d926557eb463d52737f4e7cb3206b3d4cc388249 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:35:56 -0400 Subject: [PATCH 07/21] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5d5df93d..4e7ccd89 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![CI](https://github.com/lowlydba/lowlydba.sqlserver/actions/workflows/ansible-test.yml/badge.svg)](https://github.com/lowlydba/lowlydba.sqlserver/actions/workflows/ansible-test.yml) [![CI (Windows)](https://github.com/lowlydba/lowlydba.sqlserver/actions/workflows/ansible-test-windows.yml/badge.svg)](https://github.com/lowlydba/lowlydba.sqlserver/actions/workflows/ansible-test-windows.yml) [![codecov](https://codecov.io/gh/lowlydba/lowlydba.sqlserver/branch/main/graph/badge.svg?token=3TW3VBCn9N)](https://codecov.io/gh/lowlydba/lowlydba.sqlserver) +[![immutable release ruleset](https://img.shields.io/badge/immutable%20tags-active-green?logo=github)](https://github.com/lowlydba/lowlydba.sqlserver/rules/14953198) [![GPL v3](https://img.shields.io/github/license/lowlydba/lowlydba.sqlserver)](https://github.com/lowlydba/lowlydba.sqlserver/blob/main/LICENSE) [![Ansible Collection Downloads](https://img.shields.io/ansible/collection/d/lowlydba/sqlserver)](https://galaxy.ansible.com/ui/repo/published/lowlydba/sqlserver) From d0bf0a00cb048aea41e4c3f3952f2d6d8ad636fe Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:40:04 -0400 Subject: [PATCH 08/21] Adjust test assertion quoting for state message Use double quotes around "state" in the expected error message within the integration test to avoid YAML/quote parsing issues and match the actual error output. --- tests/integration/targets/user_role/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index 16fcb34b..579aadca 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -345,7 +345,7 @@ - assert: that: - error_result.failed == true - - "'state' parameter is not supported" in error_result.msg + - '"state" parameter is not supported' in error_result.msg always: - name: Drop user From 0b101954d4d92b8254c790a2081188e777c3e19a Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 12:44:46 -0400 Subject: [PATCH 09/21] Update user_role changelog and test assertion Append PR reference (#352) to the user_role changelog entry and make the integration test assertion more robust: replace a direct substring check with an Ansible search() expression to match the expected error message pattern in tests/integration/targets/user_role/tasks/main.yml. --- changelogs/fragments/user-role-roles-list.yml | 2 +- tests/integration/targets/user_role/tasks/main.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/changelogs/fragments/user-role-roles-list.yml b/changelogs/fragments/user-role-roles-list.yml index 89e212eb..b0bea707 100644 --- a/changelogs/fragments/user-role-roles-list.yml +++ b/changelogs/fragments/user-role-roles-list.yml @@ -1,3 +1,3 @@ --- minor_changes: - - user_role - Added ``roles`` parameter with ``add``/``remove``/``set`` pattern to manage multiple roles. The existing ``role`` parameter is deprecated and will be removed in 3.0.0. \ No newline at end of file + - user_role - Added ``roles`` parameter with ``add``/``remove``/``set`` pattern to manage multiple roles. The existing ``role`` parameter is deprecated and will be removed in 3.0.0. (#352) diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index 579aadca..b5b45109 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -345,7 +345,7 @@ - assert: that: - error_result.failed == true - - '"state" parameter is not supported' in error_result.msg + - error_result.msg is search("state.*parameter.*not.*supported") always: - name: Drop user @@ -354,4 +354,3 @@ - name: Drop login lowlydba.sqlserver.login: state: "absent" - From 39ebb846463f223433766e2af5f2895b4517bad6 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 13:05:49 -0400 Subject: [PATCH 10/21] Update user_role.ps1 --- plugins/modules/user_role.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 0482c50f..7a1c5df8 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -39,7 +39,7 @@ $checkMode = $module.CheckMode $PSDefaultParameterValues = @{ "*:EnableException" = $true; "*:Confirm" = $false; "*:WhatIf" = $checkMode } -if ($null -ne $roles -and $state -ne 'present') { +if ($null -ne $roles -and $null -ne $state) { $msg = "The 'state' parameter is not supported when using the 'roles' parameter. " $msg += "Use roles.add, roles.remove, or roles.set to control membership changes." $module.FailJson($msg) From b2a8425cef42fa7bbc3c5a133cf9ebc3887c6439 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 13:30:24 -0400 Subject: [PATCH 11/21] Use Role.Name for currentRoleMembership Extract the Role.Name when building currentRoleMembership so the array contains role names (strings) instead of role objects. Updated three occurrences in plugins/modules/user_role.ps1 to cast and sort the Name property, preventing object comparison issues and ensuring accurate membership checks while preserving existing null handling. --- plugins/modules/user_role.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 7a1c5df8..cf548bcd 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -169,7 +169,7 @@ else { try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } - $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) + $currentRoleMembership = [array](($membershipObjects.Role).Name | Sort-Object) if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() } } catch { @@ -232,7 +232,7 @@ else { try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } - $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) + $currentRoleMembership = [array](($membershipObjects.Role).Name | Sort-Object) if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() } } catch { @@ -305,7 +305,7 @@ else { try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } - $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) + $currentRoleMembership = [array](($membershipObjects.Role).Name | Sort-Object) if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() } } catch { From 773c8fb791a6e4889db1cc87b4df2c09f5fc9a26 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 13:54:57 -0400 Subject: [PATCH 12/21] Update user_role.ps1 --- plugins/modules/user_role.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index cf548bcd..6eb39438 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -96,7 +96,7 @@ if ($null -ne $role) { } $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat - if ($existingRoleMembers.username -contains $username) { + if ($existingRoleMembers.UserName -contains $username) { $removeRoleMemberSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential @@ -127,7 +127,7 @@ if ($null -ne $role) { } $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat - if ($existingRoleMembers.username -notcontains $username) { + if ($existingRoleMembers.UserName -notcontains $username) { $addRoleMemberSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential @@ -169,7 +169,7 @@ else { try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } - $currentRoleMembership = [array](($membershipObjects.Role).Name | Sort-Object) + $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() } } catch { @@ -232,7 +232,7 @@ else { try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } - $currentRoleMembership = [array](($membershipObjects.Role).Name | Sort-Object) + $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() } } catch { @@ -305,7 +305,7 @@ else { try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } - $currentRoleMembership = [array](($membershipObjects.Role).Name | Sort-Object) + $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() } } catch { @@ -323,4 +323,4 @@ else { $module.Result.data = $resultData $module.ExitJson() -} \ No newline at end of file +} From 993999709385e5afd0e8fb3637656eca3f83adc9 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 14:30:07 -0400 Subject: [PATCH 13/21] Use direct membership check for role removals Replace the Compare-Object + SideIndicator pipeline with a direct membership filter when building $toRemove in plugins/modules/user_role.ps1. The new line uses: $toRemove = @($roles['remove'] | Where-Object { $_ -in $currentRoleMembership }) This is simpler, more readable, avoids relying on Compare-Object SideIndicator semantics, and ensures $toRemove is an array so .Count behaves consistently. --- plugins/modules/user_role.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 6eb39438..df68e01c 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -279,8 +279,7 @@ else { } if ($hasRemove) { - $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $roles['remove'] | - Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject + $toRemove = @($roles['remove'] | Where-Object { $_ -in $currentRoleMembership }) if ($toRemove.Count -gt 0) { foreach ($roleToRemove in $toRemove) { From 8345abc6a05721d35a0ba08fcd4dc8991ead0c83 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 14:37:28 -0400 Subject: [PATCH 14/21] Validate roles options and clarify state Add validation in the PowerShell module to prevent combining roles.set with roles.add or roles.remove, and remove the previous immediate rejection of the legacy 'state' parameter in the roles branch (handled by overall validation). Update the Python module docs for the 'state' option to state that it "Cannot be used with roles" instead of being "Ignored when using roles", clarifying intended usage and preventing invalid option combinations. --- plugins/modules/user_role.ps1 | 9 ++++----- plugins/modules/user_role.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index df68e01c..48cd7494 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -150,11 +150,6 @@ if ($null -ne $role) { $module.ExitJson() } else { - # Reject state parameter when using new roles mode - if ($null -ne $state) { - $module.FailJson("The 'state' parameter is not supported when using roles. Only 'role' parameter supports state.") - } - $rolesSetSpecified = $null -ne $roles['set'] $rolesAddSpecified = $null -ne $roles['add'] $rolesRemoveSpecified = $null -ne $roles['remove'] @@ -167,6 +162,10 @@ else { $module.FailJson("When using the 'roles' parameter, you must specify at least one of: roles.set, roles.add, or roles.remove.") } + if ($hasSet -and ($hasAdd -or $hasRemove)) { + $module.FailJson("The 'roles.set' option cannot be combined with 'roles.add' or 'roles.remove'.") + } + try { $membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username } $currentRoleMembership = [array]($membershipObjects.Role | Sort-Object) diff --git a/plugins/modules/user_role.py b/plugins/modules/user_role.py index 9853694d..96a67788 100644 --- a/plugins/modules/user_role.py +++ b/plugins/modules/user_role.py @@ -59,7 +59,7 @@ state: description: - Desired state of the user role membership. - - "Only applicable when using the I(role) parameter (legacy mode). Ignored when using I(roles)." + - "Only applicable when using the I(role) parameter (legacy mode). Cannot be used with I(roles)." type: str choices: - present From 9b736b84b672f04068fb1f907a67b00e082cca76 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 14:47:31 -0400 Subject: [PATCH 15/21] Mark devel matrix as non-fatal and add rollup jobs Treat Ansible 'devel' matrix runs as non-blocking by setting continue-on-error for sanity and integration jobs in ansible-test.yml and ansible-test-windows.yml. Add ci-test-rollup and win-ci-test-rollup jobs that run lowlydba/are-we-good to aggregate and report the needs results (use needs + if: always()). --- .github/workflows/ansible-test-windows.yml | 11 +++++++++++ .github/workflows/ansible-test.yml | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/.github/workflows/ansible-test-windows.yml b/.github/workflows/ansible-test-windows.yml index e9e79e8c..0ab133a0 100644 --- a/.github/workflows/ansible-test-windows.yml +++ b/.github/workflows/ansible-test-windows.yml @@ -44,6 +44,7 @@ jobs: integration: runs-on: ${{ matrix.os }} name: I (Ⓐ${{ matrix.ansible }}+win-2022|grp${{ matrix.group }}) + continue-on-error: ${{ matrix.ansible == 'devel' }} strategy: fail-fast: false matrix: @@ -218,3 +219,13 @@ jobs: - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: fail_ci_if_error: false + + win-ci-test-rollup: + name: Are we good? + runs-on: ubuntu-slim + needs: [integration] + if: always() + steps: + - uses: lowlydba/are-we-good@7efad05442f92a1203940ca8b79dd4fb930e75d4 # v1.0.2 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/ansible-test.yml b/.github/workflows/ansible-test.yml index a329b305..d0ae6c18 100644 --- a/.github/workflows/ansible-test.yml +++ b/.github/workflows/ansible-test.yml @@ -50,6 +50,7 @@ jobs: sanity: name: Sanity (Ⓐ${{ matrix.ansible }}) + continue-on-error: ${{ matrix.ansible == 'devel' }} permissions: contents: write # Required for uploading coverage reports strategy: @@ -102,6 +103,7 @@ jobs: integration: runs-on: ubuntu-latest name: I (Ⓐ${{ matrix.ansible }}+py${{ matrix.python }}) + continue-on-error: ${{ matrix.ansible == 'devel' }} permissions: contents: write # Required for uploading coverage reports services: @@ -152,3 +154,13 @@ jobs: fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] -- Codecov token needed for uploading coverage; workflow_dispatch only + + ci-test-rollup: + name: Are we good? + runs-on: ubuntu-slim + needs: [sanity, integration] + if: always() + steps: + - uses: lowlydba/are-we-good@7efad05442f92a1203940ca8b79dd4fb930e75d4 # v1.0.2 + with: + jobs: ${{ toJSON(needs) }} From f27ff6682be473325c086265fdd28954679ae048 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 15:06:37 -0400 Subject: [PATCH 16/21] v2.8.0 changelog --- CHANGELOG.rst | 16 ++++++++++++++ changelogs/changelog.yaml | 21 +++++++++++++++++++ changelogs/fragments/remove-six-usage.yml | 2 -- changelogs/fragments/user-role-roles-list.yml | 3 --- 4 files changed, 37 insertions(+), 5 deletions(-) delete mode 100644 changelogs/fragments/remove-six-usage.yml delete mode 100644 changelogs/fragments/user-role-roles-list.yml diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ce3200d1..072129c6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,22 @@ lowlydba.sqlserver Release Notes .. contents:: Topics +v2.8.0 +====== + +Release Summary +--------------- + +Hardened GitHub Actions workflows against supply chain attacks using pinned +SHA hashes, scoped permissions, and zizmor security analysis. Added a ``roles`` +input to the ``user_role`` module for managing multiple database role memberships +simultaneously using the ``add``/``remove``/``set`` pattern. + +Minor Changes +------------- + +- user_role - Added ``roles`` parameter with ``add``/``remove``/``set`` pattern to manage multiple roles. The existing ``role`` parameter is deprecated and will be removed in 3.0.0. (#352) + v2.7.0 ====== diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index d427baa0..963a45e7 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -583,3 +583,24 @@ releases: fragments: - 329-add-agent-outputfile.yml release_date: '2025-08-16' + 2.8.0: + changes: + minor_changes: + - user_role - Added ``roles`` parameter with ``add``/``remove``/``set`` pattern + to manage multiple roles. The existing ``role`` parameter is deprecated and + will be removed in 3.0.0. (#352) + release_summary: 'Hardened GitHub Actions workflows against supply chain attacks + using pinned + + SHA hashes, scoped permissions, and zizmor security analysis. Added a ``roles`` + + input to the ``user_role`` module for managing multiple database role memberships + + simultaneously using the ``add``/``remove``/``set`` pattern. + + ' + fragments: + - 2.8.0.yml + - remove-six-usage.yml + - user-role-roles-list.yml + release_date: '2026-04-11' diff --git a/changelogs/fragments/remove-six-usage.yml b/changelogs/fragments/remove-six-usage.yml deleted file mode 100644 index 1ed7a36a..00000000 --- a/changelogs/fragments/remove-six-usage.yml +++ /dev/null @@ -1,2 +0,0 @@ -trivial: - - Replace deprecated ``ansible.module_utils.six`` (``text_type``/``binary_type``) with native Python 3 ``str``/``bytes`` in test connection plugin ``local_pwsh`` (PR #334). diff --git a/changelogs/fragments/user-role-roles-list.yml b/changelogs/fragments/user-role-roles-list.yml deleted file mode 100644 index b0bea707..00000000 --- a/changelogs/fragments/user-role-roles-list.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -minor_changes: - - user_role - Added ``roles`` parameter with ``add``/``remove``/``set`` pattern to manage multiple roles. The existing ``role`` parameter is deprecated and will be removed in 3.0.0. (#352) From ba10cd539336b41e8f9030aeb86138b138ca3a85 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 15:17:22 -0400 Subject: [PATCH 17/21] Handle empty roles lists in user_role module Treat presence of roles keys as intent and allow empty lists: plugins/modules/user_role.ps1 now checks key presence (so roles.set: [] is a valid explicit operation that removes all roles), prevents combining roles.set with add/remove by key presence, and avoids Compare-Object on two empty lists. plugins/modules/user_role.py docs were updated to document that add/remove empty lists are no-ops, set: [] removes all memberships, and that set cannot be combined with add/remove; RETURN docs were clarified. tests/integration/targets/user_role/tasks/main.yml updated: removed an obsolete empty-list query test and added tests to verify set: [] removes all memberships, is idempotent, and errors when combined with add. --- plugins/modules/user_role.ps1 | 23 +++++--- plugins/modules/user_role.py | 29 +++++----- .../targets/user_role/tasks/main.yml | 53 ++++++++++++++----- 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 48cd7494..595021f1 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -154,15 +154,16 @@ else { $rolesAddSpecified = $null -ne $roles['add'] $rolesRemoveSpecified = $null -ne $roles['remove'] - $hasSet = $rolesSetSpecified -and @($roles['set']).Count -gt 0 + # Key presence determines intent; empty list is a valid explicit operation (e.g. set: [] removes all roles) + $hasSet = $rolesSetSpecified $hasAdd = $rolesAddSpecified -and @($roles['add']).Count -gt 0 $hasRemove = $rolesRemoveSpecified -and @($roles['remove']).Count -gt 0 - if (-not ($hasSet -or $hasAdd -or $hasRemove) -and -not ($rolesSetSpecified -or $rolesAddSpecified -or $rolesRemoveSpecified)) { - $module.FailJson("When using the 'roles' parameter, you must specify at least one of: roles.set, roles.add, or roles.remove.") + if (-not ($rolesSetSpecified -or $rolesAddSpecified -or $rolesRemoveSpecified)) { + $module.FailJson("When using 'roles', at least one key (roles.set, roles.add, or roles.remove) must be present.") } - if ($hasSet -and ($hasAdd -or $hasRemove)) { + if ($rolesSetSpecified -and ($rolesAddSpecified -or $rolesRemoveSpecified)) { $module.FailJson("The 'roles.set' option cannot be combined with 'roles.add' or 'roles.remove'.") } @@ -179,10 +180,16 @@ else { if ($hasSet) { $desiredRoles = [array]($roles['set'] | Sort-Object) - $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | - Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject - $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | - Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject + if ($currentRoleMembership.Count -eq 0 -and $desiredRoles.Count -eq 0) { + $toAdd = @() + $toRemove = @() + } + else { + $toAdd = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | + Where-Object { $_.SideIndicator -eq '=>' } | Select-Object -ExpandProperty InputObject + $toRemove = Compare-Object -ReferenceObject $currentRoleMembership -DifferenceObject $desiredRoles | + Where-Object { $_.SideIndicator -eq '<=' } | Select-Object -ExpandProperty InputObject + } if ($toAdd.Count -gt 0) { foreach ($roleToAdd in $toAdd) { diff --git a/plugins/modules/user_role.py b/plugins/modules/user_role.py index 96a67788..47129280 100644 --- a/plugins/modules/user_role.py +++ b/plugins/modules/user_role.py @@ -27,27 +27,28 @@ description: - A dictionary of roles to manage for the user. - Supports three keys C(add), C(remove), and C(set). - - C(add) adds the user to the specified roles. - - C(remove) removes the user from the specified roles. - - C(set) replaces all current roles with the specified roles. - - At least one of C(add), C(remove), or C(set) must be specified. + - C(add) adds the user to the specified roles. An empty list is a no-op and returns current membership. + - C(remove) removes the user from the specified roles. An empty list is a no-op and returns current membership. + - C(set) replaces all current roles with the specified roles. An empty list removes all role memberships. + - C(set) cannot be combined with C(add) or C(remove). + - At least one key must be present. type: dict required: false version_added: "2.8.0" suboptions: add: description: - - A list of role names to add the user to. + - A list of role names to add the user to. May be empty to query current membership without changes. type: list elements: str remove: description: - - A list of role names to remove the user from. + - A list of role names to remove the user from. May be empty to query current membership without changes. type: list elements: str set: description: - - A list of role names that replaces the user's current roles. + - A list of role names that replaces the user's current roles. An empty list removes all role memberships. type: list elements: str role: @@ -133,18 +134,22 @@ RETURN = r''' data: - description: Output from the C(Add-DbaDbRoleMember), C(Remove-DbaDbRoleMember), or C(Get-DbaDbRoleMember) functions. - returned: success, but not in check_mode. + description: + - For the C(roles) parameter - a summary object containing current role membership and any roles added or removed. + - For the legacy C(role) parameter - output from C(Add-DbaDbRoleMember) or C(Remove-DbaDbRoleMember). Not returned in check_mode. + returned: success type: dict contains: roleMembership: - description: List of roles the user is a member of. + description: List of roles the user is currently a member of. In check_mode reflects state before any changes. type: list sample: ["db_owner", "db_datareader"] added: - description: List of roles that were added to the user. + description: List of roles that were added (or would be added in check_mode). type: list + elements: str removed: - description: List of roles that were removed from the user. + description: List of roles that were removed (or would be removed in check_mode). type: list + elements: str ''' diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index b5b45109..2190eda4 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -259,18 +259,6 @@ - result is not changed - result.data.roleMembership | length == 3 - - name: Test using roles param with only empty lists (valid - query mode) - lowlydba.sqlserver.user_role: - username: "{{ username }}" - database: "{{ database }}" - roles: - add: [] - register: result - - assert: - that: - - result is not changed - - result.data.roleMembership | length == 3 - - name: "Test error: non-existent role in roles.add" lowlydba.sqlserver.user_role: username: "{{ username }}" @@ -331,6 +319,47 @@ - result is changed - result.data.roleMembership | length == 1 + - name: Set roles to empty list (removes all memberships) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + set: [] + register: result + - assert: + that: + - result is changed + - result.data.roleMembership | length == 0 + - "'db_datareader' in result.data.removed" + + - name: Set roles to empty list again (idempotency) + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + set: [] + register: result + - assert: + that: + - result is not changed + - result.data.roleMembership | length == 0 + + - name: "Test error: cannot combine roles.set with roles.add" + lowlydba.sqlserver.user_role: + username: "{{ username }}" + database: "{{ database }}" + roles: + set: [] + add: + - db_datareader + register: error_result + failed_when: error_result.failed + ignore_errors: true + - assert: + that: + - error_result.failed == true + - "'roles.set' in error_result.msg" + - name: "Test error: cannot use state with roles parameter" lowlydba.sqlserver.user_role: username: "{{ username }}" From 17bcbc3360d50935f5c7ec6f19fdbce72e313b2c Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 15:21:26 -0400 Subject: [PATCH 18/21] Remove GHWS env; set GHP_BASE_URL to repo Pages Remove the GHWS environment variable from the ansible-test-windows workflow since it is dynamically computed earlier and not needed in the job env. In the release workflow, replace the GHP_BASE_URL env reference with an explicit GitHub Pages URL constructed from the repository owner and repository name (https://.github.io/), simplifying environment resolution for releases. --- .github/workflows/ansible-test-windows.yml | 1 - .github/workflows/release.yml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ansible-test-windows.yml b/.github/workflows/ansible-test-windows.yml index 0ab133a0..4c1f8998 100644 --- a/.github/workflows/ansible-test-windows.yml +++ b/.github/workflows/ansible-test-windows.yml @@ -74,7 +74,6 @@ jobs: env: NAMESPACE: lowlydba # zizmor: ignore[template-injection] -- Static value COLLECTION_NAME: sqlserver # zizmor: ignore[template-injection] -- Static value - GHWS: ${{ env.GHWS }} # zizmor: ignore[template-injection] -- Dynamically computed in earlier step GROUP: ${{ matrix.group }} # zizmor: ignore[template-injection] -- Matrix value from controlled environment ANSIBLE: ${{ matrix.ansible }} # zizmor: ignore[template-injection] -- Matrix value from controlled environment PYTHON: python3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd0ff752..ccc17a0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: contents: write # Required for creating releases and tags env: VERSION: ${{ github.event.inputs.version }} # zizmor: ignore[template-injection] -- User input is required for this workflow - GHP_BASE_URL: ${{ env.GHP_BASE_URL }} # zizmor: ignore[template-injection] -- Set at workflow level + GHP_BASE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} GALAXY_API_KEY: ${{ secrets.GALAXY_API_KEY }} # zizmor: ignore[secrets-outside-env] -- Galaxy API key needed for publishing; workflow_dispatch only GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # zizmor: ignore[secrets-outside-env] -- GitHub token needed for release creation; workflow_dispatch only steps: From d03322c513e9c59145808913bfc06b6d22c79965 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 15:54:15 -0400 Subject: [PATCH 19/21] Update user_role.ps1 --- plugins/modules/user_role.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 595021f1..b2c77571 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -179,9 +179,15 @@ else { $desiredRoles = @() if ($hasSet) { - $desiredRoles = [array]($roles['set'] | Sort-Object) - if ($currentRoleMembership.Count -eq 0 -and $desiredRoles.Count -eq 0) { + $desiredRoles = @($roles['set'] | Sort-Object) + if ($desiredRoles.Count -eq 0) { + # set: [] — remove all current roles $toAdd = @() + $toRemove = @($currentRoleMembership) + } + elseif ($currentRoleMembership.Count -eq 0) { + # no current roles — add all desired + $toAdd = @($desiredRoles) $toRemove = @() } else { From e4e78531b51ca629c679c0890bcf0e19fcb26f66 Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 16:04:08 -0400 Subject: [PATCH 20/21] Update ansible-test-windows.yml --- .github/workflows/ansible-test-windows.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ansible-test-windows.yml b/.github/workflows/ansible-test-windows.yml index 4c1f8998..1ab0e8ef 100644 --- a/.github/workflows/ansible-test-windows.yml +++ b/.github/workflows/ansible-test-windows.yml @@ -75,7 +75,6 @@ jobs: NAMESPACE: lowlydba # zizmor: ignore[template-injection] -- Static value COLLECTION_NAME: sqlserver # zizmor: ignore[template-injection] -- Static value GROUP: ${{ matrix.group }} # zizmor: ignore[template-injection] -- Matrix value from controlled environment - ANSIBLE: ${{ matrix.ansible }} # zizmor: ignore[template-injection] -- Matrix value from controlled environment PYTHON: python3 CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] -- Codecov token needed for coverage uploads; environment protection would block PR-triggered runs WORKSPACE: ${{ github.workspace }} @@ -152,13 +151,11 @@ jobs: Add-Content -LiteralPath $env:GITHUB_ENV -Value "GHWS=$ws" # Override break-sys-pkg defaults, because we don't need to bother with python venv for CI - - name: Install ansible-base - env: - ANSIBLE: ${{ matrix.ansible }} + - name: Install ansible-base (${{ matrix.ansible }}) # zizmor: ignore[template-injection] -- matrix.ansible is a controlled enum value (stable-2.19, stable-2.20, devel) run: | python3 -m pip config set global.break-system-packages true python3 -m pip install --upgrade setuptools pypsrp --disable-pip-version-check --retries 10 - python3 -m pip install "https://github.com/ansible/ansible/archive/$ANSIBLE.tar.gz" --disable-pip-version-check --retries 10 + python3 -m pip install "https://github.com/ansible/ansible/archive/${{ matrix.ansible }}.tar.gz" --disable-pip-version-check --retries 10 - name: Install collection dependencies id: collection-dependency From 5738f98ee5e0d0b1d40d441b45bf85a09990133b Mon Sep 17 00:00:00 2001 From: John McCall Date: Sat, 11 Apr 2026 16:16:47 -0400 Subject: [PATCH 21/21] Update ansible-test-windows.yml --- .github/workflows/ansible-test-windows.yml | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ansible-test-windows.yml b/.github/workflows/ansible-test-windows.yml index 1ab0e8ef..cdd3ae49 100644 --- a/.github/workflows/ansible-test-windows.yml +++ b/.github/workflows/ansible-test-windows.yml @@ -192,23 +192,14 @@ jobs: sa-password: L0wlydb4 version: 2022 - - name: Run integration test - env: - GHWS: ${{ env.GHWS }} - NAMESPACE: ${{ env.NAMESPACE }} - COLLECTION_NAME: ${{ env.COLLECTION_NAME }} - GROUP: ${{ env.GROUP }} + - name: Run integration test # zizmor: ignore[template-injection] -- env.GHWS is a computed path; matrix values are controlled enums run: | - pushd "$GHWS/ansible_collections/$NAMESPACE/$COLLECTION_NAME" - ansible-test windows-integration -v --color --retry-on-error --continue-on-error --diff --coverage --requirements windows/group/$GROUP/ - - - name: Generate coverage report - env: - GHWS: ${{ env.GHWS }} - NAMESPACE: ${{ env.NAMESPACE }} - COLLECTION_NAME: ${{ env.COLLECTION_NAME }} + pushd "${{ env.GHWS }}/ansible_collections/${{env.NAMESPACE}}/${{env.COLLECTION_NAME}}" + ansible-test windows-integration -v --color --retry-on-error --continue-on-error --diff --coverage --requirements windows/group/${{ matrix.group }}/ + + - name: Generate coverage report # zizmor: ignore[template-injection] -- env.GHWS is a computed path run: | - pushd "$GHWS/ansible_collections/$NAMESPACE/$COLLECTION_NAME" + pushd "${{ env.GHWS }}/ansible_collections/${{env.NAMESPACE}}/${{env.COLLECTION_NAME}}" ansible-test coverage xml -v --requirements # See the reports at https://codecov.io/gh/lowlydba/lowlydba.sqlserver