Carbon.SmbShare.psm1

using namespace System.Security.AccessControl
using namespace System.Security.Principal

# Copyright WebMD Health Services
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License

#Requires -Version 5.1
Set-StrictMode -Version 'Latest'

# Functions should use $script:moduleDirPath as the relative root from which to find things. A published module has its
# function appended to this file, while a module in development has its functions in the Functions directory.
$script:moduleDirPath = $PSScriptRoot

$modulesDirPath = Join-Path -Path $script:moduleDirPath -ChildPath 'M' -Resolve
# Import the .psm1 because PowerShell is limited to 10 nested scopes and importing the directory/.psd1 adds a scope.
Import-Module -Name (Join-Path -Path $modulesDirPath -ChildPath 'Carbon.Accounts\Carbon.Accounts.psm1' -Resolve) `
              -Function @('Resolve-CPrincipalName') `
              -Verbose:$false
Import-Module -Name (Join-Path -Path $modulesDirPath -ChildPath 'Carbon.FileSystem\Carbon.FileSystem.psm1' -Resolve) `
              -Function @('Install-CDirectory') `
              -Verbose:$false

# Store each of your module's functions in its own file in the Functions directory. On the build server, your module's
# functions will be appended to this file, so only dot-source files that exist on the file system. This allows
# developers to work on a module without having to build it first. Grab all the functions that are in their own files.
$functionsPath = Join-Path -Path $script:moduleDirPath -ChildPath 'Functions\*.ps1'
if( (Test-Path -Path $functionsPath) )
{
    foreach( $functionPath in (Get-Item $functionsPath) )
    {
        . $functionPath.FullName
    }
}



function Install-CSmbShare
{
    <#
    .SYNOPSIS
    Installs a file/SMB share.
 
    .DESCRIPTION
    The `Install-CSmbShare` function installs an SMB file share. If the share doesn't exist, it is created. If a share
    exists, its description and permissions are updated in place. If its path is changing, the share is deleted and
    re-created. If the path to the share doesn't exist, it is created.
 
    Use the `FullAccess`, `ChangeAccess`, and `ReadAccess` parameters to grant full, change, and read permissions on the
    share. Each parameter takes a list of user/group names. Only the users and groups in the `FullAccess`,
    `ChangeAccess`, and `ReadAccess` parameters will be given access. Any accounts that have access that aren't in one
    of the specified lists will have their permissions removed, including the default `Everyone` read access rule. Make
    sure to pass `Everyone` to the `ReadAccess` parameter if you want everyone to have access!
 
    Permissions don't apply to the file system, only to the share. Use the Carbon.FileSystem module's
    `Grant-CNtfsPermission` function to grant file system permissions or use the SmbShare module's `Set-SmbPathAcl`.
 
    .LINK
    Test-CSmbShare
 
    .LINK
    Uninstall-CSmbShare
 
    .EXAMPLE
    Install-Share -Name TopSecretDocuments -Path C:\TopSecret -Description 'Share for our top secret documents.' -ReadAccess "Everyone" -FullAccess "Analysts"
 
    Shares the C:\TopSecret directory as `TopSecretDocuments` and grants `Everyone` read access and `Analysts` full
    control.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The share's name.
        [Parameter(Mandatory)]
        [String] $Name,

        # The path to the share. Is created if it doesn't exist.
        [Parameter(Mandatory)]
        [String] $Path,

        # A description of the share.
        [String] $Description = '',

        # The user/group names who should be granted full access to the share. Only principals in this list will be
        # given full access. All other principals will be removed.
        [String[]] $FullAccess = @(),

        # The user/group names who should be granted change access to the share. Only principals in this list will be
        # given change access. All other principals will be removed.
        [String[]] $ChangeAccess = @(),

        # The user/group names who should be granted read access to the share. Only principals in this list will be
        # given read access. All other principals will be removed.
        #
        # The `Everyone` group will *not* be granted permission by default. They must be in this list to be given
        # access.
        [String[]] $ReadAccess = @()
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState

    if ([wildcardpattern]::ContainsWildcardCharacters($Name))
    {
        $msg = "Failed to create SMB file share ""${Name}"" because the share name contains wildcard characters."
        Write-Error -Message $msg -ErrorAction $ErrorActionPreference
        return
    }

    $shareInfoWrittenTo = @{}

    function Write-Message
    {
        param(
            [String] $Message,
            [switch] $Verbose
        )

        $writeCmd = 'Write-Information'
        if ($Verbose)
        {
            $writeCmd = 'Write-Verbose'
        }

        if (-not $shareInfoWrittenTo.ContainsKey($writeCmd))
        {
            & $writeCmd "SMB File Share ${Name}"
            $shareInfoWrittenTo[$writeCmd] = $true
        }

        & $writeCmd " ${Message}"
    }

    function Format-AccessRight
    {
        param(
            [Parameter(Mandatory, ValueFromPipeline)]
            [ValidateSet('Full', 'Change', 'Read')]
            [String] $InputObject
        )

        process
        {
            return '{0,-6}' -f $InputObject
        }
    }

    # Make sure path ends with \. You can only share directories, after all.
    $Path = Join-Path -Path $Path -ChildPath '\'

    if (-not (Test-Path -Path $Path))
    {
        Install-CDirectory -Path $Path
    }

    $share = Test-CSmbShare -Name $Name -PassThru

    # Only way to update a share's path is to delete and re-create the share.
    if ($share -and (Join-Path -Path $share.Path -ChildPath '\') -ne $Path)
    {
        Uninstall-CSmbShare -Name $Name
        $share = $null
    }

    $whatIfTarget = "SMB file share '${Name}'"

    # If no permissions were specified, grant read access to everyone.
    if (-not $share)
    {
        if (-not $PSCmdlet.ShouldProcess($whatIfTarget, "create"))
        {
            return
        }

        Write-Message "created at ""${Path}""."
        foreach ($principal in $FullAccess)
        {
            Write-Message "+ $('Full' | Format-AccessRight) ${principal}"
        }

        foreach ($principal in $ChangeAccess)
        {
            Write-Message "+ $('Change' | Format-AccessRight) ${principal}"
        }

        foreach ($principal in $ReadAccess)
        {
            Write-Message "+ $('Read' | Format-AccessRight) ${principal}"
        }

        $newSmbShareArgs = @{}
        if ($FullAccess)
        {
            $newSmbShareArgs['FullAccess'] = $FullAccess
        }
        if ($ChangeAccess)
        {
            $newSmbShareArgs['ChangeAccess'] = $ChangeAccess
        }
        if ($ReadAccess)
        {
            $newSmbShareArgs['ReadAccess'] = $ReadAccess
        }

        $share = New-SmbShare -Name $Name -Path $Path -Description $Description @newSmbShareArgs
        if (-not $share)
        {
            return
        }
    }

    $Name = $share.Name

    if ($share.Description -ne $Description)
    {
        Write-Message  "Description - $($share.Description)"
        Write-Message  " + ${Description}"
        if ($PSCmdlet.ShouldProcess($whatIfTarget, "update description"))
        {
            Set-SmbShare -Name $Name -Description $Description -Force | Out-Null
        }
    }

    # Resolve all the principals.
    $FullAccess = $FullAccess | ForEach-Object { Resolve-CPrincipalName -Name $_ }
    $ChangeAccess = $ChangeAccess | ForEach-Object { Resolve-CPrincipalName -Name $_ }
    $ReadAccess = $ReadAccess | ForEach-Object { Resolve-CPrincipalName -Name $_ }

    $aces = Get-SmbShareAccess -Name $Name | Where-Object 'AccessControlType' -eq 'Allow'

    # Remove extra permissions.
    foreach ($ace in $aces)
    {
        $acePrincipalName = Resolve-CPrincipalName -Name $ace.AccountName
        $ace | Add-Member -MemberType NoteProperty -Name 'PrincipalName' -Value $acePrincipalName -Force
        if (-not $acePrincipalName)
        {
            $acePrincipalName = $ace.AccountName
        }

        if (-not $acePrincipalName -or `
            ($ace.AccessRight -eq 'Read' -and $ReadAccess -notcontains $acePrincipalName) -or `
            ($ace.AccessRight -eq 'Change' -and $ChangeAccess -notcontains $acePrincipalName) -or `
            ($ace.AccessRight -eq 'Full' -and $FullAccess -notcontains $acePrincipalName))
        {
            if (-not $acePrincipalName)
            {
                $acePrincipalName = $ace.AccountName
            }

            if ($PSCmdlet.ShouldProcess($whatIfTarget, "revoke '$($ace.AccountName)' $($ace.AccessRight) access"))
            {
                Write-Message  "- $($ace.AccessRight | Format-AccessRight) ${acePrincipalName}"
                Revoke-SmbShareAccess -Name $Name -AccountName $ace.AccountName -Force | Out-Null
            }
        }
    }

    function Grant-Access
    {
        param(
            [String[]] $PrincipalName,

            [ValidateSet('Full', 'Change', 'Read')]
            [String] $AccessRight
        )

        foreach ($_principalName in $PrincipalName)
        {
            if ($aces | Where-Object 'PrincipalName' -EQ $_principalName | Where-Object 'AccessRight' -EQ $AccessRight)
            {
                Write-Message "${_principalName} ${AccessRight}" -Verbose
                continue
            }

            if ($PSCmdlet.ShouldProcess($whatIfTarget, "grant '$($_principalName)' ${AccessRight} access"))
            {
                Write-Message  "+ $($AccessRight | Format-AccessRight) ${_principalName}"
                Grant-SmbShareAccess -Name $Name -AccountName $_principalName -AccessRight $AccessRight -Force | Out-Null
            }
        }
    }

    Grant-Access -PrincipalName $FullAccess -AccessRight 'Full'
    Grant-Access -PrincipalName $ChangeAccess -AccessRight 'Change'
    Grant-Access -PrincipalName $ReadAccess -AccessRight 'Read'
}



function Test-CSmbShare
{
    <#
    .SYNOPSIS
    Tests if an SMB file share exists on the local computer.
 
    .DESCRIPTION
    The `Test-CSmbShare` function uses `Get-SmbShare` from PowerShell's built-in SmbShare module to check if a file
    share exists on the local computer. If the share exists, `Test-CSmbShare` returns `$true`. Otherwise, it returns
    `$false`.
 
    Use the `-PassThru` switch to return the share object if it exists.
 
    .LINK
    Install-CSmbShare
 
    .LINK
    Uninstall-CSmbShare
 
    .EXAMPLE
    Test-CSmbShare -Name 'CarbonShare'
 
    Demonstrates how to test of a file share exists.
    #>

    [CmdletBinding()]
    param(
        # The name of a specific share to check.
        [Parameter(Mandatory)]
        [String] $Name,

        [switch] $PassThru
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState

    $share = Get-SmbShare -Name $Name -ErrorAction Ignore

    $shareExists = $null -ne $share

    if ($PassThru)
    {
        if ($shareExists)
        {
            return $share
        }
        return $null
    }

    return $shareExists
}




function Uninstall-CSmbShare
{
    <#
    .SYNOPSIS
    Uninstalls/removes a file share from the local computer.
 
    .DESCRIPTION
    The `Uninstall-CSmbShare` function uses `Remove-SmbShare` to uninstall/remove a file share from the local computer,
    if it exists. Pass the name of the share to delete to the `Name` parameter (or pipe the name or share objects to the
    function). If the file share exists, it is deleted. If it doesn't exist, nothing hapens.
 
    .LINK
    Install-CSmbShare
 
    .LINK
    Test-CSmbShare
 
    .EXAMPLE
    Uninstall-CSmbShare -Name 'CarbonShare'
 
    Demonstrates how to uninstall/remove a share from the local computer. If the share does not exist,
    `Uninstall-CSmbShare` silently does nothing (i.e. it doesn't write an error).
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The name of a specific share to uninstall/delete. Wildcards accepted.
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [String] $Name
    )

    process
    {
        Set-StrictMode -Version 'Latest'
        Use-CallerPreference -Cmdlet $PSCmdlet -Session $ExecutionContext.SessionState

        $shares = Test-CSmbShare -Name $Name -PassThru
        if (-not $shares)
        {
            return
        }

        foreach ($share in $shares)
        {
            if ($PSCmdlet.ShouldProcess("SMB file share '$($share.Name)'", "remove"))
            {
                Write-Information "Removing SMB file share ""$($share.Name)"" at ""$($share.Path)""."
                $share | Remove-SmbShare -Force -ErrorAction $ErrorActionPreference -WhatIf:$WhatIfPreference
            }
        }
    }
}




function Use-CallerPreference
{
    <#
    .SYNOPSIS
    Sets the PowerShell preference variables in a module's function based on the callers preferences.
 
    .DESCRIPTION
    Script module functions do not automatically inherit their caller's variables, including preferences set by common
    parameters. This means if you call a script with switches like `-Verbose` or `-WhatIf`, those that parameter don't
    get passed into any function that belongs to a module.
 
    When used in a module function, `Use-CallerPreference` will grab the value of these common parameters used by the
    function's caller:
 
     * ErrorAction
     * Debug
     * Confirm
     * InformationAction
     * Verbose
     * WarningAction
     * WhatIf
     
    This function should be used in a module's function to grab the caller's preference variables so the caller doesn't
    have to explicitly pass common parameters to the module function.
 
    This function is adapted from the [`Get-CallerPreference` function written by David Wyatt](https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d).
 
    There is currently a [bug in PowerShell](https://connect.microsoft.com/PowerShell/Feedback/Details/763621) that
    causes an error when `ErrorAction` is implicitly set to `Ignore`. If you use this function, you'll need to add
    explicit `-ErrorAction $ErrorActionPreference` to every `Write-Error` call. Please vote up this issue so it can get
    fixed.
 
    .LINK
    about_Preference_Variables
 
    .LINK
    about_CommonParameters
 
    .LINK
    https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d
 
    .LINK
    http://powershell.org/wp/2014/01/13/getting-your-script-module-functions-to-inherit-preference-variables-from-the-caller/
 
    .EXAMPLE
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
 
    Demonstrates how to set the caller's common parameter preference variables in a module function.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        #[Management.Automation.PSScriptCmdlet]
        # The module function's `$PSCmdlet` object. Requires the function be decorated with the `[CmdletBinding()]`
        # attribute.
        $Cmdlet,

        [Parameter(Mandatory)]
        # The module function's `$ExecutionContext.SessionState` object. Requires the function be decorated with the
        # `[CmdletBinding()]` attribute.
        #
        # Used to set variables in its callers' scope, even if that caller is in a different script module.
        [Management.Automation.SessionState]$SessionState
    )

    Set-StrictMode -Version 'Latest'

    # List of preference variables taken from the about_Preference_Variables and their common parameter name (taken
    # from about_CommonParameters).
    $commonPreferences = @{
                              'ErrorActionPreference' = 'ErrorAction';
                              'DebugPreference' = 'Debug';
                              'ConfirmPreference' = 'Confirm';
                              'InformationPreference' = 'InformationAction';
                              'VerbosePreference' = 'Verbose';
                              'WarningPreference' = 'WarningAction';
                              'WhatIfPreference' = 'WhatIf';
                          }

    foreach( $prefName in $commonPreferences.Keys )
    {
        $parameterName = $commonPreferences[$prefName]

        # Don't do anything if the parameter was passed in.
        if( $Cmdlet.MyInvocation.BoundParameters.ContainsKey($parameterName) )
        {
            continue
        }

        $variable = $Cmdlet.SessionState.PSVariable.Get($prefName)
        # Don't do anything if caller didn't use a common parameter.
        if( -not $variable )
        {
            continue
        }

        if( $SessionState -eq $ExecutionContext.SessionState )
        {
            Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
        }
        else
        {
            $SessionState.PSVariable.Set($variable.Name, $variable.Value)
        }
    }
}