Thursday, August 17, 2017

List all UserCustomActions in Sharepoint site collections and sub sites via PowerShell

User custom actions (see SPSite.UserCustomActions and SPWeb.UserCustomActions) are powerful mechanism to add customizations on Sharepoint site (on-premise or online) via javascript. E.g. in one of the previous posts I showed how to add custom javascript file to all pages in your site collection without modifying master page: see Add custom javascript file to all pages in on-premise Sharepoint site collection without modifying masterpage and Add custom javascript file to all pages in Sharepoint Online site collection. Sometimes we need to perform inventory of all custom actions with script links. Here is the PowerShell script which iterates through all site collections in provided web application and all sub sites and outputs custom action’s ScriptSrc to the log file:

   1: param(
   2:     [string]$url
   3: )
   4:  
   5: if (-not $url)
   6: {
   7:     Write-Host "Specify web application url in url parameter"
   8: -foregroundcolor red
   9:     return
  10: }
  11:  
  12: function CheckWeb($web)
  13: {
  14:     Write-Host "Web:" $web.Url
  15:     foreach($ac in $web.UserCustomActions)
  16:     {
  17:         ("  " + $ac.ScriptSrc) | Out-File "log.txt" -Append
  18:     }
  19:     
  20:     $web.Webs | ForEach-Object { CheckWeb $_ }
  21: }
  22:  
  23: function CheckSite($site)
  24: {
  25:     Write-Host "Site collection:" $site.Url
  26:     ("Site collection: " +  $site.Url) | Out-File "log.txt" -Append
  27:  
  28:     foreach($ac in $site.UserCustomActions)
  29:     {
  30:         ("  " + $ac.ScriptSrc) | Out-File "log.txt" -Append
  31:     }
  32:     
  33:     CheckWeb $site.RootWeb
  34:     
  35:     ("---------------------------------------") | Out-File "log.txt" -Append
  36: }
  37:  
  38:  
  39: $wa = Get-SPWebApplication $url
  40: $wa.Sites | ForEach-Object { CheckSite $_ }

Here in order to write results to the log file I used approach described in the following post: Write output to the file in PowerShell. And it is quite straightforward to rewrite this script for Sharepoint Online (see article provided above for Sharepoint Online). Hope it will help someone.

Friday, August 11, 2017

Create AD groups programmatically via DirectoryServices without domain admin rights using OU control delegation

In .Net when we need to work with AD we in most cases will use System.DirectoryServices assembly. In order to perform various actions against AD our code should run under account of user which has necessary permissions. Other option is to use security binding – DirectoryEntry constructor which receives username and password as parameter. In this case specified user also should have necessary permissions. The question is which exact permissions are needed e.g. for creating AD groups. Of course if we use account of domain admin it will be enough – users of this group may perform almost all actions over their AD. But it would also open more vulnerabilities as application may harm whole AD if malicious users will hack it.

In Microsoft AD there is another feature called control delegation (Delegate Control) which allows you to worj with AD without having domain admin rights. The idea is that you delegate control over specific AD object (e.g. over Organizational Unit – OU) to the user or group and this user/group will be able to make changes only within this AD object. Let’s see the following test example.

1. At first for clarity we will create new AD account which is not member of any AD group except built-in Domain Users group:

2. After that create Test OU in AD where we will create AD groups:

3. Now let’s try to run the following code which uses DirectoryEntry with security binding with created account for creating new AD group. Current timestamp is used in the group name:

   1: DirectoryEntry dom = new DirectoryEntry("LDAP://SP.DEV",
   2:     "sp\\testuser1", "...", AuthenticationTypes.Secure);
   3:  
   4: DirectoryEntry ou = dom.Children.Find("OU=Test");
   5:  
   6: string name = "test_" + DateTime.Now.ToString("yyyyMMddHHmmss");
   7: DirectoryEntry group = ou.Children.Add("CN=" + name, "group");
   8:  
   9: group.Properties["samAccountName"].Value = name;
  10:  
  11: group.CommitChanges();

The result will be System.UnauthorizedAccessException:

Unhandled Exception: System.UnauthorizedAccessException: Access is denied.

   at System.DirectoryServices.Interop.UnsafeNativeMethods.IAds.SetInfo()
   at System.DirectoryServices.DirectoryEntry.CommitChanges()
   …

4. Now let’s return to AD, right click on OU and select Delegate Control menu item. In the opened wizard we choose our account and “Create, delete and manage groups” tasks:

5. After that if we will run our code it will successfully create new AD group:

As you can see using control delegation we were able to create AD groups without having domain admin rights. Here is the list of all actions for which you can delegate control in AD:

  • Create, delete and manage user accounts
  • Reset user passwords and force password change at next logon
  • Read all user information
  • Create, delete and manage groups
  • Modify the membership of a group
  • Manage group policy links
  • Generate resultant set of policy (planning)
  • Generate resultant set of policy (logging)
  • Create, delete and manage inetOrgPerson accounts
  • Reset inetOrgPerson passwords and force password change at next logon
  • Read all inetOrgPerson information

This is quite powerful feature which you may use for making you code more secure.

Wednesday, August 2, 2017

PowerShell script for listing master pages in all sites of Sharepoint Online site collection via CSOM

If you maintain big site collection in Sharepoint Online it will be useful to know what master pages are used in sub sites. The following script recursively iterates through all sub sites in specific site collection and prints master pages. Also it prints whether or not site master page and custom master page are inherited from the parent web:

   1: param(
   2:     [string]$siteUrl,
   3:     [string]$login,
   4:     [string]$password
   5: )
   6:  
   7: $currentDir = Convert-Path(Get-Location)
   8: $dllsDir = resolve-path($currentDir +
   9:     "\Microsoft.SharePointOnline.CSOM.16.1.6420.1200\lib\net45")
  10:  
  11: [System.Reflection.Assembly]::LoadFile([System.IO.Path]::Combine($dllsDir,
  12:     "Microsoft.SharePoint.Client.dll"))
  13: [System.Reflection.Assembly]::LoadFile([System.IO.Path]::Combine($dllsDir,
  14:     "Microsoft.SharePoint.Client.Runtime.dll"))
  15:  
  16: if (-not $siteUrl)
  17: {
  18:     Write-Host "Specify site url in siteUrl parameter" -foregroundcolor red
  19:     return
  20: }
  21:  
  22: if (-not $login)
  23: {
  24:     Write-Host "Specify user name in login parameter" -foregroundcolor red
  25:     return
  26: }
  27:  
  28: if (-not $password)
  29: {
  30:     Write-Host "Specify user password in password parameter" -foregroundcolor red
  31:     return
  32: }
  33:  
  34: <#
  35: .Synopsis
  36:     Facilitates the loading of specific properties of a Microsoft.SharePoint.Client.ClientObject object or Microsoft.SharePoint.Client.ClientObjectCollection object.
  37: .DESCRIPTION
  38:     Replicates what you would do with a lambda expression in C#. 
  39:     For example, "ctx.Load(list, l => list.Title, l => list.Id)" becomes
  40:     "Load-CSOMProperties -object $list -propertyNames @('Title', 'Id')".
  41: .EXAMPLE
  42:     Load-CSOMProperties -parentObject $web -collectionObject $web.Fields -propertyNames @("InternalName", "Id") -parentPropertyName "Fields" -executeQuery
  43:     $web.Fields | select InternalName, Id
  44: .EXAMPLE
  45:    Load-CSOMProperties -object $web -propertyNames @("Title", "Url", "AllProperties") -executeQuery
  46:    $web | select Title, Url, AllProperties
  47: #>
  48: function Load-CSOMProperties {
  49:     [CmdletBinding(DefaultParameterSetName='ClientObject')]
  50:     param (
  51:         # The Microsoft.SharePoint.Client.ClientObject to populate.
  52:         [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "ClientObject")]
  53:         [Microsoft.SharePoint.Client.ClientObject]
  54:         $object,
  55:  
  56:         # The Microsoft.SharePoint.Client.ClientObject that contains the collection object.
  57:         [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "ClientObjectCollection")]
  58:         [Microsoft.SharePoint.Client.ClientObject]
  59:         $parentObject,
  60:  
  61:         # The Microsoft.SharePoint.Client.ClientObjectCollection to populate.
  62:         [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1, ParameterSetName = "ClientObjectCollection")]
  63:         [Microsoft.SharePoint.Client.ClientObjectCollection]
  64:         $collectionObject,
  65:  
  66:         # The object properties to populate
  67:         [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "ClientObject")]
  68:         [Parameter(Mandatory = $true, Position = 2, ParameterSetName = "ClientObjectCollection")]
  69:         [string[]]
  70:         $propertyNames,
  71:  
  72:         # The parent object's property name corresponding to the collection object to retrieve (this is required to build the correct lamda expression).
  73:         [Parameter(Mandatory = $true, Position = 3, ParameterSetName = "ClientObjectCollection")]
  74:         [string]
  75:         $parentPropertyName,
  76:  
  77:         # If specified, execute the ClientContext.ExecuteQuery() method.
  78:         [Parameter(Mandatory = $false, Position = 4)]
  79:         [switch]
  80:         $executeQuery
  81:     )
  82:  
  83:     begin { }
  84:     process {
  85:         if ($PsCmdlet.ParameterSetName -eq "ClientObject") {
  86:             $type = $object.GetType()
  87:         } else {
  88:             $type = $collectionObject.GetType() 
  89:             if ($collectionObject -is [Microsoft.SharePoint.Client.ClientObjectCollection]) {
  90:                 $type = $collectionObject.GetType().BaseType.GenericTypeArguments[0]
  91:             }
  92:         }
  93:  
  94:         $exprType = [System.Linq.Expressions.Expression]
  95:         $parameterExprType = [System.Linq.Expressions.ParameterExpression].MakeArrayType()
  96:         $lambdaMethod = $exprType.GetMethods() | ? { $_.Name -eq "Lambda" -and $_.IsGenericMethod -and $_.GetParameters().Length -eq 2 -and $_.GetParameters()[1].ParameterType -eq $parameterExprType }
  97:         $lambdaMethodGeneric = Invoke-Expression "`$lambdaMethod.MakeGenericMethod([System.Func``2[$($type.FullName),System.Object]])"
  98:         $expressions = @()
  99:  
 100:         foreach ($propertyName in $propertyNames) {
 101:             $param1 = [System.Linq.Expressions.Expression]::Parameter($type, "p")
 102:             try {
 103:                 $name1 = [System.Linq.Expressions.Expression]::Property($param1, $propertyName)
 104:             } catch {
 105:                 Write-Error "Instance property '$propertyName' is not defined for type $type"
 106:                 return
 107:             }
 108:             $body1 = [System.Linq.Expressions.Expression]::Convert($name1, [System.Object])
 109:             $expression1 = $lambdaMethodGeneric.Invoke($null, [System.Object[]] @($body1, [System.Linq.Expressions.ParameterExpression[]] @($param1)))
 110:  
 111:             if ($collectionObject -ne $null) {
 112:                 $expression1 = [System.Linq.Expressions.Expression]::Quote($expression1)
 113:             }
 114:             $expressions += @($expression1)
 115:         }
 116:  
 117:  
 118:         if ($PsCmdlet.ParameterSetName -eq "ClientObject") {
 119:             $object.Context.Load($object, $expressions)
 120:             if ($executeQuery) { $object.Context.ExecuteQuery() }
 121:         } else {
 122:             $newArrayInitParam1 = Invoke-Expression "[System.Linq.Expressions.Expression``1[System.Func````2[$($type.FullName),System.Object]]]"
 123:             $newArrayInit = [System.Linq.Expressions.Expression]::NewArrayInit($newArrayInitParam1, $expressions)
 124:  
 125:             $collectionParam = [System.Linq.Expressions.Expression]::Parameter($parentObject.GetType(), "cp")
 126:             $collectionProperty = [System.Linq.Expressions.Expression]::Property($collectionParam, $parentPropertyName)
 127:  
 128:             $expressionArray = @($collectionProperty, $newArrayInit)
 129:             $includeMethod = [Microsoft.SharePoint.Client.ClientObjectQueryableExtension].GetMethod("Include")
 130:             $includeMethodGeneric = Invoke-Expression "`$includeMethod.MakeGenericMethod([$($type.FullName)])"
 131:  
 132:             $lambdaMethodGeneric2 = Invoke-Expression "`$lambdaMethod.MakeGenericMethod([System.Func``2[$($parentObject.GetType().FullName),System.Object]])"
 133:             $callMethod = [System.Linq.Expressions.Expression]::Call($null, $includeMethodGeneric, $expressionArray)
 134:             
 135:             $expression2 = $lambdaMethodGeneric2.Invoke($null, @($callMethod, [System.Linq.Expressions.ParameterExpression[]] @($collectionParam)))
 136:  
 137:             $parentObject.Context.Load($parentObject, $expression2)
 138:             if ($executeQuery) { $parentObject.Context.ExecuteQuery() }
 139:         }
 140:     }
 141:     end { }
 142: }
 143:  
 144: function CheckMasterPage($ctx, $web)
 145: {
 146:     Load-CSOMProperties -object $web -propertyNames @("MasterUrl", "CustomMasterUrl",
 147:         "Webs", "AllProperties")
 148:     $ctx.ExecuteQuery()
 149:     
 150:     ($web.Url) | Out-File "log.txt" -Append
 151:     ("    " + $web.MasterUrl) | Out-File "log.txt" -Append
 152:     ("    " + $web.AllProperties["__InheritsMasterUrl"]) | Out-File "log.txt" -Append
 153:     ("    " + $web.CustomMasterUrl) | Out-File "log.txt" -Append
 154:     ("    " + $web.AllProperties["__InheritsCustomMasterUrl"]) | Out-File "log.txt" -Append
 155:     
 156:     foreach ($w in $web.Webs)
 157:     {
 158:         CheckMasterPage $ctx $w
 159:     }
 160: }
 161:  
 162: $securePassword = ConvertTo-SecureString $password -AsPlainText -Force
 163: $credentials =
 164:     New-Object Microsoft.SharePoint.Client.SharePointOnlineCredentials($login,
 165:         $securePassword)    
 166: $ctx = New-Object Microsoft.SharePoint.Client.ClientContext($siteUrl)
 167: $ctx.AuthenticationMode =
 168:     [Microsoft.SharePoint.Client.ClientAuthenticationMode]::Default
 169: $ctx.Credentials = $credentials
 170: $ctx.Load($ctx.Site)
 171: $ctx.Load($ctx.Web)
 172: $ctx.ExecuteQuery()
 173:  
 174: CheckMasterPage $ctx $ctx.Web

Script uses CSOM v.16.1.6420.1200, but you may use other versions of course – download it and save in the script folder (you may need to change path from where assemblies are loaded – lines 8-9). Also it uses helper utility function Load-CSOMProperties (credits go to Gary Lapointe) which is analogue of ClientContext.Load function in C# which allows to specify which properties should be loaded via lambda expressions (in PowerShell there are no lambdas, so we have to use helper function). Script itself is quite simple – it recursively iterates through all sub sites and prints master pages (lines 144-160). Hope it will help someone.