From 837012b24d341e19f380bef82e2d255800a7cbc2 Mon Sep 17 00:00:00 2001
From: John McCall <john@lowlydba.com>
Date: Sun, 15 Dec 2024 15:40:13 -0500
Subject: [PATCH] feat: add user_role module (#292)

* feat: add user_role module

* chore: add changelog/release

* fix: remove force param, not supported

* fix: ignore errors when testing errors

* fix: add missing state for drop test
---
 CHANGELOG.rst                                 |  18 +++
 changelogs/changelog.yaml                     |  13 ++
 galaxy.yml                                    |   2 +-
 plugins/modules/user_role.ps1                 | 121 ++++++++++++++++++
 plugins/modules/user_role.py                  |  70 ++++++++++
 tests/integration/targets/user_role/aliases   |   2 +
 .../targets/user_role/meta/main.yml           |   3 +
 .../targets/user_role/tasks/main.yml          | 119 +++++++++++++++++
 .../targets/win_restore/tasks/main.yml        |   1 -
 .../integration/targets/win_user_role/aliases |   5 +
 .../targets/win_user_role/meta/main.yml       |   3 +
 11 files changed, 355 insertions(+), 2 deletions(-)
 create mode 100644 plugins/modules/user_role.ps1
 create mode 100644 plugins/modules/user_role.py
 create mode 100644 tests/integration/targets/user_role/aliases
 create mode 100644 tests/integration/targets/user_role/meta/main.yml
 create mode 100644 tests/integration/targets/user_role/tasks/main.yml
 create mode 100644 tests/integration/targets/win_user_role/aliases
 create mode 100644 tests/integration/targets/win_user_role/meta/main.yml

diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 23bc1fda..9500c36c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,6 +4,24 @@ lowlydba.sqlserver Release Notes
 
 .. contents:: Topics
 
+v2.4.0
+======
+
+Release Summary
+---------------
+
+New role user_role added to allow adding/removing database roles for users!
+
+Minor Changes
+-------------
+
+- Add new user_role module to manage users' membership to database roles (https://github.com/lowlydba/lowlydba.sqlserver/pull/292).
+
+New Modules
+-----------
+
+- user_role - Configures a user's role in a database.
+
 v2.3.6
 ======
 
diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml
index 9e945bfa..ce5b43ad 100644
--- a/changelogs/changelog.yaml
+++ b/changelogs/changelog.yaml
@@ -531,3 +531,16 @@ releases:
     fragments:
     - 288-fix-agent-schedule.yml
     release_date: '2024-12-08'
+  2.4.0:
+    changes:
+      minor_changes:
+      - Add new user_role module to manage users' membership to database roles (https://github.com/lowlydba/lowlydba.sqlserver/pull/292).
+      release_summary: New role user_role added to allow adding/removing database
+        roles for users!
+    fragments:
+    - 292-add-user-role-module.yml
+    modules:
+    - description: Configures a user's role in a database.
+      name: user_role
+      namespace: ''
+    release_date: '2024-12-15'
diff --git a/galaxy.yml b/galaxy.yml
index 84b1eb80..7decbcac 100644
--- a/galaxy.yml
+++ b/galaxy.yml
@@ -2,7 +2,7 @@
 
 namespace: lowlydba
 name: sqlserver
-version: 2.3.6
+version: 2.4.0
 readme: README.md
 authors:
   - John McCall (github.com/lowlydba)
diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1
new file mode 100644
index 00000000..8a9f7fe4
--- /dev/null
+++ b/plugins/modules/user_role.ps1
@@ -0,0 +1,121 @@
+#!powershell
+# -*- coding: utf-8 -*-
+
+# (c) 2022, John McCall (@lowlydba)
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+#AnsibleRequires -CSharpUtil Ansible.Basic
+#AnsibleRequires -PowerShell ansible_collections.lowlydba.sqlserver.plugins.module_utils._SqlServerUtils
+#Requires -Modules @{ ModuleName="dbatools"; ModuleVersion="2.0.0" }
+
+$ErrorActionPreference = "Stop"
+
+$spec = @{
+    supports_check_mode = $true
+    options = @{
+        database = @{type = 'str'; required = $true }
+        username = @{type = 'str'; required = $true }
+        role = @{type = 'str'; required = $true }
+        state = @{type = 'str'; required = $false; default = 'present'; choices = @('present', 'absent') }
+    }
+}
+
+$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-LowlyDbaSqlServerAuthSpec))
+$sqlInstance, $sqlCredential = Get-SqlCredential -Module $module
+$username = $module.Params.username
+$database = $module.Params.database
+$role = $module.Params.role
+$state = $module.Params.state
+$checkMode = $module.CheckMode
+
+$module.Result.changed = $false
+
+$getUserSplat = @{
+    SqlInstance = $sqlInstance
+    SqlCredential = $sqlCredential
+    Database = $database
+    User = $username
+    EnableException = $true
+}
+$getRoleSplat = @{
+    SqlInstance = $sqlInstance
+    SqlCredential = $sqlCredential
+    Database = $database
+    Role = $role
+    EnableException = $true
+}
+$getRoleMemberSplat = @{
+    SqlInstance = $sqlInstance
+    SqlCredential = $sqlCredential
+    Database = $database
+    Role = $role
+    IncludeSystemUser = $true
+    EnableException = $true
+}
+
+# Verify user and role exist, DBATools currently fails silently
+$existingUser = Get-DbaDbUser @getUserSplat
+if ($null -eq $existingUser) {
+    $module.FailJson("User [$username] does not exist in database [$database].")
+}
+$existingRole = Get-DbaDbRole @getRoleSplat
+if ($null -eq $existingRole) {
+    $module.FailJson("Role [$role] does not exist in database [$database].")
+}
+
+# Get role members
+$existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat
+
+if ($state -eq "absent") {
+    if ($existingRoleMembers.username -contains $username) {
+        try {
+            $removeRoleMemberSplat = @{
+                SqlInstance = $sqlInstance
+                SqlCredential = $sqlCredential
+                User = $username
+                Database = $database
+                Role = $role
+                EnableException = $true
+                WhatIf = $checkMode
+                Confirm = $false
+            }
+            $output = Remove-DbaDbRoleMember @removeRoleMemberSplat
+            $module.Result.changed = $true
+        }
+        catch {
+            $module.FailJson("Removing user [$username] from database role [$role] failed: $($_.Exception.Message)", $_)
+        }
+    }
+}
+elseif ($state -eq "present") {
+    # Add user to role
+    if ($existingRoleMembers.username -notcontains $username) {
+        try {
+            $addRoleMemberSplat = @{
+                SqlInstance = $sqlInstance
+                SqlCredential = $sqlCredential
+                User = $username
+                Database = $database
+                Role = $role
+                EnableException = $true
+                WhatIf = $checkMode
+                Confirm = $false
+            }
+            $output = Add-DbaDbRoleMember @addRoleMemberSplat
+            $module.Result.changed = $true
+        }
+        catch {
+            $module.FailJson("Adding user [$username] to database role [$role] failed: $($_.Exception.Message)", $_)
+        }
+    }
+}
+try {
+    if ($null -ne $output) {
+        $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
new file mode 100644
index 00000000..f120ba74
--- /dev/null
+++ b/plugins/modules/user_role.py
@@ -0,0 +1,70 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# (c) 2022, John McCall (@lowlydba)
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+DOCUMENTATION = r'''
+---
+module: user_role
+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
+options:
+  database:
+    description:
+      - Database for the user.
+    type: str
+    required: true
+  username:
+    description:
+      - Name of the user.
+    type: str
+    required: true
+  role:
+    description:
+      - The database role for the user to be modified.
+    type: str
+    required: true
+author: "John McCall (@lowlydba)"
+requirements:
+  - L(dbatools,https://www.powershellgallery.com/packages/dbatools/) PowerShell module
+extends_documentation_fragment:
+  - 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
+  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:
+    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:
+    sql_instance: sql-01.myco.io
+    username: TheIntern
+    database: InternProject1
+    role: db_intern
+    state: absent
+'''
+
+RETURN = r'''
+data:
+  description: Output from the C(Remove-DbaDbRoleMember), (Get-DbaDbRoleMember), or C(Add-DbaDbRoleMember) functions.
+  returned: success, but not in check_mode.
+  type: dict
+'''
diff --git a/tests/integration/targets/user_role/aliases b/tests/integration/targets/user_role/aliases
new file mode 100644
index 00000000..4f4b6b91
--- /dev/null
+++ b/tests/integration/targets/user_role/aliases
@@ -0,0 +1,2 @@
+context/target
+setup/once/setup_sqlserver
diff --git a/tests/integration/targets/user_role/meta/main.yml b/tests/integration/targets/user_role/meta/main.yml
new file mode 100644
index 00000000..a3309752
--- /dev/null
+++ b/tests/integration/targets/user_role/meta/main.yml
@@ -0,0 +1,3 @@
+---
+dependencies:
+  - setup_sqlserver_test_plugins
diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml
new file mode 100644
index 00000000..cf56b2aa
--- /dev/null
+++ b/tests/integration/targets/user_role/tasks/main.yml
@@ -0,0 +1,119 @@
+---
+- name: Var block
+  vars:
+    login_name: "PhillipJFry"
+    plain_password: "P0pS3cret!23$%"
+    password_expiration_enabled: false
+    password_policy_enforced: false
+    password_must_change: false
+    enabled: false
+    default_database: "master"
+    language: "us_english"
+    default_schema: "dbo"
+    username: "PhillipJFry"
+    database: "master"
+    role: "db_owner"
+  module_defaults:
+    lowlydba.sqlserver.login:
+      sql_instance: "{{ sqlserver_instance }}"
+      sql_username: "{{ sqlserver_username }}"
+      sql_password: "{{ sqlserver_password }}"
+      default_database: "{{ default_database }}"
+      login: "{{ login_name }}"
+      password: "{{ plain_password }}"
+      password_expiration_enabled: "{{ password_expiration_enabled }}"
+      password_must_change: "{{ password_must_change }}"
+      enabled: "{{ enabled }}"
+      language: "{{ language }}"
+      state: present
+    lowlydba.sqlserver.user:
+      sql_instance: "{{ sqlserver_instance }}"
+      sql_username: "{{ sqlserver_username }}"
+      sql_password: "{{ sqlserver_password }}"
+      database: "{{ database }}"
+      login: "{{ login_name }}"
+      username: "{{ username }}"
+      default_schema: "{{ default_schema }}"
+      state: present
+    lowlydba.sqlserver.user_role:
+      sql_instance: "{{ sqlserver_instance }}"
+      sql_username: "{{ sqlserver_username }}"
+      sql_password: "{{ sqlserver_password }}"
+      database: "{{ database }}"
+      username: "{{ username }}"
+      role: "{{ role }}"
+      state: present
+  tags: ["sqlserver.user"]
+  block:
+    - name: Create login
+      lowlydba.sqlserver.login:
+      register: result
+    - assert:
+        that:
+          - result.data != None
+
+    - name: Create user
+      lowlydba.sqlserver.user:
+      register: result
+    - assert:
+        that:
+          - result.data != None
+          - result.data.ComputerName != None
+          - result.data.InstanceName != None
+          - result.data.SqlInstance != None
+          - result.data.Database == "{{ database }}"
+          - result.data.DefaultSchema == "{{ default_schema }}"
+          - result.data.Login == "{{ login_name }}"
+          - result.data.Name == "{{ username }}"
+
+    - name: Add user to database role
+      lowlydba.sqlserver.user_role:
+      register: result
+    - assert:
+        that:
+          - result is changed
+
+    - name: Add user to non-existent database role
+      lowlydba.sqlserver.user_role:
+        role: db_IMadeThisOneUp
+      register: error_result
+      failed_when: error_result.failed
+      ignore_errors: true
+    - assert:
+        that:
+          - error_result.failed == true
+          - "'Role [db_IMadeThisOneUp] does not exist in database' in error_result.msg"
+
+    - name: Add non-existent user to database role
+      lowlydba.sqlserver.user_role:
+        username: NewUserWhoThis
+      register: error_result
+      failed_when: error_result.failed
+      ignore_errors: true
+    - assert:
+        that:
+          - error_result.failed == true
+          - "'User [NewUserWhoThis] does not exist in database' in error_result.msg"
+
+    - name: Add user again to database role
+      lowlydba.sqlserver.user_role:
+      register: result
+    - assert:
+        that:
+          - result is not changed
+
+    - name: Remove user from database role
+      lowlydba.sqlserver.user_role:
+        state: "absent"
+      register: result
+    - assert:
+        that:
+          - result is changed
+
+  always:
+    - name: Drop user
+      lowlydba.sqlserver.user:
+        state: "absent"
+    - name: Drop login
+      lowlydba.sqlserver.login:
+        state: "absent"
diff --git a/tests/integration/targets/win_restore/tasks/main.yml b/tests/integration/targets/win_restore/tasks/main.yml
index af86b376..88841633 100644
--- a/tests/integration/targets/win_restore/tasks/main.yml
+++ b/tests/integration/targets/win_restore/tasks/main.yml
@@ -48,4 +48,3 @@
         that:
           - error_result.failed == true
           - "'already exists' in error_result.msg"
-          - error_result.msg != None
diff --git a/tests/integration/targets/win_user_role/aliases b/tests/integration/targets/win_user_role/aliases
new file mode 100644
index 00000000..486f09cf
--- /dev/null
+++ b/tests/integration/targets/win_user_role/aliases
@@ -0,0 +1,5 @@
+windows/all
+windows/group/1
+context/target
+setup/once/setup_win_sqlserver
+needs/target/user_role
diff --git a/tests/integration/targets/win_user_role/meta/main.yml b/tests/integration/targets/win_user_role/meta/main.yml
new file mode 100644
index 00000000..5b743c06
--- /dev/null
+++ b/tests/integration/targets/win_user_role/meta/main.yml
@@ -0,0 +1,3 @@
+---
+dependencies:
+  - user_role