DSCResources/DSC_SqlLogin/DSC_SqlLogin.psm1

$script:sqlServerDscHelperModulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Modules\SqlServerDsc.Common'
$script:resourceHelperModulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\Modules\DscResource.Common'

Import-Module -Name $script:sqlServerDscHelperModulePath
Import-Module -Name $script:resourceHelperModulePath

$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US'

<#
    .SYNOPSIS
    Gets the specified login by name.
 
    .PARAMETER Name
    The name of the login to retrieve.
 
    .PARAMETER ServerName
    Hostname of the SQL Server to retrieve the login from. Default value is $env:COMPUTERNAME.
 
    .PARAMETER InstanceName
    Name of the SQL instance to retrieve the login from.
#>

function Get-TargetResource
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Name,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $ServerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $true)]
        [System.String]
        $InstanceName
    )

    Write-Verbose -Message (
        $script:localizedData.GetLogin -f $Name, $ServerName, $InstanceName
    )

    $serverObject = Connect-SQL -ServerName $ServerName -InstanceName $InstanceName
    if ($serverObject)
    {
        $login = $serverObject.Logins[$Name]

        if ($login)
        {
            $ensure = 'Present'
        }
        else
        {
            $ensure = 'Absent'
        }
    }

    Write-Verbose -Message (
        $script:localizedData.LoginCurrentState -f $Name, $ensure, $ServerName, $InstanceName
    )

    $returnValue = @{
        Ensure          = $ensure
        Name            = $Name
        LoginType       = $login.LoginType
        ServerName      = $ServerName
        InstanceName    = $InstanceName
        Disabled        = $login.IsDisabled
        DefaultDatabase = $login.DefaultDatabase
    }

    if ($login.LoginType -eq 'SqlLogin')
    {
        $returnValue.Add('LoginMustChangePassword', $login.MustChangePassword)
        $returnValue.Add('LoginPasswordExpirationEnabled', $login.PasswordExpirationEnabled)
        $returnValue.Add('LoginPasswordPolicyEnforced', $login.PasswordPolicyEnforced)
    }

    return $returnValue
}

<#
    .SYNOPSIS
    Creates a login.
 
    .PARAMETER Ensure
    Specifies if the login to exist. Default is 'Present'.
 
    .PARAMETER Name
    The name of the login to retrieve.
 
    .PARAMETER LoginType
    The type of login to create. Default is 'WindowsUser'
 
    .PARAMETER ServerName
    Hostname of the SQL Server to create the login on. Default value is $env:COMPUTERNAME.
 
    .PARAMETER InstanceName
    Name of the SQL instance to create the login on.
 
    .PARAMETER LoginCredential
    The credential containing the password for a SQL Login. Only applies if the login type is SqlLogin.
 
    .PARAMETER LoginMustChangePassword
    Specifies if the login is required to have its password change on the next login. Only applies to SQL Logins. Default is $true.
 
    .PARAMETER LoginPasswordExpirationEnabled
    Specifies if the login password is required to expire in accordance to the operating system security policy. Only applies to SQL Logins. Default is $true.
 
    .PARAMETER LoginPasswordPolicyEnforced
    Specifies if the login password is required to conform to the password policy specified in the system security policy. Only applies to SQL Logins. Default is $true.
 
    .PARAMETER Disabled
    Specifies if the login is disabled. Default is $false.
 
    .PARAMETER DefaultDatabase
    Specifies the default database for the login.
#>

function Set-TargetResource
{
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateSet('Present', 'Absent')]
        [System.String]
        $Ensure = 'Present',

        [Parameter(Mandatory = $true)]
        [System.String]
        $Name,

        [Parameter()]
        [ValidateSet(
            'WindowsUser',
            'WindowsGroup',
            'SqlLogin',
            'Certificate',
            'AsymmetricKey',
            'ExternalUser',
            'ExternalGroup'
        )]
        [System.String]
        $LoginType = 'WindowsUser',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $ServerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $true)]
        [System.String]
        $InstanceName,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        $LoginCredential,

        [Parameter()]
        [System.Boolean]
        $LoginMustChangePassword = $true,

        [Parameter()]
        [System.Boolean]
        $LoginPasswordExpirationEnabled = $true,

        [Parameter()]
        [System.Boolean]
        $LoginPasswordPolicyEnforced = $true,

        [Parameter()]
        [System.Boolean]
        $Disabled,

        [Parameter()]
        [System.String]
        $DefaultDatabase
    )

    $serverObject = Connect-SQL -ServerName $ServerName -InstanceName $InstanceName

    switch ( $Ensure )
    {
        'Present'
        {
            if ( $serverObject.Logins[$Name] )
            {
                $login = $serverObject.Logins[$Name]

                if ( $login.LoginType -eq 'SqlLogin' )
                {
                    if ( $login.PasswordExpirationEnabled -ne $LoginPasswordExpirationEnabled )
                    {
                        Write-Verbose -Message (
                            $script:localizedData.SetPasswordExpirationEnabled -f $LoginPasswordExpirationEnabled, $Name, $ServerName, $InstanceName
                        )

                        $login.PasswordExpirationEnabled = $LoginPasswordExpirationEnabled
                        Update-SQLServerLogin -Login $login
                    }

                    if ( $login.PasswordPolicyEnforced -ne $LoginPasswordPolicyEnforced )
                    {
                        Write-Verbose -Message (
                            $script:localizedData.SetPasswordPolicyEnforced -f $LoginPasswordPolicyEnforced, $Name, $ServerName, $InstanceName
                        )

                        $login.PasswordPolicyEnforced = $LoginPasswordPolicyEnforced
                        Update-SQLServerLogin -Login $login
                    }

                    # Set the password if it is specified
                    if ( $LoginCredential )
                    {
                        Write-Verbose -Message (
                            $script:localizedData.SetPassword -f $Name, $ServerName, $InstanceName
                        )

                        Set-SQLServerLoginPassword -Login $login -SecureString $LoginCredential.Password
                    }
                }

                if ( $PSBoundParameters.ContainsKey('Disabled') -and ($login.IsDisabled -ne $Disabled) )
                {
                    if ( $Disabled )
                    {
                        Write-Verbose -Message (
                            $script:localizedData.SetDisabled -f $Name, $ServerName, $InstanceName
                        )

                        $login.Disable()
                    }
                    else
                    {
                        Write-Verbose -Message (
                            $script:localizedData.SetEnabled -f $Name, $ServerName, $InstanceName
                        )

                        $login.Enable()
                    }
                }

                if ( $PSBoundParameters.ContainsKey('DefaultDatabase') -and ($login.DefaultDatabase -ne $DefaultDatabase) )
                {
                    $login.DefaultDatabase = $DefaultDatabase
                    Update-SQLServerLogin -Login $login
                }
            }
            else
            {
                # Some login types need additional work. These will need to be fleshed out more in the future
                if ( @('Certificate', 'AsymmetricKey', 'ExternalUser', 'ExternalGroup') -contains $LoginType )
                {
                    $errorMessage = $script:localizedData.LoginTypeNotImplemented -f $LoginType
                    New-NotImplementedException -Message $errorMessage
                }

                if ( ( $LoginType -eq 'SqlLogin' ) -and ( -not $LoginCredential ) )
                {
                    $errorMessage = $script:localizedData.LoginCredentialNotFound -f $Name
                    New-ObjectNotFoundException -Message $errorMessage
                }

                Write-Verbose -Message (
                    $script:localizedData.CreateLogin -f $Name, $LoginType, $ServerName, $InstanceName
                )

                $login = New-Object -TypeName 'Microsoft.SqlServer.Management.Smo.Login' -ArgumentList $serverObject, $Name
                $login.LoginType = $LoginType

                switch ($LoginType)
                {
                    'SqlLogin'
                    {
                        # Verify the instance is in Mixed authentication mode
                        if ( $serverObject.LoginMode -notmatch 'Mixed|Normal' )
                        {
                            $errorMessage = $script:localizedData.IncorrectLoginMode -f $ServerName, $InstanceName, $serverObject.LoginMode
                            New-InvalidOperationException -Message $errorMessage
                        }

                        $login.PasswordPolicyEnforced = $LoginPasswordPolicyEnforced
                        $login.PasswordExpirationEnabled = $LoginPasswordExpirationEnabled
                        if ( $LoginMustChangePassword )
                        {
                            $LoginCreateOptions = [Microsoft.SqlServer.Management.Smo.LoginCreateOptions]::MustChange
                        }
                        else
                        {
                            $LoginCreateOptions = [Microsoft.SqlServer.Management.Smo.LoginCreateOptions]::None
                        }

                        New-SQLServerLogin -Login $login -LoginCreateOptions $LoginCreateOptions -SecureString $LoginCredential.Password -ErrorAction Stop
                    }

                    default
                    {
                        New-SQLServerLogin -Login $login
                    }
                }

                # we can only disable the login once it's been created
                if ( $Disabled )
                {
                    Write-Verbose -Message (
                        $script:localizedData.SetDisabled -f $Name, $ServerName, $InstanceName
                    )

                    $login.Disable()
                }

                # set the default database if specified
                if ( $PSBoundParameters.ContainsKey('DefaultDatabase') )
                {
                    $login.DefaultDatabase = $DefaultDatabase
                    Update-SQLServerLogin -Login $login
                }
            }
        }

        'Absent'
        {
            if ( $serverObject.Logins[$Name] )
            {
                Write-Verbose -Message (
                    $script:localizedData.DropLogin -f $Name, $ServerName, $InstanceName
                )

                Remove-SQLServerLogin -Login $serverObject.Logins[$Name]
            }
        }
    }
}

<#
    .SYNOPSIS
    Tests to verify the login exists and the properties are correctly set.
 
    .PARAMETER Ensure
    Specifies if the login is supposed to exist. Default is 'Present'.
 
    .PARAMETER Name
    The name of the login.
 
    .PARAMETER LoginType
    The type of login. Default is 'WindowsUser'
 
    .PARAMETER ServerName
    Hostname of the SQL Server to create the login on. Default value is $env:COMPUTERNAME.
 
    .PARAMETER InstanceName
    Name of the SQL instance.
 
    .PARAMETER LoginCredential
    The credential containing the password for a SQL Login. Only applies if the login type is SqlLogin.
 
    .PARAMETER LoginMustChangePassword
    Specifies if the login is required to have its password change on the next login. Only applies to SQL Logins. Default is $true.
 
    .PARAMETER LoginPasswordExpirationEnabled
    Specifies if the login password is required to expire in accordance to the operating system security policy. Only applies to SQL Logins. Default is $true.
 
    .PARAMETER LoginPasswordPolicyEnforced
    Specifies if the login password is required to conform to the password policy specified in the system security policy. Only applies to SQL Logins. Default is $true.
 
    .PARAMETER Disabled
    Specifies if the login is disabled. Default is $false.
 
    .PARAMETER DefaultDatabase
    Specifies the default database for the login.
#>

function Test-TargetResource
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [Parameter()]
        [ValidateSet('Present', 'Absent')]
        [System.String]
        $Ensure = 'Present',

        [Parameter(Mandatory = $true)]
        [System.String]
        $Name,

        [Parameter()]
        [ValidateSet('WindowsUser',
            'WindowsGroup',
            'SqlLogin',
            'Certificate',
            'AsymmetricKey',
            'ExternalUser',
            'ExternalGroup'
        )]
        [System.String]
        $LoginType = 'WindowsUser',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $ServerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $true)]
        [System.String]
        $InstanceName,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        $LoginCredential,

        [Parameter()]
        [System.Boolean]
        $LoginMustChangePassword = $true,

        [Parameter()]
        [System.Boolean]
        $LoginPasswordExpirationEnabled = $true,

        [Parameter()]
        [System.Boolean]
        $LoginPasswordPolicyEnforced = $true,

        [Parameter()]
        [System.Boolean]
        $Disabled,

        [Parameter()]
        [System.String]
        $DefaultDatabase
    )

    Write-Verbose -Message (
        $script:localizedData.TestingConfiguration -f $Name, $ServerName, $InstanceName
    )

    # Assume the test will pass
    $testPassed = $true

    $getParams = @{
        Name         = $Name
        ServerName   = $ServerName
        InstanceName = $InstanceName
    }

    $loginInfo = Get-TargetResource @getParams

    if ( $Ensure -ne $loginInfo.Ensure )
    {
        Write-Verbose -Message (
            $script:localizedData.WrongEnsureState -f $Name, $loginInfo.Ensure, $Ensure
        )

        $testPassed = $false
    }

    if ( $Ensure -eq 'Present' -and $($loginInfo.Ensure) -eq 'Present' )
    {
        if ( $LoginType -ne $loginInfo.LoginType )
        {
            Write-Verbose -Message (
                $script:localizedData.WrongLoginType -f $Name, $loginInfo.LoginType, $LoginType
            )

            $testPassed = $false
        }

        if ( $PSBoundParameters.ContainsKey('Disabled') -and ($loginInfo.Disabled -ne $Disabled) )
        {
            if ($Disabled)
            {
                Write-Verbose -Message (
                    $script:localizedData.ExpectedDisabled -f $Name
                )
            }
            else
            {
                Write-Verbose -Message (
                    $script:localizedData.ExpectedEnabled -f $Name
                )
            }

            $testPassed = $false
        }

        if ( $PSBoundParameters.ContainsKey('DefaultDatabase') -and ($loginInfo.DefaultDatabase -ne $DefaultDatabase) )
        {
            Write-Verbose -Message (
                $script:localizedData.WrongDefaultDatabase -f $Name, $loginInfo.DefaultDatabase, $DefaultDatabase
            )

            $testPassed = $false
        }

        if ( $LoginType -eq 'SqlLogin' )
        {
            if ( $LoginPasswordExpirationEnabled -ne $loginInfo.LoginPasswordExpirationEnabled )
            {
                if ($LoginPasswordExpirationEnabled)
                {
                    Write-Verbose -Message (
                        $script:localizedData.ExpectedLoginPasswordExpirationEnabled -f $Name
                    )
                }
                else
                {
                    Write-Verbose -Message (
                        $script:localizedData.ExpectedLoginPasswordExpirationDisabled -f $Name
                    )
                }

                $testPassed = $false
            }

            if ( $LoginPasswordPolicyEnforced -ne $loginInfo.LoginPasswordPolicyEnforced )
            {
                if ($LoginPasswordPolicyEnforced)
                {
                    Write-Verbose -Message (
                        $script:localizedData.ExpectedLoginPasswordPolicyEnforcedEnabled -f $Name
                    )
                }
                else
                {
                    Write-Verbose -Message (
                        $script:localizedData.ExpectedLoginPasswordPolicyEnforcedDisabled -f $Name
                    )
                }

                $testPassed = $false
            }

            # If testPassed is still true and a login credential was specified, test the password
            if ( $testPassed -and $LoginCredential )
            {
                $userCredential = [System.Management.Automation.PSCredential]::new($Name, $LoginCredential.Password)

                try
                {
                    Connect-SQL -ServerName $ServerName -InstanceName $InstanceName -SetupCredential $userCredential -LoginType 'SqlLogin' | Out-Null
                }
                catch
                {
                    # Check to see if the parameter of $Disabled is true
                    if ($Disabled)
                    {
                        <#
                            An exception occurred and $Disabled is true, we neeed
                            to check the error codes for expected error numbers.
                            Recursively search the Exception variable and inner
                            Exceptions for the specific numbers.
                            18470 - Username and password are correct, but
                            account is disabled.
                            18456 - Login failed for user.
                        #>

                        if ((Find-ExceptionByNumber -ExceptionToSearch $_.Exception -ErrorNumber 18470))
                        {
                            Write-Verbose -Message (
                                    $script:localizedData.PasswordValidButLoginDisabled -f $Name
                            )
                        }
                        elseif ((Find-ExceptionByNumber -ExceptionToSearch $_.Exception -ErrorNumber 18456))
                        {
                            Write-Verbose -Message (
                                '{0} {1}' -f
                                    ($script:localizedData.PasswordValidationFailed -f $Name),
                                    ($script:localizedData.PasswordValidationFailedMessage -f $_.Exception.message)
                            )

                            # The password was not correct, password validation failed
                            $testPassed = $false
                        }
                        else
                        {
                            # Something else went wrong, rethrow error
                            $errorMessage = $script:localizedData.PasswordValidationError
                            New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
                        }
                    }
                    else
                    {
                        Write-Verbose -Message (
                            $script:localizedData.PasswordValidationFailed -f $Name
                        )

                        $testPassed = $false
                    }
                }
            }
        }
    }

    return $testPassed
}

<#
    .SYNOPSIS
    Alters a login.
 
    .PARAMETER Login
    The Login object to alter.
 
    .NOTES
    This function allows us to more easily write mocks.
#>

function Update-SQLServerLogin
{
    param
    (
        [Parameter(Mandatory = $true)]
        [Microsoft.SqlServer.Management.Smo.Login]
        $Login
    )

    try
    {
        $originalErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'

        $Login.Alter()
    }
    catch
    {
        $errorMessage = $script:localizedData.AlterLoginFailed -f $Login.Name
        New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
    }
    finally
    {
        $ErrorActionPreference = $originalErrorActionPreference
    }
}

<#
    .SYNOPSIS
    Creates a login.
 
    .PARAMETER Login
    The Login object to create.
 
    .PARAMETER LoginCreateOptions
    The LoginCreateOptions object to use when creating a SQL login.
 
    .PARAMETER SecureString
    The SecureString object that contains the password for a SQL login.
 
    .EXAMPLE
    CreateLogin -Login $login -LoginCreateOptions $LoginCreateOptions -SecureString $LoginCredential.Password -ErrorAction Stop
 
    .EXAMPLE
    CreateLogin -Login $login
 
    .NOTES
    This function allows us to more easily write mocks.
#>

function New-SQLServerLogin
{
    [CmdletBinding(DefaultParameterSetName = 'WindowsLogin')]
    param
    (
        [Parameter(Mandatory = $true, ParameterSetName = 'WindowsLogin')]
        [Parameter(Mandatory = $true, ParameterSetName = 'SqlLogin')]
        [Microsoft.SqlServer.Management.Smo.Login]
        $Login,

        [Parameter(Mandatory = $true, ParameterSetName = 'SqlLogin')]
        [Microsoft.SqlServer.Management.Smo.LoginCreateOptions]
        $LoginCreateOptions,

        [Parameter(Mandatory = $true, ParameterSetName = 'SqlLogin')]
        [System.Security.SecureString]
        $SecureString
    )

    switch ( $PSCmdlet.ParameterSetName )
    {
        'SqlLogin'
        {
            try
            {
                $originalErrorActionPreference = $ErrorActionPreference
                $ErrorActionPreference = 'Stop'

                $login.Create($SecureString, $LoginCreateOptions)
            }
            catch [Microsoft.SqlServer.Management.Smo.FailedOperationException]
            {
                if ( $_.Exception.InnerException.InnerException.InnerException -match 'Password validation failed' )
                {
                    $errorMessage = $script:localizedData.CreateLoginFailedOnPassword -f $Login.Name
                    New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
                }
                else
                {
                    $errorMessage = $script:localizedData.CreateLoginFailed -f $Login.Name
                    New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
                }
            }
            catch
            {
                $errorMessage = $script:localizedData.CreateLoginFailed -f $Login.Name
                New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
            }
            finally
            {
                $ErrorActionPreference = $originalErrorActionPreference
            }
        }

        'WindowsLogin'
        {
            try
            {
                $originalErrorActionPreference = $ErrorActionPreference
                $ErrorActionPreference = 'Stop'

                $login.Create()
            }
            catch
            {
                $errorMessage = $script:localizedData.CreateLoginFailed -f $Login.Name
                New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
            }
            finally
            {
                $ErrorActionPreference = $originalErrorActionPreference
            }
        }
    }
}

<#
    .SYNOPSIS
    Drops a login.
 
    .PARAMETER Login
    The Login object to drop.
 
    .NOTES
    This function allows us to more easily write mocks.
#>

function Remove-SQLServerLogin
{
    param
    (
        [Parameter(Mandatory = $true)]
        [Microsoft.SqlServer.Management.Smo.Login]
        $Login
    )

    try
    {
        $originalErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'

        $Login.Drop()
    }
    catch
    {
        $errorMessage = $script:localizedData.DropLoginFailed -f $Login.Name
        New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
    }
    finally
    {
        $ErrorActionPreference = $originalErrorActionPreference
    }
}

<#
    .SYNOPSIS
    Changes the password of a SQL Login.
 
    .PARAMETER Login
    The Login object to change the password on.
 
    .PARAMETER SecureString
    The SecureString object that contains the password for a SQL login.
 
    .NOTES
    This function allows us to more easily write mocks.
#>

function Set-SQLServerLoginPassword
{
    param
    (
        [Parameter(Mandatory = $true)]
        [Microsoft.SqlServer.Management.Smo.Login]
        $Login,

        [Parameter(Mandatory = $true)]
        [System.Security.SecureString]
        $SecureString
    )

    try
    {
        $originalErrorActionPreference = $ErrorActionPreference
        $ErrorActionPreference = 'Stop'

        $Login.ChangePassword($SecureString)
    }
    catch [Microsoft.SqlServer.Management.Smo.FailedOperationException]
    {
        if ( $_.Exception.InnerException.InnerException.InnerException -match 'Password validation failed' )
        {
            $errorMessage = $script:localizedData.SetPasswordValidationFailed -f $Login.Name
            New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
        }
        else
        {
            $errorMessage = $script:localizedData.SetPasswordFailed -f $Login.Name
            New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
        }
    }
    catch
    {
        $errorMessage = $script:localizedData.SetPasswordFailed -f $Login.Name
        New-InvalidOperationException -Message $errorMessage -ErrorRecord $_
    }
    finally
    {
        $ErrorActionPreference = $originalErrorActionPreference
    }
}

Export-ModuleMember -Function *-TargetResource