#!powershell

# Copyright: (c) 2023, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

#AnsibleRequires -CSharpUtil Ansible.AccessToken
#AnsibleRequires -CSharpUtil Ansible.Basic
#AnsibleRequires -PowerShell ..module_utils._ADObject

Function Test-Credential {
    param(
        [String]$Username,
        [String]$Password,
        [String]$Domain = $null
    )
    if (($Username.ToCharArray()) -contains [char]'@') {
        # UserPrincipalName
        $Domain = $null # force $Domain to be null, to prevent undefined behaviour, as a domain name is already included in the username
    }
    elseif (($Username.ToCharArray()) -contains [char]'\') {
        # Pre Win2k Account Name
        $Domain = ($Username -split '\\')[0]
        $Username = ($Username -split '\\', 2)[-1]
    } # If no domain provided, so maybe local user, or domain specified separately.

    try {
        ([Ansible.AccessToken.TokenUtil]::LogonUser($Username, $Domain, $Password, "Network", "Default")).Dispose()
        return $true
    }
    catch [Ansible.AccessToken.Win32Exception] {
        # following errors indicate the creds are correct but the user was
        # unable to log on for other reasons, which we don't care about
        $success_codes = @(
            0x0000052F, # ERROR_ACCOUNT_RESTRICTION
            0x00000530, # ERROR_INVALID_LOGON_HOURS
            0x00000531, # ERROR_INVALID_WORKSTATION
            0x00000569  # ERROR_LOGON_TYPE_GRANTED
        )
        $failed_codes = @(
            0x0000052E, # ERROR_LOGON_FAILURE
            0x00000532, # ERROR_PASSWORD_EXPIRED
            0x00000701, # ERROR_ACCOUNT_EXPIRED
            0x00000773, # ERROR_PASSWORD_MUST_CHANGE
            0x00000533  # ERROR_ACCOUNT_DISABLED
        )

        if ($_.Exception.NativeErrorCode -in $failed_codes) {
            return $false
        }
        elseif ($_.Exception.NativeErrorCode -in $success_codes) {
            return $true
        }
        else {
            # an unknown failure, reraise exception
            throw $_
        }
    }
}

$setParams = @{
    PropertyInfo = @(
        [PSCustomObject]@{
            Name = 'account_locked'
            Option = @{
                choices = @(, $false)
                type = 'bool'
            }
            Attribute = 'LockedOut'
            Set = {
                param($Module, $ADParams, $SetParams, $ADObject)

                if ($ADObject.LockedOut) {
                    Unlock-ADAccount @ADParams -Identity $ADObject.ObjectGUID -WhatIf:$Module.CheckMode
                    $Module.Result.changed = $true
                }

                $Module.Diff.after.account_locked = $false
            }
        }

        [PSCustomObject]@{
            Name = 'city'
            Option = @{ type = 'str' }
            Attribute = 'City'
        }

        [PSCustomObject]@{
            Name = 'company'
            Option = @{ type = 'str' }
            Attribute = 'company'
        }

        [PSCustomObject]@{
            Name = 'country'
            Option = @{ type = 'str' }
            Attribute = 'Country'
        }

        [PSCustomObject]@{
            Name = 'delegates'
            Option = @{
                aliases = 'principals_allowed_to_delegate'
                type = 'dict'
                options = @{
                    add = @{ type = 'list'; elements = 'str' }
                    remove = @{ type = 'list'; elements = 'str' }
                    set = @{ type = 'list'; elements = 'str' }
                }
            }
            Attribute = 'PrincipalsAllowedToDelegateToAccount'
            CaseInsensitive = $true
        }

        [PSCustomObject]@{
            Name = 'email'
            Option = @{ type = 'str' }
            Attribute = 'EmailAddress'
        }

        [PSCustomObject]@{
            Name = 'enabled'
            Option = @{ type = 'bool' }
            Attribute = 'Enabled'
        }

        [PSCustomObject]@{
            Name = 'firstname'
            Option = @{ type = 'str' }
            Attribute = 'givenName'
        }

        [PSCustomObject]@{
            Name = 'groups'
            Option = @{
                type = 'dict'
                options = @{
                    add = @{ type = 'list'; elements = 'str' }
                    remove = @{ type = 'list'; elements = 'str' }
                    set = @{ type = 'list'; elements = 'str' }
                    missing_behaviour = @{
                        choices = 'fail', 'ignore', 'warn'
                        default = 'fail'
                        type = 'str'
                    }
                }
            }
        }

        [PSCustomObject]@{
            Name = 'password'
            Option = @{
                no_log = $true
                type = 'str'
            }
            New = {
                param($Module, $ADParams, $NewParams)

                $NewParams.AccountPassword = (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.password)
                $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER'
            }
            Set = {
                param($Module, $ADParams, $SetParams, $ADObject)

                $Module.Diff.before.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER'

                $changed = switch ($Module.Params.update_password) {
                    always { $true }
                    on_create { $false }
                    when_changed {
                        # Try and use the UPN but fallback to msDS-PrincipalName if none is defined
                        $username = $ADObject.UserPrincipalName
                        if (-not $username) {
                            $username = $ADObject['msDS-PrincipalName']
                        }

                        -not (Test-Credential -Username $username -Password $module.Params.password)
                    }
                }

                if (-not $changed) {
                    $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER'
                    return
                }

                # -WhatIf was broken until Server 2016 and will set the
                # password. Just avoid calling this in check mode.
                if (-not $Module.CheckMode) {
                    $setParams = @{
                        Identity = $ADObject.ObjectGUID
                        Reset = $true
                        Confirm = $false
                        NewPassword = (ConvertTo-SecureString -AsPlainText -Force -String $module.Params.password)
                    }
                    Set-ADAccountPassword @setParams @ADParams
                }

                $Module.Diff.after.password = 'VALUE_SPECIFIED_IN_NO_LOG_PARAMETER - changed'
                $Module.Result.changed = $true
            }
        }

        [PSCustomObject]@{
            Name = 'password_expired'
            Option = @{ type = 'bool' }
            Attribute = 'PasswordExpired'
            New = {
                param($Module, $ADParams, $NewParams)

                $NewParams.ChangePasswordAtLogon = $module.Params.password_expired
                $Module.Diff.after.password_expired = $module.Params.password_expired
            }
            Set = {
                param($Module, $ADParams, $SetParams, $ADObject)

                if ($ADObject.PasswordExpired -ne $Module.Params.password_expired) {
                    $SetParams.ChangePasswordAtLogon = $Module.Params.password_expired
                }

                $Module.Diff.after.password_expired = $Module.Params.password_expired
            }
        }

        [PSCustomObject]@{
            Name = 'password_never_expires'
            Option = @{ type = 'bool' }
            Attribute = 'PasswordNeverExpires'
        }

        [PSCustomObject]@{
            Name = 'postal_code'
            Option = @{ type = 'str' }
            Attribute = 'PostalCode'
        }

        [PSCustomObject]@{
            Name = 'sam_account_name'
            Option = @{ type = 'str' }
            Attribute = 'sAMAccountName'
        }

        [PSCustomObject]@{
            Name = 'spn'
            Option = @{
                aliases = 'spns'
                type = 'dict'
                options = @{
                    add = @{ type = 'list'; elements = 'str' }
                    remove = @{ type = 'list'; elements = 'str' }
                    set = @{ type = 'list'; elements = 'str' }
                }
            }
            Attribute = 'ServicePrincipalNames'
            New = {
                param($Module, $ADParams, $NewParams)

                $spns = @(
                    $Module.Params.spn.add
                    $Module.Params.spn.set
                ) | Select-Object -Unique

                $NewParams.ServicePrincipalNames = $spns
                $Module.Diff.after.spn = $spns
            }
            Set = {
                param($Module, $ADParams, $SetParams, $ADObject)

                $desired = $Module.Params.spn
                $compareParams = @{
                    Existing = $ADObject.ServicePrincipalNames
                    CaseInsensitive = $true
                }
                $res = Compare-AnsibleADIdempotentList @compareParams @desired
                if ($res.Changed) {
                    $SetParams.ServicePrincipalNames = @{}
                    if ($res.ToAdd) {
                        $SetParams.ServicePrincipalNames.Add = $res.ToAdd
                    }
                    if ($res.ToRemove) {
                        $SetParams.ServicePrincipalNames.Remove = $res.ToRemove
                    }
                }
                $module.Diff.after.spn = @($res.Value | Sort-Object)
            }
        }

        [PSCustomObject]@{
            Name = 'state_province'
            Option = @{ type = 'str' }
            Attribute = 'State'
        }

        [PSCustomObject]@{
            Name = 'street'
            Option = @{ type = 'str' }
            Attribute = 'StreetAddress'
        }

        [PSCustomObject]@{
            Name = 'surname'
            Option = @{
                aliases = 'lastname'
                type = 'str'
            }
            Attribute = 'Surname'
        }

        [PSCustomObject]@{
            Name = 'update_password'
            Option = @{
                choices = 'always', 'on_create', 'when_changed'
                default = 'always'
                type = 'str'
            }
        }

        [PSCustomObject]@{
            Name = 'upn'
            Option = @{ type = 'str' }
            Attribute = 'userPrincipalName'
        }

        [PSCustomObject]@{
            Name = 'user_cannot_change_password'
            Option = @{ type = 'bool' }
            Attribute = 'CannotChangePassword'
        }
    )
    ModuleNoun = 'ADUser'
    DefaultPath = {
        param($Module, $ADParams)

        $GUID_USERS_CONTAINER_W = 'A9D1CA15768811D1ADED00C04FD8D5CD'
        $defaultNamingContext = (Get-ADRootDSE @ADParams -Properties defaultNamingContext).defaultNamingContext

        Get-ADObject @ADParams -Identity $defaultNamingContext -Properties wellKnownObjects |
            Select-Object -ExpandProperty wellKnownObjects |
            Where-Object { $_.StartsWith("B:32:$($GUID_USERS_CONTAINER_W):") } |
            ForEach-Object Substring 38
    }
    ExtraProperties = @(
        # Used for password when checking if the password is valid
        'msDS-PrincipalName'
    )
    PreAction = {
        param ($Module, $ADParams, $ADObject)

        if (
            $Module.Params.state -eq 'present' -and
            $null -eq $ADObject -and
            $null -eq $Module.Params.enabled
        ) {
            $Module.Params.enabled = -not ([String]::IsNullOrWhiteSpace($Module.Params.password))
        }
    }
    PostAction = {
        param($Module, $ADParams, $ADObject)

        if ($ADObject) {
            $Module.Result.sid = $ADObject.SID.Value
        }
        elseif ($Module.Params.state -eq 'present') {
            # Use dummy value for check mode when creating a new user
            $Module.Result.sid = 'S-1-5-0000'
        }

        if ($null -eq $Module.Params.groups -or $Module.Params.groups.Count -eq 0 -or $Module.Params.state -eq 'absent') {
            return
        }

        $groupMissingBehaviour = $Module.Params.groups.missing_behaviour
        $lookupGroup = {
            try {
                (Get-ADGroup -Identity $args[0] @ADParams).DistinguishedName
            }
            catch {
                if ($groupMissingBehaviour -eq "fail") {
                    $module.FailJson("Failed to locate group $($args[0]): $($_.Exception.Message)", $_)
                }
                elseif ($groupMissingBehaviour -eq "warn") {
                    $module.Warn("Failed to locate group $($args[0]) but continuing on: $($_.Exception.Message)")
                }
            }
        }

        [string[]]$existingGroups = @(
            # In check mode the ADObject won't be given
            if ($ADObject) {
                try {
                    Get-ADPrincipalGroupMembership -Identity $ADObject.ObjectGUID @ADParams -ErrorAction Stop |
                        Select-Object -ExpandProperty DistinguishedName
                }
                catch {
                    $module.Warn("Failed to enumerate user groups but continuing on: $($_.Exception.Message)")
                }
            }
        )

        if ($Module.Diff.before) {
            $Module.Diff.before.groups = @($existingGroups | Sort-Object)
        }

        $compareParams = @{
            CaseInsensitive = $true
            Existing = $existingGroups
        }
        'add', 'remove', 'set' | ForEach-Object -Process {
            if ($null -ne $Module.Params.groups[$_]) {
                $compareParams[$_] = @(
                    foreach ($group in $Module.Params.groups[$_]) {
                        & $lookupGroup $group
                    }
                )
            }
        }

        $res = Compare-AnsibleADIdempotentList @compareParams
        $Module.Diff.after.groups = $res.Value

        if ($res.Changed) {
            $commonParams = @{
                Confirm = $false
                WhatIf = $Module.CheckMode
            }
            foreach ($member in $res.ToAdd) {
                if ($ADObject) {
                    Add-ADGroupMember -Identity $member -Members $ADObject.ObjectGUID @ADParams @commonParams
                }
                $Module.Result.changed = $true
            }
            foreach ($member in $res.ToRemove) {
                if ($ADObject) {
                    try {
                        Remove-ADGroupMember -Identity $member -Members $ADObject.ObjectGUID @ADParams @commonParams
                    }
                    catch [Microsoft.ActiveDirectory.Management.ADException] {
                        if ($_.Exception.ErrorCode -eq 0x0000055E) {
                            # ERROR_MEMBERS_PRIMARY_GROUP - win_domain_user didn't
                            # fail in this scenario. To preserve compatibility just
                            # display a warning. The warning isn't added if set
                            # was an empty list.
                            if ($null -eq $Module.Params.groups -or $Module.Params.groups.set.Length -ne 0) {
                                $Module.Warn("Cannot remove group '$member' as it's the primary group of the user, skipping: $($_.Exception.Message)")
                            }
                            $Module.Diff.after.groups = @($Module.Diff.after.groups; $member)
                        }
                        else {
                            throw
                        }
                    }
                }
                $Module.Result.changed = $true
            }
        }

        # Ensure it's in alphabetical order to match before state as much as possible
        $Module.Diff.after.groups = @($res.Value | Sort-Object)
    }
}
Invoke-AnsibleADObject @setParams
