diff --git a/.github/workflows/ansible-test-windows.yml b/.github/workflows/ansible-test-windows.yml index e9e79e8c..cdd3ae49 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: @@ -73,9 +74,7 @@ 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 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 @@ -195,26 +192,27 @@ 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 - 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) }} 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: 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/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) 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/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 8a9f7fe4..b2c77571 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -15,107 +15,323 @@ $spec = @{ 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') } + roles = @{type = 'dict'; required = $false } + role = @{type = 'str'; required = $false } + state = @{type = 'str'; required = $false; choices = @('present', 'absent') } } + mutually_exclusive = @( + , @('role', 'roles') + ) + 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 +$PSDefaultParameterValues = @{ "*:EnableException" = $true; "*:Confirm" = $false; "*:WhatIf" = $checkMode } -$getUserSplat = @{ - SqlInstance = $sqlInstance - SqlCredential = $sqlCredential - Database = $database - User = $username - EnableException = $true +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) } -$getRoleSplat = @{ + +$module.Result.changed = $false + +$commonParamSplat = @{ 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 - EnableException = $true + User = $username } - -# 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) { + # Set default state for legacy mode if not specified + if ($null -eq $state) { + $state = 'present' + } -if ($state -eq "absent") { - if ($existingRoleMembers.username -contains $username) { + $getRoleSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + Database = $database + Role = $role + } + $existingRole = Get-DbaDbRole @getRoleSplat + if ($null -eq $existingRole) { + $module.FailJson("Role [$role] does not exist in database [$database].") + } + + if ($state -eq "absent") { try { - $removeRoleMemberSplat = @{ + $getRoleMemberSplat = @{ SqlInstance = $sqlInstance SqlCredential = $sqlCredential - User = $username Database = $database Role = $role - EnableException = $true - WhatIf = $checkMode - Confirm = $false + IncludeSystemUser = $true + } + $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat + + if ($existingRoleMembers.UserName -contains $username) { + $removeRoleMemberSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + User = $username + Database = $database + Role = $role + } + $output = Remove-DbaDbRoleMember @removeRoleMemberSplat + $module.Result.changed = $true + if ($null -ne $output) { + $resultData = ConvertTo-SerializableObject -InputObject $output + $module.Result.data = $resultData + } } - $output = Remove-DbaDbRoleMember @removeRoleMemberSplat - $module.Result.changed = $true } 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 - EnableException = $true - WhatIf = $checkMode - Confirm = $false + IncludeSystemUser = $true + } + $existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat + + if ($existingRoleMembers.UserName -notcontains $username) { + $addRoleMemberSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + User = $username + Database = $database + Role = $role + } + $output = Add-DbaDbRoleMember @addRoleMemberSplat + $module.Result.changed = $true + if ($null -ne $output) { + $resultData = ConvertTo-SerializableObject -InputObject $output + $module.Result.data = $resultData + } } - $output = Add-DbaDbRoleMember @addRoleMemberSplat - $module.Result.changed = $true } 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 { + $rolesSetSpecified = $null -ne $roles['set'] + $rolesAddSpecified = $null -ne $roles['add'] + $rolesRemoveSpecified = $null -ne $roles['remove'] + + # 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 ($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 ($rolesSetSpecified -and ($rolesAddSpecified -or $rolesRemoveSpecified)) { + $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) + if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() } + } + catch { + $module.FailJson("Failure getting current role membership: $($_.Exception.Message)", $_) + } + + $desiredRoles = @() + + if ($hasSet) { + $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 { + $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 + } + 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 + } + 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 + } + 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 = @($roles['remove'] | Where-Object { $_ -in $currentRoleMembership }) + + if ($toRemove.Count -gt 0) { + foreach ($roleToRemove in $toRemove) { + try { + $removeRoleMemberSplat = @{ + SqlInstance = $sqlInstance + SqlCredential = $sqlCredential + User = $username + Database = $database + Role = $roleToRemove + } + 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)", $_) -} diff --git a/plugins/modules/user_role.py b/plugins/modules/user_role.py index f120ba74..47129280 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''' @@ -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,48 @@ - Name of the user. type: str required: true + roles: + 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. 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. 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. 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. An empty list removes all role memberships. + type: list + elements: str role: description: - The database role for the user to be modified. + - "B(Deprecated:) This parameter is deprecated and will be removed in version 3.0.0. Use I(roles) instead." type: str - required: true + required: false + state: + description: + - Desired state of the user role membership. + - "Only applicable when using the I(role) parameter (legacy mode). Cannot be used with I(roles)." + type: str + choices: + - present + - absent author: "John McCall (@lowlydba)" requirements: - L(dbatools,https://www.powershellgallery.com/packages/dbatools/) PowerShell module @@ -34,37 +72,84 @@ - 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 - role: db_intern - state: absent + 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 + 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. - 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 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 (or would be added in check_mode). + type: list + elements: str + removed: + 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 ec5936b7..2190eda4 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,283 @@ - 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 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: 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 }}" + 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 + - error_result.msg is search("state.*parameter.*not.*supported") always: - name: Drop user