From 7b3d893f2de0771c70fddc2e3662bd018fe13e58 Mon Sep 17 00:00:00 2001 From: Andrew Saraceni Date: Mon, 31 Jul 2017 14:10:57 -0400 Subject: [PATCH] New Module: Manage Windows local group membership (win_group_member) (#26307) * initial commit for win_group_member module * fix variable name change for split_adspath * correct ordering of examples/return data to match documentation verbiage * change tests setup/teardown to use new group rather than an inbult group --- .../modules/windows/win_group_member.ps1 | 230 ++++++++++++++++ .../modules/windows/win_group_member.py | 101 +++++++ .../targets/win_group_member/aliases | 1 + .../targets/win_group_member/tasks/main.yml | 31 +++ .../targets/win_group_member/tasks/tests.yml | 258 ++++++++++++++++++ 5 files changed, 621 insertions(+) create mode 100644 lib/ansible/modules/windows/win_group_member.ps1 create mode 100644 lib/ansible/modules/windows/win_group_member.py create mode 100644 test/integration/targets/win_group_member/aliases create mode 100644 test/integration/targets/win_group_member/tasks/main.yml create mode 100644 test/integration/targets/win_group_member/tasks/tests.yml diff --git a/lib/ansible/modules/windows/win_group_member.ps1 b/lib/ansible/modules/windows/win_group_member.ps1 new file mode 100644 index 0000000000..52624a31d5 --- /dev/null +++ b/lib/ansible/modules/windows/win_group_member.ps1 @@ -0,0 +1,230 @@ +#!powershell + +# (c) 2017, Andrew Saraceni +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# WANT_JSON +# POWERSHELL_COMMON + +$ErrorActionPreference = "Stop" + +function Test-GroupMember { + <# + .SYNOPSIS + Parse desired member into domain and username. + Also, ensure member can be resolved/exists on the target system by checking its SID. + .NOTES + Returns a hashtable of the same type as returned from Get-GroupMember. + Accepts username (users, groups) and domains in the following formats: + - username + - .\username + - SERVERNAME\username + - NT AUTHORITY\username + - DOMAIN\username + - username@DOMAIN + #> + param( + [String]$GroupMember + ) + + $parsed_member = @{ + domain = $null + username = $null + combined = $null + } + + # Split domain and account name into separate values + # '\' or '@' needs additional parsing, otherwise assume local computer + + if ($GroupMember -match "\\") { + # DOMAIN\username + $split_member = $GroupMember.Split("\") + + if ($split_member[0] -in @($env:COMPUTERNAME, ".")) { + # Local + $parsed_member.domain = $env:COMPUTERNAME + } + else { + # Domain or service (i.e. NT AUTHORITY) + $parsed_member.domain = $split_member[0] + } + $parsed_member.username = $split_member[1] + } + elseif ($GroupMember -match "@") { + # username@DOMAIN + $parsed_member.domain = $GroupMember.Split("@")[1] + $parsed_member.username = $GroupMember.Split("@")[0] + } + else { + # Local + $parsed_member.domain = $env:COMPUTERNAME + $parsed_member.username = $GroupMember + } + + if ($parsed_member.domain -match "\.") { + # Assume FQDN was passed - change to NetBIOS/short name for later ADSI membership comparisons + $netbios_name = (Get-CimInstance -ClassName Win32_NTDomain -Filter "DnsForestName = '$($parsed_member.domain)'").DomainName + + if (!$netbios_name) { + Fail-Json -obj $result -message "Could not resolve NetBIOS name for domain $($parsed_member.domain)" + } + $parsed_member.domain = $netbios_name + } + + # Set SID check arguments, and 'combined' for later comparison and output reporting + if ($parsed_member.domain -eq $env:COMPUTERNAME) { + $sid_check_args = @($parsed_member.username) + $parsed_member.combined = "{0}" -f $parsed_member.username + } + else { + $sid_check_args = @($parsed_member.domain, $parsed_member.username) + $parsed_member.combined = "{0}\{1}" -f $parsed_member.domain, $parsed_member.username + } + + try { + $user_object = New-Object -TypeName System.Security.Principal.NTAccount -ArgumentList $sid_check_args + $user_object.Translate([System.Security.Principal.SecurityIdentifier]) + } + catch { + Fail-Json -obj $result -message "Could not resolve group member $GroupMember" + } + + return $parsed_member +} + +function Get-GroupMember { + <# + .SYNOPSIS + Retrieve group members for a given group, and return in a common format. + .NOTES + Returns an array of hashtables of the same type as returned from Test-GroupMember. + #> + param( + [System.DirectoryServices.DirectoryEntry]$Group + ) + + $members = @() + + $current_members = $Group.psbase.Invoke("Members") | ForEach-Object { + ([ADSI]$_).InvokeGet("ADsPath") + } + + foreach ($current_member in $current_members) { + $parsed_member = @{ + domain = $null + username = $null + combined = $null + } + + $rootless_adspath = $current_member.Replace("WinNT://", "") + $split_adspath = $rootless_adspath.Split("/") + + if ($split_adspath -match $env:COMPUTERNAME) { + # Local + $parsed_member.domain = $env:COMPUTERNAME + $parsed_member.username = $split_adspath[-1] + $parsed_member.combined = $split_adspath[-1] + } + elseif ($split_adspath.Count -eq 1 -and $split_adspath[0] -like "S-1*") { + # Broken SID + $parsed_member.username = $split_adspath[0] + $parsed_member.combined = $split_adspath[0] + } + else { + # Domain or service (i.e. NT AUTHORITY) + $parsed_member.domain = $split_adspath[0] + $parsed_member.username = $split_adspath[1] + $parsed_member.combined = "{0}\{1}" -f $split_adspath[0], $split_adspath[1] + } + + $members += $parsed_member + } + + return $members +} + +$params = Parse-Args $args -supports_check_mode $true +$check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false + +$name = Get-AnsibleParam -obj $params -name "name" -type "str" -failifempty $true +$members = Get-AnsibleParam -obj $params -name "members" -type "list" -failifempty $true +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "present" -validateset "present","absent" + +$result = @{ + changed = $false + name = $name +} +if ($state -eq "present") { + $result.added = @() +} +elseif ($state -eq "absent") { + $result.removed = @() +} + +$adsi = [ADSI]"WinNT://$env:COMPUTERNAME" +$group = $adsi.Children | Where-Object { $_.SchemaClassName -eq "group" -and $_.Name -eq $name } + +if (!$group) { + Fail-Json -obj $result -message "Could not find local group $name" +} + +$current_members = Get-GroupMember -Group $group + +foreach ($member in $members) { + $group_member = Test-GroupMember -GroupMember $member + + $user_in_group = $false + foreach ($current_member in $current_members) { + if ($current_member.combined -eq $group_member.combined) { + $user_in_group = $true + break + } + } + + $member_adspath = "WinNT://{0}/{1}" -f $group_member.domain, $group_member.username + + try { + if ($state -eq "present" -and !$user_in_group) { + if (!$check_mode) { + $group.Add($member_adspath) + $result.added += $group_member.combined + } + $result.changed = $true + } + elseif ($state -eq "absent" -and $user_in_group) { + if (!$check_mode) { + $group.Remove($member_adspath) + $result.removed += $group_member.combined + } + $result.changed = $true + } + } + catch { + Fail-Json -obj $result -message $_.Exception.Message + } +} + +$final_members = Get-GroupMember -Group $group + +if ($final_members) { + $result.members = [Array]$final_members.combined +} +else { + $result.members = @() +} + +Exit-Json -obj $result diff --git a/lib/ansible/modules/windows/win_group_member.py b/lib/ansible/modules/windows/win_group_member.py new file mode 100644 index 0000000000..ac1a581f7b --- /dev/null +++ b/lib/ansible/modules/windows/win_group_member.py @@ -0,0 +1,101 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2017, Andrew Saraceni +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +# this is a windows documentation stub. actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: win_group_member +version_added: "2.4" +short_description: Manage Windows local group membership +description: + - Allows the addition and removal of local, service and domain users, + and domain groups from a local group. +options: + name: + description: + - Name of the local group to manage membership on. + required: true + members: + description: + - A list of members to ensure are present/absent from the group. + - Accepts local users as username, .\username, and SERVERNAME\username. + - Accepts domain users and groups as DOMAIN\username and username@DOMAIN. + - Accepts service users as NT AUTHORITY\username. + required: true + state: + description: + - Desired state of the members in the group. + choices: + - present + - absent + default: present +author: + - Andrew Saraceni (@andrewsaraceni) +''' + +EXAMPLES = r''' +- name: Add a local and domain user to a local group + win_group_member: + name: Remote Desktop Users + members: + - NewLocalAdmin + - DOMAIN\TestUser + state: present + +- name: Remove a domain group and service user from a local group + win_group_member: + name: Backup Operators + members: + - DOMAIN\TestGroup + - NT AUTHORITY\SYSTEM + state: absent +''' + +RETURN = r''' +name: + description: The name of the target local group. + returned: always + type: string + sample: Administrators +added: + description: A list of members added when C(state) is C(present); this is + empty if no members are added. + returned: success and C(state) is C(present) + type: list + sample: ["NewLocalAdmin", "DOMAIN\\TestUser"] +removed: + description: A list of members removed when C(state) is C(absent); this is + empty if no members are removed. + returned: success and C(state) is C(absent) + type: list + sample: ["DOMAIN\\TestGroup", "NT AUTHORITY\\SYSTEM"] +members: + description: A list of all local group members at completion; this is empty + if the group contains no members. + returned: success + type: list + sample: ["DOMAIN\\TestUser", "NewLocalAdmin"] +''' diff --git a/test/integration/targets/win_group_member/aliases b/test/integration/targets/win_group_member/aliases new file mode 100644 index 0000000000..c6d6198167 --- /dev/null +++ b/test/integration/targets/win_group_member/aliases @@ -0,0 +1 @@ +windows/ci/group3 diff --git a/test/integration/targets/win_group_member/tasks/main.yml b/test/integration/targets/win_group_member/tasks/main.yml new file mode 100644 index 0000000000..09902eb68e --- /dev/null +++ b/test/integration/targets/win_group_member/tasks/main.yml @@ -0,0 +1,31 @@ +- name: Gather facts + setup: + +- name: Remove potentially leftover test group + win_group: &wg_absent + name: WinGroupMemberTest + state: absent + +- name: Add new test group + win_group: + name: WinGroupMemberTest + state: present + +- name: Run tests for win_group_member + block: + + - name: Test in normal mode + include_tasks: tests.yml + vars: + win_local_group: WinGroupMemberTest + in_check_mode: no + + - name: Test in check-mode + include_tasks: tests.yml + vars: + win_local_group: WinGroupMemberTest + in_check_mode: yes + check_mode: yes + +- name: Remove test group + win_group: *wg_absent diff --git a/test/integration/targets/win_group_member/tasks/tests.yml b/test/integration/targets/win_group_member/tasks/tests.yml new file mode 100644 index 0000000000..bdcbbf7a4a --- /dev/null +++ b/test/integration/targets/win_group_member/tasks/tests.yml @@ -0,0 +1,258 @@ +# Test code for win_group_member + +# (c) 2017, Andrew Saraceni +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +- name: Remove potentially leftover group members + win_group_member: + name: "{{ win_local_group }}" + members: + - Administrator + - Guest + - NT AUTHORITY\SYSTEM + - NT AUTHORITY\NETWORK SERVICE + state: absent + + +- name: Add user to fake group + win_group_member: + name: FakeGroup + members: + - Administrator + state: present + register: add_user_to_fake_group + failed_when: add_user_to_fake_group.changed != false or add_user_to_fake_group.msg != "Could not find local group FakeGroup" + + +- name: Add fake local user + win_group_member: + name: "{{ win_local_group }}" + members: + - FakeUser + state: present + register: add_fake_local_user + failed_when: add_fake_local_user.changed != false or add_fake_local_user.msg != "Could not resolve group member FakeUser" + + +- name: Add fake FQDN domain user + win_group_member: + name: "{{ win_local_group }}" + members: + - FakeUser@domain.fake + state: present + register: add_fake_fqdn_domain_user + failed_when: add_fake_fqdn_domain_user.changed != false or add_fake_fqdn_domain_user.msg != "Could not resolve NetBIOS name for domain domain.fake" + + +- name: Add users to group + win_group_member: &wgm_present + name: "{{ win_local_group }}" + members: + - Administrator + - Guest + - NT AUTHORITY\SYSTEM + state: present + register: add_users_to_group + +- name: Test add_users_to_group (normal mode) + assert: + that: + - add_users_to_group.changed == true + - add_users_to_group.added == ["Administrator", "Guest", "NT AUTHORITY\\SYSTEM"] + - add_users_to_group.members == ["Administrator", "Guest", "NT AUTHORITY\\SYSTEM"] + when: not in_check_mode + +- name: Test add_users_to_group (check-mode) + assert: + that: + - add_users_to_group.changed == true + - add_users_to_group.added == [] + - add_users_to_group.members == [] + when: in_check_mode + + +- name: Add users to group (again) + win_group_member: *wgm_present + register: add_users_to_group_again + +- name: Test add_users_to_group_again (normal mode) + assert: + that: + - add_users_to_group_again.changed == false + - add_users_to_group_again.added == [] + - add_users_to_group_again.members == ["Administrator", "Guest", "NT AUTHORITY\\SYSTEM"] + when: not in_check_mode + + +- name: Add different syntax users to group (again) + win_group_member: + <<: *wgm_present + members: + - "{{ ansible_hostname }}\\Administrator" + - .\Guest + register: add_different_syntax_users_to_group_again + +- name: Test add_different_syntax_users_to_group_again (normal mode) + assert: + that: + - add_different_syntax_users_to_group_again.changed == false + - add_different_syntax_users_to_group_again.added == [] + - add_different_syntax_users_to_group_again.members == ["Administrator", "Guest", "NT AUTHORITY\\SYSTEM"] + when: not in_check_mode + +- name: Test add_different_syntax_users_to_group_again (check-mode) + assert: + that: + - add_different_syntax_users_to_group_again.changed == true + - add_different_syntax_users_to_group_again.added == [] + - add_different_syntax_users_to_group_again.members == [] + when: in_check_mode + + +- name: Add another user to group + win_group_member: &wgma_present + <<: *wgm_present + members: + - NT AUTHORITY\NETWORK SERVICE + register: add_another_user_to_group + +- name: Test add_another_user_to_group (normal mode) + assert: + that: + - add_another_user_to_group.changed == true + - add_another_user_to_group.added == ["NT AUTHORITY\\NETWORK SERVICE"] + - add_another_user_to_group.members == ["Administrator", "Guest", "NT AUTHORITY\\SYSTEM", "NT AUTHORITY\\NETWORK SERVICE"] + when: not in_check_mode + +- name: Test add_another_user_to_group (check-mode) + assert: + that: + - add_another_user_to_group.changed == true + - add_another_user_to_group.added == [] + - add_another_user_to_group.members == [] + when: in_check_mode + + +- name: Add another user to group (again) + win_group_member: *wgma_present + register: add_another_user_to_group_again + +- name: Test add_another_user_to_group_1_again (normal mode) + assert: + that: + - add_another_user_to_group_again.changed == false + - add_another_user_to_group_again.added == [] + - add_another_user_to_group_again.members == ["Administrator", "Guest", "NT AUTHORITY\\SYSTEM", "NT AUTHORITY\\NETWORK SERVICE"] + when: not in_check_mode + + +- name: Remove users from group + win_group_member: &wgm_absent + <<: *wgm_present + state: absent + register: remove_users_from_group + +- name: Test remove_users_from_group (normal mode) + assert: + that: + - remove_users_from_group.changed == true + - remove_users_from_group.removed == ["Administrator", "Guest", "NT AUTHORITY\\SYSTEM"] + - remove_users_from_group.members == ["NT AUTHORITY\\NETWORK SERVICE"] + when: not in_check_mode + +- name: Test remove_users_from_group (check-mode) + assert: + that: + - remove_users_from_group.changed == false + - remove_users_from_group.removed == [] + - remove_users_from_group.members == [] + when: in_check_mode + + +- name: Remove users from group (again) + win_group_member: *wgm_absent + register: remove_users_from_group_again + +- name: Test remove_users_from_group_again (normal mode) + assert: + that: + - remove_users_from_group_again.changed == false + - remove_users_from_group_again.removed == [] + - remove_users_from_group_again.members == ["NT AUTHORITY\\NETWORK SERVICE"] + when: not in_check_mode + + +- name: Remove different syntax users from group (again) + win_group_member: + <<: *wgm_absent + members: + - "{{ ansible_hostname }}\\Administrator" + - .\Guest + register: remove_different_syntax_users_from_group_again + +- name: Test remove_different_syntax_users_from_group_again (normal mode) + assert: + that: + - remove_different_syntax_users_from_group_again.changed == false + - remove_different_syntax_users_from_group_again.removed == [] + - remove_different_syntax_users_from_group_again.members == ["NT AUTHORITY\\NETWORK SERVICE"] + when: not in_check_mode + +- name: Test add_different_syntax_users_to_group_again (check-mode) + assert: + that: + - remove_different_syntax_users_from_group_again.changed == false + - remove_different_syntax_users_from_group_again.removed == [] + - remove_different_syntax_users_from_group_again.members == [] + when: in_check_mode + + +- name: Remove another user from group + win_group_member: &wgma_absent + <<: *wgm_absent + members: + - NT AUTHORITY\NETWORK SERVICE + register: remove_another_user_from_group + +- name: Test remove_another_user_from_group (normal mode) + assert: + that: + - remove_another_user_from_group.changed == true + - remove_another_user_from_group.removed == ["NT AUTHORITY\\NETWORK SERVICE"] + - remove_another_user_from_group.members == [] + when: not in_check_mode + +- name: Test remove_another_user_from_group (check-mode) + assert: + that: + - remove_another_user_from_group.changed == false + - remove_another_user_from_group.removed == [] + - remove_another_user_from_group.members == [] + when: in_check_mode + + +- name: Remove another user from group (again) + win_group_member: *wgma_absent + register: remove_another_user_from_group_again + +- name: Test remove_another_user_from_group_again (normal mode) + assert: + that: + - remove_another_user_from_group_again.changed == false + - remove_another_user_from_group_again.removed == [] + - remove_another_user_from_group_again.members == [] + when: not in_check_mode