├── .gitignore ├── AzureADAccessReviewsOnPremises ├── AzureADAccessReviewsOnPremises.psm1 └── Readme.MD ├── CODE_OF_CONDUCT.md ├── ExternalIdentityUse ├── README.MD ├── SECURITY.MD ├── external-identity-research-JUL2020.ps1 ├── sample-output-guest-cleanup-FrickelsoftNET.onmicrosoft.com-20-Jul-2020.htm ├── sample-output-guest-cleanup-FrickelsoftNET.onmicrosoft.com-20-Jul-2020.ps1 ├── screenshots │ ├── ExternalIdentityUse.png │ ├── HTM-output.png │ └── README.MD └── style.css ├── LICENSE.MD ├── README.md ├── Refreshed-AccessReviews-API-samples ├── README.MD ├── Refreshed-AccessReviews-Powershell-Samples-DEC2020.ps1 └── screenshots │ ├── README.MD │ ├── relationships.png │ └── structure.png ├── ReviewStaleExternals ├── ARtemplate.json ├── README.MD ├── ReviewStaleExternals-DEC2020.ps1 ├── ReviewStaleExternals-OCT2020.ps1 ├── SECURITY.MD └── screenshots │ ├── StaleIDs.png │ ├── appPermissions.png │ └── disable-and-delete.png └── SECURITY.md /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | -------------------------------------------------------------------------------- /AzureADAccessReviewsOnPremises/AzureADAccessReviewsOnPremises.psm1: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | 5 | #region AuthToken Handling 6 | 7 | #Authentication sample from https://techcommunity.microsoft.com/t5/azure-active-directory/example-how-to-create-azure-ad-access-reviews-using-microsoft/m-p/807241 8 | function Get-GraphExampleAuthTokenServicePrincipal { 9 | [cmdletbinding()] 10 | param 11 | ( 12 | [Parameter(Mandatory = $true)] 13 | $ClientId, 14 | 15 | [Parameter(Mandatory = $true)] 16 | $ClientSecret, 17 | 18 | [Parameter(Mandatory = $true)] 19 | $TenantDomain 20 | ) 21 | 22 | 23 | $tenant = $TenantDomain 24 | 25 | 26 | Write-Verbose "Checking for AzureAD module..." 27 | 28 | $AadModule = Get-Module -Name "AzureAD" -ListAvailable 29 | if ($AadModule -eq $null) { 30 | write-verbose "AzureAD PowerShell module not found, looking for AzureADPreview" 31 | $AadModule = Get-Module -Name "AzureADPreview" -ListAvailable 32 | } 33 | 34 | if ($AadModule -eq $null) { 35 | write-output 36 | write-error "AzureAD Powershell module not installed..." 37 | write-output "Install by running 'Install-Module AzureAD' or 'Install-Module AzureADPreview' from an elevated PowerShell prompt" 38 | write-output "Script can't continue..." 39 | write-output 40 | return "" 41 | } 42 | # Getting path to ActiveDirectory Assemblies 43 | # If the module count is greater than 1 find the latest version 44 | 45 | if ($AadModule.count -gt 1) { 46 | write-verbose "multiple module versions" 47 | $Latest_Version = ($AadModule | select version | Sort-Object)[-1] 48 | $aadModule = $AadModule | ? { $_.version -eq $Latest_Version.version } 49 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 50 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 51 | } 52 | 53 | else { 54 | write-verbose "single module version" 55 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 56 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 57 | } 58 | 59 | Write-verbose "loading $adal and $adalforms" 60 | 61 | 62 | [System.Reflection.Assembly]::LoadFrom($adal) | Out-Null 63 | [System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null 64 | 65 | write-verbose "DLLs loaded" 66 | 67 | # $redirectUri = "urn:ietf:wg:oauth:2.0:oob" 68 | $resourceAppIdURI = "https://graph.microsoft.com" 69 | 70 | $authority = "https://login.microsoftonline.com/$Tenant" 71 | 72 | try { 73 | write-verbose "instantiating ADAL objects for $authority" 74 | $authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority 75 | 76 | write-verbose "client $ClientId $clientSecret" 77 | 78 | $clientCredential = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.ClientCredential" -ArgumentList ($ClientId,$ClientSecret) 79 | 80 | write-verbose "acquiring token for $resourceAppIdURI" 81 | # AuthenticationResult authResult = await authContext.AcquireTokenAsync(BatchResourceUri, new ClientCredential(ClientId, ClientKey)); 82 | # if you get an error about PowerShell not being able to find this method with 2 parameters, it means there is another version of ADAL DLL already in the process space of your PowerShell environment. 83 | 84 | $authResult = $authContext.AcquireTokenAsync($resourceAppIdURI, $clientCredential).Result 85 | # If the accesstoken is valid then create the authentication header 86 | if ($authResult.AccessToken) { 87 | write-verbose "acquired token" 88 | # Creating header for Authorization token 89 | $authHeader = @{ 90 | 'Content-Type' = 'application/json' 91 | 'Authorization' = "Bearer " + $authResult.AccessToken 92 | 'ExpiresOn' = $authResult.ExpiresOn 93 | } 94 | return $authHeader 95 | } 96 | else { 97 | write-output "" 98 | write-output "Authorization Access Token is null, please re-run authentication..." 99 | write-output "" 100 | break 101 | } 102 | } 103 | catch { 104 | write-output $_.Exception.Message 105 | write-output $_.Exception.ItemName 106 | write-output "" 107 | break 108 | } 109 | } 110 | #endregion 111 | 112 | $_SampleInternalAuthNHeaders = @() 113 | $_userList = @() 114 | 115 | # exported module member 116 | function Connect-AzureADMSARSample { 117 | [CmdletBinding()] 118 | param( 119 | [Parameter(Mandatory=$true)] 120 | [ValidateScript({ 121 | try { 122 | [System.Guid]::Parse($_) | Out-Null 123 | $true 124 | } catch { 125 | throw "$_ is not a valid GUID" 126 | } 127 | })] 128 | [string]$ClientApplicationId, 129 | 130 | [Parameter(Mandatory=$true)] 131 | [string]$ClientSecret, # base64 client secret. Note this as a command line parameter is for testing purposes only 132 | 133 | [Parameter(Mandatory=$true)] 134 | [string]$TenantDomain # e.g., microsoft.onmicrosoft.com 135 | ) 136 | 137 | $script:_SampleInternalAuthNHeaders = @() 138 | 139 | 140 | $authHeaders = Get-GraphExampleAuthTokenServicePrincipal -ClientId $ClientApplicationId -ClientSecret $ClientSecret -TenantDomain $TenantDomain 141 | 142 | $script:_SampleInternalAuthNHeaders = $authHeaders 143 | 144 | } 145 | 146 | 147 | function Get-InternalAuthNHeaders { 148 | [CmdletBinding()] 149 | param() 150 | 151 | try { 152 | 153 | $authResult = $script:_SampleInternalAuthNHeaders 154 | if ($authResult.Length -eq @()) { 155 | Throw "Connect-AzureADMSARSample must be called first" 156 | } 157 | 158 | } catch { 159 | Throw # "Connect-AzureADMSControls must be called first" 160 | } 161 | return $authResult 162 | } 163 | 164 | <# 165 | .Synopsis 166 | Retrieves decisions for a single Access Review and displays Powershell commands for Windows Active Directory to be executed to apply Access Reviews results on-premises. 167 | 168 | .Description 169 | Retrieves the decisions for a single Access Review, identified by the reviewID. Checks whether the Access Review reviews an on-premises group and if so, loads the decisions for it. If there are "deny" decisions for users, it will display a Powershell command that removes denied users from the Windows Active Directory group. This is to apply Access Reviews decisions to on-premises groups. 170 | 171 | .Parameter reviewID 172 | This is the objectID of the Access Review 173 | 174 | .Parameter filePath 175 | This is the full file path for a TXT file that Powershell commands for Windows AD are written to. 176 | 177 | .Example 178 | # Retrieve changes for on-premises group membership from the results of an Access Review - and print Powershell commands: 179 | Get-AzureADARSignleReviewOnPrem -reviewId 180 | 181 | .Example 182 | # Retrieve changes for on-premises group membership from the results of an Access Review - export the Powershell commands into a TXT file: 183 | Get-AzureADARSingleReviewOnPrem -reviewId "20924e60-a9fb-4891-9c92-f30c47636484" -filePath "C:\temp\WindowsADCommands.txt" 184 | 185 | #> 186 | function Get-AzureADARSingleReviewOnPrem 187 | { 188 | [CmdletBinding()] 189 | param( 190 | [Parameter()] 191 | [ValidateScript({ 192 | try { 193 | [System.Guid]::Parse($_) | Out-Null 194 | $true 195 | } catch { 196 | throw "$_ is not a valid GUID" 197 | } 198 | })] 199 | [string]$reviewId, 200 | [Parameter()] 201 | [alias("fp")] 202 | [ValidateScript({Test-Path $_})] 203 | [string] 204 | $filePath 205 | ) 206 | 207 | $callURL = "https://graph.microsoft.com/beta/accessReviews/" + $reviewId 208 | $callURL += '/?$select=status,reviewedEntity' 209 | 210 | $response = Invoke-WebRequest -UseBasicParsing -headers $_SampleInternalAuthNHeaders -Uri $callURL -Method Get 211 | 212 | if ($response -eq $null -or $response.Content -eq $null) { 213 | throw "ERROR: We did not get a response from $callURL" 214 | } 215 | 216 | $result = ConvertFrom-Json $response.Content 217 | 218 | #Extract the status and the id from the Access Review that we've found. If the status is not "Completed", we should abort. 219 | if($result.Status -ne "Completed" -and $result.Status -ne "Applied") 220 | { throw "ERROR: The Access Review you requested is not completed. Check whether it is still running."} 221 | if($result.reviewedEntity.ID -eq $null -or $result.reviewedEntity.ID -eq "") 222 | { throw "ERROR: There's no reviewed resource." } 223 | 224 | #Now let's take a closer look at the group in question. 225 | $groupID = $result.reviewedEntity.ID 226 | 227 | $isGroupOnprem = Get-GroupByID $_SampleInternalAuthNHeaders $groupID ##if the group comes from on-premises, we are getting the SID from on-premises Windows AD back. Otherwise $null. 228 | if($isGroupOnprem -eq $null) 229 | { 230 | throw "The group is not from on-premises, aborting." #The group is not from on-premises. Let's stop here. 231 | } 232 | 233 | #now start building a list of users to remove from the group. 234 | Get-ReviewResultsToApply $_SampleInternalAuthNHeaders $reviewId 235 | Write-Host "We should remove $($Script:_userList.Count) users from the on-premises group $groupID" 236 | 237 | if($autoExecute -eq $true) { Run-GroupCleanup } else { $commandForOnPremises = Construct-CommandsToExecute $isGroupOnprem } 238 | 239 | Write-Host $commandForOnPremises 240 | if($filePath) 241 | { 242 | $commandForOnPremises | Out-File ($filePath) 243 | } 244 | Write-Host "." #We're done. 245 | 246 | } 247 | 248 | function Construct-CommandsToExecute($onPremGroupID) 249 | { 250 | $members_to_delete = "" 251 | foreach($u in $Script:_userList) 252 | { 253 | $members_to_delete += """$u""," 254 | } 255 | 256 | #there's a trailing "," that we need to get rid of 257 | $members_to_delete = $members_to_delete -replace ".$" 258 | 259 | 260 | #Remove-ADGroupMember -Identity "DocumentReaders" -Members administrator,DavidChew 261 | return "Remove-ADGroupMember -Identity $onPremGroupID -Members $members_to_delete" 262 | 263 | } 264 | 265 | function Get-ReviewResultsToApply($authHeaders, $reviewID) 266 | { 267 | #Call Graph to find pull the decisions of the Access Review. 268 | #We should be getting a list of users that were denied and need removing from on-premises groups. 269 | #We are requesting 20 results at a time. We're using paging here. 270 | $decisionURL = "https://graph.microsoft.com/beta/accessReviews/" + $reviewId + "/decisions/" 271 | $decisionURL += '?$filter=' + "(reviewResult eq 'Deny')" 272 | $decisionURL += '&$top=20&$skip=0' ##&$select=userId,reviewResult ##we ask for 20 at a time. 273 | 274 | $applyResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $decisionURL -Method Get 275 | 276 | if ($applyResponse -eq $null -or $applyResponse.Content -eq $null) { 277 | throw "ERROR: We did not get a response from $callURL" 278 | } 279 | 280 | $applyResult = ConvertFrom-Json $applyResponse.Content 281 | $data = $applyResult.Value 282 | 283 | #Let's check if Graph told us there are more results for us to fetch. If so, let's loop through the results until we have all. 284 | while($applyResult.'@odata.nextLink') 285 | { 286 | $nextURL = $applyResult.'@odata.nextLink' 287 | 288 | $applyResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $nextURL -Method Get 289 | 290 | if ($applyResponse -eq $null -or $applyResponse.Content -eq $null) { 291 | throw "ERROR: We did not get a response from $nextURL" 292 | } 293 | 294 | $applyResult = ConvertFrom-Json $applyResponse.Content 295 | $data += $applyResult.Value 296 | } 297 | 298 | foreach($r in $data) 299 | { 300 | if($r.reviewResult -eq 'Deny') 301 | { 302 | $user_onprem = Get-UsersOnPremSIDbyID $authHeaders $r.userId 303 | $Script:_userList += $user_onprem 304 | } 305 | } 306 | } 307 | 308 | function Get-GroupByID($authHeaders, $groupID) 309 | { 310 | $groupURL = "https://graph.microsoft.com/v1.0/groups/" + $groupID 311 | $groupURL += '?$select=onPremisesSecurityIdentifier,onPremisesLastSyncDateTime' 312 | $groupResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $groupURL -Method Get 313 | 314 | $groupResult = ConvertFrom-Json $groupResponse.Content 315 | 316 | #Did we get a result? 317 | if ($groupResult -eq $null -and $groupResult.Content -eq $null) { 318 | throw "ERROR: We did not get a response from Graph, asking for the group, $groupURL" 319 | } 320 | #Qualifying the result. If the SID OR the onPremisesLastSyncDateTime are null or empty, we have reason to believe it's not an on-premises group. 321 | #We can abort then. 322 | if($groupResult.onPremisesSecurityIdentifier -eq $null -or $groupResult.onPremisesSecurityIdentifier -eq "" -or $groupResult.onPremisesLastSyncDateTime -eq $null -or $groupResult.onPremisesLastSyncDateTime -eq "") 323 | { return $null; } 324 | 325 | return $groupResult.onPremisesSecurityIdentifier; 326 | } 327 | 328 | function Get-UsersOnPremSIDbyID($authHeaders, $userID) 329 | { 330 | $usersURL = "https://graph.microsoft.com/v1.0/users/" + $userID + "/" 331 | $usersURL += '?$select=onPremisesSecurityIdentifier,onPremisesSyncEnabled' 332 | 333 | $usersResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $usersURL -Method Get 334 | 335 | if ($usersResponse -eq $null -or $usersResponse.Content -eq $null) { 336 | throw "ERROR: We did not get a response from $usersResponse" 337 | } 338 | 339 | $usersResult = ConvertFrom-Json $usersResponse.Content 340 | if($usersResult.onPremisesSyncEnabled -ne "true") { throw "ERROR: The user is not on-premises synchronized. It may be a cloud-managed user." } 341 | return $usersResult.onPremisesSecurityIdentifier 342 | } 343 | 344 | <# 345 | .Synopsis 346 | Retrieves decisions for a multiple Access Reviews and displays Powershell commands for Windows Active Directory to be executed to apply Access Reviews results on-premises. 347 | 348 | .Description 349 | Retrieves the decisions for multiple, past Access Reviews. Will retrieve results for as many past Access Reviews as defined by "maxReviews" parameter. Checks whether the Access Review reviews an on-premises group and if so, loads the decisions for it. If there are "deny" decisions for users, it will display a Powershell command that removes denied users from the Windows Active Directory group. This is to apply Access Reviews decisions to on-premises groups. 350 | 351 | .Parameter maxResults 352 | Defines the maximum number of Access Reviews to load and inspect. 353 | 354 | .Parameter filePath 355 | This is the full file path for a TXT file that Powershell commands for Windows AD are written to. 356 | 357 | .Example 358 | # Retrieve the 50 last Access Reviews and check whether they reviewed an on-premises group. If so, display Powershell commands to execute required changes against Windows Active Directory. 359 | Get-AzureADARAllReviewsOnPrem 360 | 361 | .Example 362 | Retrieve the 15 last Access Reviews and check whether they reviewed an on-premises group. If so, display Powershell commands to execute required changes against Windows Active Directory. 363 | Get-AzureADARAllReviewsOnPrem -maxReviews 15 364 | 365 | .Example 366 | # Retrieve the 15 last Access Reviews and check whether they are reviewed an on-premises group. If so, display Powershell commands to execute required changes against Windows Active Directory. Export the Powershell commands into a TXT file: 367 | Get-AzureADARAllReviewsOnPrem -filePath "C:\temp\WindowsADCommands.txt" -maxReviews 15 368 | 369 | #> 370 | function Get-AzureADARAllReviewsOnPrem 371 | { 372 | [CmdletBinding()] 373 | param( 374 | [Parameter()] 375 | [ValidateScript({ 376 | try { 377 | if($_ -gt 0 -and $_ -lt 200) { $true } 378 | else { $false } 379 | } catch { 380 | throw "$_ exceeds the recommended boundaries - must be 1 and 200." 381 | } 382 | })] 383 | [int]$maxReviews = 50, 384 | [Parameter()] 385 | [alias("fp")] 386 | [ValidateScript({Test-Path $_})] 387 | [string] 388 | $filePath 389 | 390 | ) 391 | 392 | $allReviews = "https://graph.microsoft.com/beta/accessReviews?" 393 | $allReviews += '$filter=businessFlowTemplateId eq ''6e4f3d20-c5c3-407f-9695-8460952bcc68'' AND status eq ''Completed'' OR status eq ''Applied''' 394 | $allReviews += '&$select=id,reviewedEntity,status' 395 | $allReviews += '&$top='+$maxReviews+'&$skip=0' #filtering 396 | $allReviews = Invoke-WebRequest -UseBasicParsing -headers $_SampleInternalAuthNHeaders -Uri $allReviews -Method Get 397 | 398 | if ($allReviews -eq $null -or $allReviews.Content -eq $null) { 399 | throw "ERROR: we couldn't get an overview of Access Reviews through Graph." 400 | } 401 | 402 | $allReviewResult = ConvertFrom-Json $allReviews.Content 403 | 404 | foreach($review in $allReviewResult.Value) 405 | { 406 | #Let's iterate through all review objects that Graph gave us. 407 | 408 | #some sanity checks first. 409 | if($review.Status -ne "Completed" -and $review.Status -ne "Applied") 410 | { throw "ERROR: The Access Review you requested is not completed. Check whether it is still running."} ## this shouldn't happen, if the $select is constructed correctly. 411 | if($review.reviewedEntity.ID -eq $null -or $review.reviewedEntity.ID -eq "") 412 | { throw "ERROR: There's no reviewed resource!?" } 413 | 414 | #Since we want to apply decisions on on-prereviewedEntity.ID) 415 | $currentGroup = $($review.reviewedEntity.ID) 416 | $isGroupOnprem = Get-GroupByID $_SampleInternalAuthNHeaders $currentGroup ##if the group comes from on-premises, we are getting the onPremises-SID back. Otherwise $null. 417 | if($isGroupOnprem -eq $null) 418 | { 419 | Write-Host "$($review.id) did not review an on-premises group." 420 | } 421 | else 422 | { 423 | 424 | #Okay, this review has a group that is on-premises - fantastic. How about we look into the Access Review's decision and collect deleted users? 425 | Get-ReviewResultsToApply $_SampleInternalAuthNHeaders $($review.id) 426 | 427 | $commandForOnPremises = Construct-CommandsToExecute $isGroupOnprem 428 | $Script:_userList=@() 429 | Write-Host $commandForOnPremises 430 | if($filePath) 431 | { 432 | $commandForOnPremises | Out-File $filePath -Append 433 | } 434 | } 435 | 436 | } 437 | 438 | Write-Host "." #We're done. 439 | 440 | } 441 | 442 | 443 | Export-modulemember -function Connect-AzureADMSARSample 444 | Export-modulemember -function Get-AzureADARSingleReviewOnPrem 445 | Export-modulemember -function Get-AzureADARAllReviewsOnPrem -------------------------------------------------------------------------------- /AzureADAccessReviewsOnPremises/Readme.MD: -------------------------------------------------------------------------------- 1 | # Enforcing Azure AD Access Reviews decisions on on-premises-managed groups (AzureADAccessReviewsOnPremises) 2 | ## Synopsis 3 | 4 | This Powershell sample module exposes Powershell functions that allow to read Access Review results and - in case the Access Review was for an on-premises managed group - display Powershell commands for Windows AD, to enforce group membership changes. 5 | 6 | Access Reviews, today, can review on-premises-managed security groups. However, Access Reviews cannot (yet) enforce group membership changes as a result of Access Review decisions. 7 | 8 | This script shows one possible scripted solution: it reads one or more Access Reviews, inspects whether the review(s) targeted on-premises security groups and if so, evaluates whether there were any "Deny" decisions by the reviewer, requiring a change in the on-premises group. If so, it will show Powershell commands that can be executed against Windows AD, to perform the required changes. 9 | 10 | > Connect-AzureADMSARSample -ClientApplicationId -ClientSecret -TenantDomain "yourTenant.onmicrosoft.com" 11 | 12 | > Get-AzureADARSingleReviewOnPrem "20924e60-a9fb-4891-9c92-f30c47636484" -filePath "C:\temp\WindowsADCommands.txt" 13 | 14 | > Removing 2 users from the group 8bbb95e4-cc93-4026-9afa-769fd89aa099 15 | 16 | > Remove-ADGroupMember -Identity S-1-5-21-3579548627-1650277345-491116358-158602 -Members "S-1-5-21-3579548627-1650277345-491116358-5127","S-1-5-21-3579548627-1650277345-491116358-5 17 | 569" 18 | 19 | 20 | ## Prerequisites 21 | This Powershell module runs in an application context, which requires that you create an application registration in Azure AD for this script, and admin-consent for the required permissions for Microsoft Graph. 22 | 23 | The steps are as follows: 24 | 1. Log into the Azure portal as a global administrator. 25 | 2. In the Azure portal, go to Azure Active Directory, and then click App registrations on the left. 26 | 3. Click New registration. Give your app a name, and then click Register. 27 | 4. Copy and save for later the application (client) ID that appears after the app is registered. 28 | 5. On the left, click API permissions. 29 | 6. Click "Add a permission", click "Microsoft Graph", and then click "Application permissions". 30 | 7. In the Select permissions list, select the following permissions: (a) AccessReview.Read.All, (b) Group.Read.All and (c) User.Read.All. 31 | 8. Click Add permissions. 32 | 9. Click to Grant admin consent for and then click Yes. The status for each permission the app needs should change to a green checkmark, indicating consent was granted. 33 | 10. On the left, click Certificates & secrets. 34 | 11. Click New client secret and then for Expires select an expiry date that's a month away in the future. This will allow you to test sensibly, but not infinitely keep the credentials/secret valid. Click Add. 35 | 12. Copy and save locally the value of the secret that appears- you won’t see it again after you leave this part of the UI. 36 | 37 | ## Exported functions 38 | 39 | This sample module exports the following Azure AD functions: 40 | 41 | Export-modulemember -function Connect-AzureADMSARSample 42 | 43 | Export-modulemember -function Get-AzureADARSingleReviewOnPrem 44 | 45 | Export-modulemember -function Get-AzureADARAllReviewsOnPrem 46 | 47 | ## Exported functions 48 | ### Function Get-AzureADARSingleReviewOnPrem 49 | 50 | Get-AzureADARSingleReviewOnPrem -reviewId "20924e60-a9fb-4891-9c92-f30c47636484" -filePath "C:\temp\WindowsADCommands.txt" 51 | 52 | [-filePath] to define a text-based file the function writes the Windows AD Powershell command to. 53 | [-reviewId] to define the objectID of the Access Review you want to target and pull the review decisions from. 54 | 55 | **.Synopsis** 56 | Retrieves decisions for a single Access Review and displays Powershell commands for Windows Active Directory to be executed to apply Access Reviews results on-premises. 57 | 58 | **.Description** 59 | Retrieves the decisions for a single Access Review, identified by the reviewID. Checks whether the Access Review reviews an on-premises group and if so, loads the decisions for it. If there are "deny" decisions for users, it will display a Powershell command that removes denied users from the Windows Active Directory group. This is to apply Access Reviews decisions to on-premises groups. 60 | 61 | **.Parameter reviewID** 62 | This is the objectID of the Access Review 63 | 64 | **.Parameter filePath** 65 | This is the full file path for a TXT file that Powershell commands for Windows AD are written to. 66 | 67 | **.Example** 68 | Retrieve changes for on-premises group membership from the results of an Access Review - and print Powershell commands: 69 | Get-AzureADARSignleReviewOnPrem -reviewId 70 | 71 | **.Example** 72 | Retrieve changes for on-premises group membership from the results of an Access Review - export the Powershell commands into a TXT file: 73 | Get-AzureADARSingleReviewOnPrem -reviewId "20924e60-a9fb-4891-9c92-f30c47636484" -filePath "C:\temp\WindowsADCommands.txt" 74 | 75 | 76 | 77 | ### Function AzureADARAllReviewsOnPrem 78 | 79 | Get-AzureADARAllReviewsOnPrem -filePath "C:\temp\WindowsADCommands.txt" -maxReviews 15 80 | 81 | [-filePath] to define a text-based file the function writes the Windows AD Powershell command to. 82 | [-maxReviews] to define how many Access Reviews to inspect and look through, to determine whether they are targeted an on-premises managed group. 83 | 84 | **.Synopsis** 85 | Retrieves decisions for a multiple Access Reviews and displays Powershell commands for Windows Active Directory to be executed to apply Access Reviews results on-premises. 86 | 87 | **.Description** 88 | Retrieves the decisions for multiple, past Access Reviews. Will retrieve results for as many past Access Reviews as defined by "maxReviews" parameter. Checks whether the Access Review reviews an on-premises group and if so, loads the decisions for it. If there are "deny" decisions for users, it will display a Powershell command that removes denied users from the Windows Active Directory group. This is to apply Access Reviews decisions to on-premises groups. 89 | 90 | **.Parameter maxResults** 91 | Defines the maximum number of Access Reviews to load and inspect. 92 | 93 | **.Parameter filePath** 94 | This is the full file path for a TXT file that Powershell commands for Windows AD are written to. 95 | 96 | **.Example** 97 | Retrieve the 50 last Access Reviews and check whether they reviewed an on-premises group. If so, display Powershell commands to execute required changes against Windows Active Directory. 98 | Get-AzureADARAllReviewsOnPrem 99 | 100 | **.Example** 101 | Retrieve the 15 last Access Reviews and check whether they reviewed an on-premises group. If so, display Powershell commands to execute required changes against Windows Active Directory. 102 | Get-AzureADARAllReviewsOnPrem -maxReviews 15 103 | 104 | **.Example** 105 | Retrieve the 15 last Access Reviews and check whether they are reviewed an on-premises group. If so, display Powershell commands to execute required changes against Windows Active Directory. Export the Powershell commands into a TXT file: 106 | Get-AzureADARAllReviewsOnPrem -filePath "C:\temp\WindowsADCommands.txt" -maxReviews 15 107 | 108 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /ExternalIdentityUse/README.MD: -------------------------------------------------------------------------------- 1 | # Gathering information around external identity proliferation (for Access Reviews Disable-and-Delete) in Azure AD 2 | ## Synopsis 3 | 4 | This Powershell sample script is meant to create a high-level overview over external identity use in a tenant, outlining if and where external identities are used: 5 | * group membership 6 | * application assignment 7 | * assignment to privileged roles 8 | * membership through rules in a dynamic group 9 | 10 | The script is enumerating membership and assignments in Azure AD. It does not reach out to services that keep membership or role assignments outside of Azure AD (e.g. Sharepoint Online with direct user-to-role assignment outside of group membership). 11 | 12 | This script has two file outputs, once executed correctly: 13 | 1. an HTM file that outlines found external identities, their home domains and where they were assigned permissions and group membership in the tenant 14 | 2. a Powershell PS1 script file that allows creating new Azure AD groups to collect and group external identities for an Access Review. 15 | 16 | This Powershell script can be used to get an overview of external identities that do not have any assignments in groups or applications any more, hence, should be reviewed via Access Reviews for disable&delete from the tenant. 17 | 18 | ## How to use this script and its output 19 | 20 | This sample script intends to assist Administrators and Compliance Auditors in organizations that use Azure AD for Business-to-Business (B2B) collaobration in finding, reviewing and - should need be - clean up external identity references from their Azure AD. As with internal users and employees - you want to ensure when collaborating with external partners, vendors and supplies that 21 | 22 | > the right people have the right access at the right time. 23 | 24 | This script is the first step in discovering the use of external identities in your Azure AD tenant. It outlines where external identities are used and where external identities with potentially no permissions to resources exist, that can be cleaned up. Using the HTM output report and the PS1 script file, administrators will gain better clarity about the use of external identities in their tenant and can, based on the report, plan next steps to group (using the PS1 script) and review (using Azure AD Access Reviews) and re-attest continued need for external identities in the tenant. 25 | 26 | This script drives awareness of external identities and prepares administrators to be able to plan their Access Reviews deployment and setup, to review external identities' access, as well as the need for continued presence for external identities in their tenant. 27 | 28 | Learn more: 29 | 30 | [Azure AD - Access Reviews](https://docs.microsoft.com/en-us/azure/active-directory/governance/access-reviews-overview) 31 | 32 | [Azure AD - Identity Governance](https://docs.microsoft.com/en-us/azure/active-directory/governance/identity-governance-overview) 33 | 34 | [Azure AD - External Collaboration](https://docs.microsoft.com/en-us/azure/active-directory/b2b/what-is-b2b) 35 | 36 | 37 | ![How we suggest you use this script](./screenshots/ExternalIdentityUse.png) 38 | 39 | The HTM output this script generates outlines found external identities in the tenant and their use: 40 | 41 | ![The HTM output file generated from the script](./screenshots/HTM-output.png) 42 | 43 | 44 | ## Prerequisites and starting the script 45 | This Powershell module runs in user context, which requires that the user account you run this with has privileges to read the directory. 46 | 47 | If you want to learn what the script does, download the two sample output files, and get an overview of what the script generates and the style.CSS file to format the HTM output: 48 | * sample-output-guest-cleanup-FrickelsoftNET.onmicrosoft.com-20-Jul-2020.htm 49 | * style.CSS 50 | * sample-output-guest-cleanup-FrickelsoftNET.onmicrosoft.com-20-Jul-2020.PS1 51 | 52 | If you want to download the script and gather information, download the script file, as well as the .CSS style file, to format your tenant-specific output: 53 | * style.CSS 54 | * external-identity-research-JUL2020.ps1 55 | 56 | ## Exported functions 57 | The script will run in the context of the executing user. An Azure AD user with reading permissions on the directory for users, groups, apps and roles is sufficient. The script, once started, will pop-up an Azure AD Sign-In dialog to sign-in into Azure AD. 58 | ```Powershell 59 | .\external-identity-research-JUL2020.PS1 -filePath C:\temp 60 | ``` 61 | 62 | 63 | [-filePath] to define the path the two output files (HTM and PS1) are created in. Use with a full path, such as: 64 | 65 | ```Powershell 66 | .\external-identity-research-JUL2020.PS1 -filePath C:\temp 67 | 68 | .\external-identity-research-JUL2020.PS1 -filePath "C:\users\jennifer\Downloads\script files" 69 | ``` 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /ExternalIdentityUse/SECURITY.MD: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /ExternalIdentityUse/external-identity-research-JUL2020.ps1: -------------------------------------------------------------------------------- 1 | # azuread-sample-suggest-guest-cleanup-candidate-groups.ps1 2 | # Copyright 2020 Microsoft Corporation 3 | # 4 | # Example for locating guest users to review 5 | # Those guests from managed domains, and who have no other group memberships 6 | # This script produces a PS1 file as output, it does not itself create or update groups. 7 | # 8 | # This scripts requires AzureADPreview module. 9 | # 10 | # This material is provided "AS-IS" and has no warranty. 11 | # 12 | # Last updated July 14th, 2020 13 | # 14 | 15 | 16 | Param( 17 | [Switch]$IncludeNonManagedUsers = $false, 18 | [Switch]$IncludeAccountBlockedUsers = $false, 19 | [Parameter()] 20 | [alias("fp")] 21 | [ValidateScript({Test-Path $_})] 22 | [string] 23 | $filePath 24 | ) 25 | 26 | #region "Global Variables" 27 | 28 | $global:CountCandidateUsers = 0 29 | 30 | $global:CountEvaluatedUsers = 0 31 | $global:CountSkippedBlockedUsers = 0 32 | $global:CountSkippedDirSyncUsers = 0 33 | $global:CountSkippedInternalUsers = 0 34 | $global:CountSkippedInvalidUpns = 0 35 | $global:CountSkippedUnretrievable = 0 36 | $global:CountSkippedSocialUsers = 0 37 | $global:CountInvalidUpns = 0 38 | 39 | $global:CountSkippedDomains = 0 40 | 41 | $global:CountSkippedGroups = 0 42 | $global:CountSkippedDynamicGroups = 0 43 | $global:CountInvalidGroupMemberCount = 0 44 | 45 | 46 | $global:MembershipsPassed = 0 47 | 48 | $global:GroupObjects = @{} 49 | $global:Groups1 = @{} 50 | $global:Domains1 = @{} 51 | $global:Groups2 = @{} 52 | $global:Domains2 = @{} 53 | 54 | $global:Apps1 = @{} 55 | $global:DirectoryRoles = @{} 56 | $global:DirectoryRoles2 = @{} 57 | 58 | $global:ManagedDomains = @{} 59 | $global:ConsumerDomains = @{} 60 | 61 | $global:UsersNotReadyForRemoval = @() 62 | $global:UsersNotReadyForRemovalDueToApps = @() 63 | $global:UsersReadyForRemoval = @() 64 | 65 | $global:PShInstructions = @{} 66 | 67 | $global:HtmlOutputFilename = "suggest-groups.htm" 68 | $global:PSOutputFilename = "create-groups.ps1" 69 | 70 | #endregion 71 | 72 | function Test-Viral2 ($origupn) { 73 | 74 | $uri = "https://login.microsoftonline.com/common/userrealm?user=" + $origupn + "&api-version=2.1" 75 | 76 | try { 77 | $resp1 = Invoke-WebRequest -UseBasicParsing -Uri $uri -method Get 78 | } catch { 79 | return $false 80 | } 81 | 82 | $j1 = ConvertFrom-Json $resp1.Content 83 | 84 | $nst = $j1.NameSpaceType 85 | if ($nst -eq "Federated") { 86 | 87 | $cd = $j1.ConsumerDomain 88 | 89 | if ($cd -eq "true") { 90 | write-verbose "Adding user from consumer tenant: $origupn" 91 | return $true 92 | } 93 | 94 | write-verbose "Skipping user from federated tenant: $origupn" 95 | return $false 96 | } elseif ($nst -eq "Managed") { 97 | 98 | $isv = $j1.IsViral 99 | 100 | if ($isv -eq "true") { 101 | write-verbose "Adding user from viral tenant: $origupn" 102 | 103 | return $true 104 | } else { 105 | write-verbose "Skipping user from non-viral managed directory: $origupn" 106 | return $false 107 | } 108 | 109 | } else { 110 | write-verbose "Namespace type for $origupn : $nst" 111 | } 112 | return $true 113 | } 114 | 115 | 116 | function RecordDomain ($objectid,$upn,$oldu,$domain) 117 | { 118 | $displayname = $oldu.DisplayName 119 | $rtvf = $oldu.RefreshTokensValidFromDateTime 120 | $userState = $oldu.UserState 121 | $usco = $oldu.UserStateChangedOn 122 | 123 | write-verbose "recorddomain enter" 124 | if ($global:Domains1.ContainsKey($domain) -eq $false) { 125 | $users = @() 126 | 127 | } else { 128 | $users = $global:Domains1[$domain] 129 | } 130 | $gl = [System.Collections.ArrayList]@(); 131 | $nu = [PSCustomObject]@{ 132 | ObjectId = $objectid; 133 | UPN = $upn; 134 | DisplayName = $displayname; 135 | Groups = $gl; 136 | RefreshTokensValidFromDateTime = $rtvf; 137 | UserState = $userState; 138 | UserStateChangedOn = $usco; 139 | } 140 | $users += $nu 141 | $global:CountCandidateUsers++ 142 | $global:Domains1[$domain] = $users 143 | write-verbose "recorddomain exit" 144 | return $nu 145 | } 146 | 147 | function AddDomainToMemberArray ($domain,$members,$nu) 148 | { 149 | if ($members.ContainsKey($domain) -eq $false) { 150 | $users = @() 151 | } else { 152 | $users = $members[$domain] 153 | } 154 | 155 | $users += $nu 156 | $members[$domain] = $users 157 | return $members 158 | } 159 | 160 | function ParseUserMemberships ($uobjectid,$upn,$msa,$domain,$nu) 161 | { 162 | try { 163 | $gml = Get-AzureADUserMembership -ObjectId $uobjectid -All $true 164 | 165 | 166 | } catch { 167 | $global:CountSkippedUnretrievable++ 168 | return 169 | } 170 | 171 | 172 | foreach ($m in $gml) { 173 | $gobjectid = $m.ObjectId 174 | #addres = $nu.Groups.Add($gobjectid) 175 | if ($global:Groups1.ContainsKey($gobjectid) -eq $false) { 176 | $members = @{} 177 | } else { 178 | $members = $global:Groups1[$gobjectid] 179 | } 180 | $members = AddDomainToMemberArray $domain $members $nu 181 | $global:Groups1[$gobjectid] = $members 182 | $global:GroupObjects[$gobjectId] = $m 183 | } 184 | 185 | } 186 | 187 | function GetDomainFromUpn() { 188 | # external user's UPNs show like this: madeline_identities.wtf#EXT#@FrickelsoftNET.onmicrosoft.com - we need to convert that to madeline@identities.wtf 189 | $usplit1 = $upn.Split("#") 190 | $lhs1 = $usplit1[0] 191 | if ($lhs1.Contains("_") -eq $False) { 192 | $CountSkippedInvalidUpns++ 193 | return $null 194 | } 195 | $usplit2 = $lhs1.Split("_") 196 | $dindex = $usplit2.Count 197 | $dindex-- 198 | $domain =$usplit2[$dindex].ToLower() 199 | 200 | if ($domain.Contains(".") -eq $false) { 201 | $global:CountInvalidUpns++ 202 | return $null 203 | } 204 | return $domain 205 | } 206 | 207 | 208 | function findExternalsWithoutGroupMembership () { 209 | 210 | Write-Progress -Activity "Retrieving Guest Users..." 211 | 212 | $users = Get-AzureADUser -Filter "usertype eq 'Guest'" -All $true 213 | $totalUsers = $users.Count 214 | 215 | 216 | Write-Progress -Activity "Retrieving Guest Users..." -Completed -Status "Done, $totalUsers retrieved" 217 | Write-Progress -Activity "Retrieving Group Memberships..." 218 | 219 | $cur = 0 220 | 221 | foreach ($u in $users) { 222 | 223 | #We have all users with userType "Guest" here now. However, we may have caught (a) users that are in a blocked state (accountEnabled = true) or 224 | #that were synchronized from on-premises with userType=Guest (aka. "sync as guest" scenario with AADConnect. We want to count them differently). 225 | 226 | $frac = [math]::round(($cur * 100) / $totalUsers) 227 | $cur++ 228 | Write-Progress -Activity "Retrieving Group Memberships..." -PercentComplete $frac -CurrentOperation "$frac% complete" 229 | 230 | if ($IncludeAccountBlockedUsers -eq $true) { 231 | # everyone 232 | } else { 233 | if ($u.AccountEnabled -eq $False) { 234 | $global:CountSkippedBlockedUsers++ 235 | continue 236 | } 237 | } 238 | 239 | if ($u.DirSyncEnabled -eq $true) { # DirSyncEnabled 240 | $global:CountSkippedDirSyncUsers++ 241 | continue 242 | } 243 | $objectid = $u.ObjectId 244 | $upn = $u.UserPrincipalName 245 | 246 | if ($upn.Contains("#EXT#@") -eq $False) { 247 | $CountSkippedInternalUsers++ 248 | continue 249 | } 250 | 251 | # we have counted. Let's extract the UPN. 252 | $domain = GetDomainFromUpn $upn 253 | if ($domain -eq $null) { 254 | continue 255 | } 256 | 257 | $msa = $false 258 | 259 | if ($includeNonManagedUsers -eq $false) { 260 | 261 | if (($domain -eq "gmail.com") -or ($domain -eq "outlook.com") -or ($domain -eq "live.com")) { 262 | $msa = $true 263 | } elseif ($domain.EndsWith(".onmicrosoft.com")) { 264 | $msa = $false 265 | } else { 266 | if ($global:ConsumerDomains.ContainsKey($domain)) { 267 | $msa = $true 268 | } else { 269 | if ($global:ManagedDomains.ContainsKey($domain)) { 270 | $msa = $false 271 | } else { 272 | $nuser = "user@" + $domain 273 | $msa = Test-Viral2 $nuser 274 | 275 | if ($msa -eq $true) { 276 | $global:ConsumerDomains[$domain] = $true 277 | } else { 278 | $global:ManagedDomains[$domain] = $true 279 | } 280 | } 281 | } 282 | 283 | } 284 | } 285 | 286 | 287 | if ($msa -eq $false) { 288 | $global:CountEvaluatedUsers++ 289 | $nu = RecordDomain $objectid $upn $u $domain 290 | ParseUserMemberships $objectid $upn $msa $domain $nu 291 | } else { 292 | $global:CountSkippedSocialUsers++ 293 | } 294 | } 295 | Write-Progress -Activity "Retrieving Group Memberships..." -Completed -Status "Done" 296 | 297 | } 298 | 299 | function IsReviewGroup($ginfo) { 300 | 301 | # mail enabled, unified groups, on-prem groups are not review groups 302 | if ($ginfo.SecurityEnabled -eq $false) { 303 | return $false 304 | } 305 | if ($ginfo.MailEnabled -eq $true) { 306 | return $false 307 | } 308 | 309 | foreach ($i in $ginfo.GroupTypes) { 310 | if ($i -eq "Unified") { 311 | return $false 312 | } 313 | } 314 | 315 | if ($ginfo.OnPremisesSecurityIdentifier -ne $null) { 316 | return $false 317 | } 318 | 319 | if ($ginfo.Description -match "access review of external identities") { 320 | return $true 321 | } 322 | 323 | 324 | return $false 325 | } 326 | 327 | function CheckPotentialDirectoryRole($gid) 328 | { 329 | try { 330 | $roles = Get-AzureADDirectoryRole -ObjectId $gid 331 | 332 | if ($roles.Count -eq 1) { 333 | $global:DirectoryRoles[$gid] = $roles[0] 334 | } else { 335 | # should not occur 336 | } 337 | # add to role list 338 | 339 | } catch { 340 | 341 | return # not a role 342 | } 343 | } 344 | 345 | function IsDynamicOrReviewGroup($gid) { 346 | 347 | 348 | 349 | try { 350 | $ginfo = Get-AzureADMSGroup -Id $gid 351 | 352 | } catch { 353 | # group may not exist, may be a directory role 354 | CheckPotentialDirectoryRole $gid 355 | return $false 356 | } 357 | foreach ($i in $ginfo.GroupTypes) { 358 | if ($i -eq "DynamicMembership") { 359 | 360 | return $true 361 | } 362 | } 363 | 364 | return IsReviewGroup $ginfo 365 | 366 | 367 | } 368 | 369 | function ExcludeGroupFromUsers($gk) 370 | { 371 | $members = $global:Groups1[$gk] 372 | 373 | foreach ($dm in $members.Values) { 374 | 375 | foreach ($nu in $dm) { 376 | $upn = $nu.UPN 377 | if ($nu.Groups.Contains($gk)) { 378 | $nu.Groups.Remove($gk) 379 | # write-verbose "user $upn removing group $gk" 380 | } else { 381 | 382 | } 383 | 384 | } 385 | } 386 | } 387 | 388 | 389 | 390 | function ReduceGroup ($members) { 391 | $newmembers = @{} 392 | foreach ($ind in $members.Keys) { 393 | if ($global:Domains1.ContainsKey($ind) -eq $false) { 394 | continue 395 | } 396 | 397 | $memberDomain = $members[$ind] 398 | 399 | $newmembers[$ind] = $memberDomain 400 | $global:MembershipsPassed++ 401 | } 402 | 403 | return $newmembers 404 | } 405 | 406 | 407 | function RemoveDynamicGroups() { 408 | $keys = $global:Groups1.Keys 409 | $totalKeys = $keys.Count 410 | $cur = 0 411 | 412 | Write-Progress -Activity "Reducing groups..." 413 | 414 | 415 | foreach ($gk in $keys) { 416 | $frac = [math]::round(($cur * 100) / $totalKeys) 417 | $cur++ 418 | Write-Progress -Activity "Reducing groups..." -PercentComplete $frac -CurrentOperation "$frac% complete" 419 | 420 | if ($global:DirectoryRoles.ContainsKey($gk)) { 421 | # it's a role 422 | } else { 423 | 424 | $isdyn = IsDynamicOrReviewGroup $gk 425 | 426 | if ($isdyn -eq $true) { 427 | #if($global:DynamicGroups -NotContains $gk) { $global:DynamicGroups += $gk } 428 | #$global:DynamicGroups.Add($gk) 429 | ExcludeGroupFromUsers $gk 430 | $global:CountSkippedDynamicGroups++ 431 | continue 432 | } 433 | } 434 | 435 | $members = $global:Groups1[$gk] 436 | 437 | $newmembers = ReduceGroup $members 438 | 439 | if ($newmembers.Count -eq 0) { 440 | # no domains 441 | $global:CountSkippedGroups++ 442 | } else { 443 | 444 | if ($global:DirectoryRoles.ContainsKey($gk)) { 445 | 446 | $global:DirectoryRoles2[$gk] = $newmembers 447 | } else { 448 | 449 | $global:Groups2[$gk] = $newmembers 450 | } 451 | } 452 | 453 | } 454 | 455 | Write-Progress -Activity "Reducing groups..." -Completed -Status "Done" 456 | 457 | } 458 | 459 | function GetAppRoleAssignments($uobjectid) 460 | { 461 | $roles = Get-AzureADUserAppRoleAssignment -ObjectId $uobjectid 462 | 463 | $rcount = 0 464 | 465 | foreach ($r in $roles) { 466 | 467 | $appid = $r.ResourceId 468 | $prevusers = @() 469 | 470 | if ($global:Apps1.ContainsKey($appid)) { 471 | $nu = $global:Apps1[$appid] 472 | $nu.Users += $uobjectid 473 | } else { 474 | $prevusers += $uobjectid 475 | 476 | 477 | $na = [PSCustomObject]@{ 478 | ObjectId = $appid; 479 | DisplayName = $r.ResourceDisplayName; 480 | Users = $prevusers 481 | } 482 | 483 | $global:Apps1[$appid] = $na 484 | } 485 | 486 | $rcount++ 487 | 488 | } 489 | 490 | return $rcount 491 | } 492 | 493 | function FindGroupsAndAppsForDomains() { 494 | 495 | Write-Progress -Activity "Checking app role assignments..." 496 | 497 | $ucount = 0 498 | 499 | foreach ($dr in $global:Domains1.Keys) { 500 | $users = $global:Domains1[$dr] 501 | $unogroups = @() 502 | $ugroups = @() 503 | $uapps = @() 504 | foreach ($u in $users) { 505 | $ucount++ 506 | 507 | $frac = [math]::round(($ucount * 100) / $global:CountCandidateUsers) 508 | Write-Progress -Activity "Checking app role assignments..." -PercentComplete $frac -CurrentOperation "$frac% complete" 509 | 510 | $gc = $u.Groups.Count 511 | $uobjid = $u.ObjectId 512 | $upn = $u.UPN 513 | 514 | $arc = GetAppRoleAssignments $uobjid 515 | 516 | 517 | if ($gc -eq 0) { 518 | if ($arc -eq 0) { 519 | $unogroups += $uobjid 520 | $global:UsersReadyForRemoval += $upn 521 | } else { 522 | $uapps += $upn 523 | } 524 | } else { 525 | $ugroups += $upn 526 | } 527 | } 528 | 529 | 530 | foreach ($upn in $ugroups) { 531 | $global:UsersNotReadyForRemoval += $upn 532 | 533 | } 534 | 535 | foreach ($upn in $uapps) { 536 | $global:UsersNotReadyForRemovalDueToApps += $upn 537 | 538 | } 539 | 540 | 541 | if ($unogroups.Count -eq 0) { 542 | continue 543 | } 544 | 545 | $global:Domains2[$dr] = $unogroups 546 | 547 | } 548 | 549 | Write-Progress -Activity "Checking app role assignments..." -Completed -Status "Done" 550 | 551 | } 552 | 553 | function IsUserStillNeedingReview($dr,$userid) 554 | { 555 | foreach ($objid in $global:Domains2[$dr]) { 556 | if ($userid -eq $objid) { 557 | return $true 558 | } 559 | } 560 | return $false 561 | } 562 | 563 | function WasUserAlreadyReviewed($geml,$userid) 564 | { 565 | foreach ($gem in $geml) { 566 | if ($gem.ObjectId -eq $userid) { 567 | return $true 568 | } 569 | } 570 | 571 | return $false 572 | } 573 | 574 | function FindUserForDisplayName($dr,$objectid) { 575 | 576 | foreach ($u in $global:Domains1[$dr]) { 577 | if ($u.ObjectId -eq $objectid) { 578 | return $u 579 | } 580 | } 581 | 582 | 583 | return $null 584 | } 585 | 586 | function CompareMemberships($gnum,$slist,$dr,$gid) { 587 | $geml = @() 588 | 589 | 590 | $emitted = @() 591 | 592 | if ($gid -ne $null) { 593 | $geml = get-azureadgroupmember -objectid $gid -All $true 594 | 595 | # is there a group member which is not in $global:Domains2? if so, remove them from the group 596 | foreach ($gem in $geml) { 597 | $snr = IsUserStillNeedingReview $dr $gem.ObjectId 598 | if ($snr -eq $false) { 599 | 600 | # does not yet emit removing them from the group 601 | $s = "# guest " + $gem.DisplayName + " " + $gem.ObjectId + " already in group, but may have other access" 602 | $slist += $s 603 | 604 | } else { 605 | $s = "# guest " + $gem.DisplayName + " " + $gem.ObjectId + " already in group" 606 | $emitted += $gem.ObjectId 607 | $slist += $s 608 | } 609 | } 610 | } 611 | 612 | # is there a user in $global:Domains2 which is not in $gem? if so, add them to the group 613 | 614 | foreach ($objid in $global:Domains2[$dr]) { 615 | if ($gid -eq $null) { 616 | $gar = $false 617 | } else { 618 | $gar = WasUserAlreadyReviewed $geml $objid 619 | } 620 | if ($gar -eq $false) { 621 | 622 | $nu =FindUserForDisplayName $dr $objid 623 | $displayname = $nu.DisplayName 624 | 625 | $s = "# user " + '"' + $displayname + '"' + " to be added to that new group" 626 | $slist += $s 627 | 628 | $s = "Add-AzureADGroupMember -ObjectId " + '$gid' + $gnum + ".ObjectId -RefObjectId " + '"' + $objid + '"' 629 | $slist += $s 630 | } else { 631 | 632 | if ($emitted -contains $objid) { 633 | continue 634 | } 635 | 636 | $s = "# guest " + $objid + " already in group" 637 | $slist += $s 638 | } 639 | } 640 | 641 | return $slist 642 | 643 | } 644 | 645 | function FindExistingReviewGroup($displayname) 646 | { 647 | $gl = Get-AzureADMSGroup -SearchString $displayname -All $true 648 | 649 | if ($gl.Count -eq 0) { 650 | 651 | return $null 652 | } 653 | 654 | foreach ($g in $gl) { 655 | if ($g.DisplayName -eq $displayname) { 656 | $gid = $g.Id 657 | return $gid 658 | } 659 | } 660 | 661 | return $null 662 | } 663 | 664 | 665 | function ConstructReviewGroupAndMemberships() { 666 | 667 | 668 | $gnum = 0 669 | 670 | foreach ($dr in $global:Domains2.Keys) { 671 | 672 | 673 | 674 | $domainusercount = $global:Domains2[$dr].Count 675 | 676 | if ($domainusercount -eq 0) { 677 | continue 678 | } 679 | 680 | $gnum++ 681 | 682 | $displayname = "external identities from " + $dr 683 | 684 | # see if it already exists, if so, 685 | # compare the membership 686 | # otherwise, create it 687 | 688 | 689 | 690 | $slist = @() 691 | 692 | 693 | # test group already exists, if not create one 694 | $gid = FindExistingReviewGroup $displayname 695 | 696 | if ($gid -eq $null) { 697 | 698 | 699 | $slist += "# Create a group for $domainusercount users from $dr" 700 | 701 | 702 | $slist += "" 703 | 704 | $desc = "access review of external identities from " + $dr 705 | $mn = $dr # + "-" + $datefmt 706 | $s = '$gid' + $gnum + ' = ' 707 | $s += "New-AzureADGroup -DisplayName " + '"' + $displayname + '"' + " -Description " + '"' + $desc + '" -MailEnabled $false -SecurityEnabled $true -MailNickname "' + $mn + '"' 708 | $slist += $s 709 | 710 | $slist += "" 711 | 712 | $slist = CompareMemberships $gnum $slist $dr $null 713 | 714 | } else { 715 | # else have gid of existing group, 716 | 717 | $s = '$gid' + $gnum + ' = ' 718 | $s += "Get-AzureADGroup -ObjectId " + '"' + $gid + '"' 719 | $slist += $s 720 | 721 | $slist = CompareMemberships $gnum $slist $dr $gid 722 | } 723 | 724 | 725 | $slist += "" 726 | 727 | $global:PShInstructions[$dr] = $slist 728 | 729 | } 730 | 731 | } 732 | 733 | 734 | function WriteHtml($s) { 735 | Add-Content -Path $global:HtmlOutputFilename -Value $s 736 | } 737 | 738 | function WritePS($s) { 739 | Add-Content -Path $global:PSOutputFilename -Value $s 740 | } 741 | 742 | function GetInitialDomain($ctd) { 743 | 744 | } 745 | 746 | function WriteHtmlFile ($datefmt,$initialdomain) { 747 | 748 | Set-Content -Path $global:HtmlOutputFilename -Value "" -Force 749 | Set-Content -Path $global:PSOutputFilename -Value "# Automatically generated on $datefmt for $initialdomain" 750 | WriteHtml "" 751 | WriteHtml "" 752 | WriteHtml "" 753 | 754 | WriteHtml "

Azure AD External Identity Lookup: Summary of guest users potentially ready for review using the Azure AD Access Reviews

" 755 | 756 | WriteHtml "
Generated on $datefmt for $initialdomain
" 757 | 758 | WriteHtml "

External users that have no static group membership or application assignments in your tenant

" 759 | WriteHtml "

The following table outlines all external identities that have no static group memberships and no applications assignments in your tenant. The external identities listed below could, however, have one of the following:

  • group membership in dynamic groups
  • access to Sharepoint Sites managed outside of Azure AD groups or assigned directly
  • " 760 | 761 | WriteHtml "" 762 | 763 | WriteHtml "" 764 | 765 | WriteHtml "" 766 | foreach ($dr in $global:Domains2.Keys) { 767 | 768 | 769 | $domainusercount = $global:Domains2[$dr].Count 770 | 771 | if ($domainusercount -eq 0) { 772 | continue 773 | } 774 | 775 | foreach ($objid in $global:Domains2[$dr]) { 776 | $nu = FindUserForDisplayName $dr $objid 777 | if ($nu -eq $null) { 778 | continue 779 | } 780 | 781 | WriteHtml "" 782 | 783 | WriteHtml "" 784 | $upn = $nu.UPN 785 | WriteHtml "" 786 | 787 | $displayName = $nu.DisplayName 788 | WriteHtml "" 789 | 790 | $rt = $nu.RefreshTokensValidFromDateTime 791 | WriteHtml "" 792 | 793 | $us = $nu.UserState 794 | WriteHtml "" 795 | $usco = $nu.UserStateChangedOn 796 | WriteHtml "" 797 | 798 | WriteHtml "" 799 | } 800 | 801 | } 802 | 803 | # foreach ($upn in $global:UsersReadyForRemoval) { } 804 | WriteHtml "
    DomainUPNDisplay NameRefresh TokenUser StateUser State Changed
    $dr$upn$displayName$rt$us$usco
    " 805 | 806 | WriteHtml "

    Script suggestion: Create groups to try Azure AD Access Reviews disable-and-delete feature on

    " 807 | 808 | $dcount = $global:Domains2.Count 809 | if ($dcount -eq 0) { 810 | WriteHtml "

    There are no external identities from other directories that have no group memberships. Below are Powershell code snippets that will allow you to create Azure AD groups that will include the 'group less' external identities found. Using this newly created group, you can create an Access Review with Disable and Delete.

    " 811 | } else { 812 | WriteHtml "

    There are $dcount domains of external identities having no other group memberships. Below are Powershell code snippets that will allow you to create Azure AD groups that will include the 'group less' external identities found. Using this newly created group, you can create an Access Review with Disable and Delete." 813 | WriteHtml "

    First, create or update a group for each domain's external identities, using this script $global:PSOutputFilename that was also automatically created for you.

    " 814 | WriteHtml "
    "
     815 | 
     816 |   
     817 | 
     818 |     foreach ($dr in $global:Domains2.Keys) {
     819 | 
     820 | 
     821 |         $domainusercount = $global:Domains2[$dr].Count
     822 | 
     823 |         if ($domainusercount -eq 0) {
     824 |             continue
     825 |         }
     826 | 
     827 |         $slist = $global:PShInstructions[$dr]
     828 | 
     829 |         foreach ($s in $slist) {
     830 |             WriteHtml $s
     831 |             WritePS $s
     832 |         }
     833 |     
     834 |     }
     835 |     
     836 |     WriteHtml "
    " 837 | 838 | WriteHtml "

    Now, after you have created the respective groups, create an Access Review with disable-and-delete for them, following the instructions on DOCS.

    " 839 | } 840 | 841 | 842 | $g2count = $global:Groups2.Count 843 | 844 | if ($g2count -ge 1) { 845 | 846 | WriteHtml "
    " 847 | 848 | WriteHtml "

    Additional Info: Other groups to review

    " 849 | 850 | 851 | WriteHtml "

    The below groups have group members that are external identities. You may want to review these groups with Access Reviews, not to remove those external identities from the directory, but to determine if those external identities still need to be members of those groups.

    " 852 | 853 | WriteHtml "
      " 854 | foreach ($gr in $global:Groups2.Keys) { 855 | $go = $global:GroupObjects[$gr] 856 | $dn = $go.DisplayName 857 | 858 | $membercount = 0 859 | if ($global:Groups1.ContainsKey($gr)) { 860 | $membercount = $global:Groups1[$gr].Count 861 | } 862 | 863 | WriteHtml "
    • Group Name: $dn (objectID: $gr, $membercount external identities as a member)
    • " 864 | } 865 | WriteHtml "
    " 866 | 867 | } 868 | 869 | $dr2count = $global:DirectoryRoles.Count 870 | if ($dr2count -ge 1) { 871 | WriteHtml "

    Additional Info: Directory roles to review

    " 872 | 873 | 874 | WriteHtml "

    You may also wish to review the external identities in these directory roles, but to determine if those external identities still need to be members of those roles.

    " 875 | 876 | WriteHtml "
      " 877 | foreach ($gr in $global:DirectoryRoles.Keys) { 878 | $go = $global:DirectoryRoles[$gr] 879 | $dn = $go.DisplayName 880 | 881 | 882 | WriteHtml "
    • $dn
    • " 883 | } 884 | WriteHtml "
    " 885 | 886 | 887 | } 888 | 889 | $a1count = $global:Apps1.Count 890 | if ($a1count -ge 1) { 891 | WriteHtml "

    Additional Info: Apps to review

    " 892 | 893 | 894 | WriteHtml "

    You may also wish to review the external identities in these apps, not to remove those external identities from the directory, but to determine if those external identities still need access.

    " 895 | 896 | WriteHtml "
      " 897 | foreach ($gr in $global:Apps1.Keys) { 898 | $go = $global:Apps1[$gr] 899 | $dn = $go.DisplayName 900 | $objectid = $go.ObjectId 901 | $ucount = $go.Users.Count 902 | 903 | 904 | WriteHtml "
    • $dn ($objectid, $ucount users)
    • " 905 | } 906 | WriteHtml "
    " 907 | } 908 | 909 | if ($global.$global:UsersNotReadyForRemoval.Count -ge 1) { 910 | WriteHtml "

    Additional Info: - Guest users not ready for removal due to those external identities having other group memberships

    " 911 | 912 | WriteHtml "
      " 913 | foreach ($upn in $global:UsersNotReadyForRemoval) { 914 | WriteHtml "
    • $upn
    • " 915 | } 916 | WriteHtml "
    " 917 | 918 | } 919 | 920 | if ($global.$global:UsersNotReadyForRemovaDueToApps.Count -ge 1) { 921 | WriteHtml "

    Additional Info: - Guest users not ready for removal due to those external identities having application roles

    " 922 | 923 | WriteHtml "
      " 924 | foreach ($upn in $global:UsersNotReadyForRemovalDueToApps) { 925 | WriteHtml "
    • $upn
    • " 926 | } 927 | WriteHtml "
    " 928 | 929 | } 930 | 931 | $cdcount = $global:ConsumerDomains.Count 932 | 933 | if ($cdcount -ge 1) { 934 | 935 | WriteHtml "

    Additional Info: Non-Managed domains (Consumer domains) of external identities not included

    " 936 | 937 | 938 | 939 | WriteHtml "

    There were $global:CountSkippedSocialUsers external identities from these domains that were not considered as candidates, as they are not from other tenants. " 940 | writeHtml "If you wish to include them, re-run this script with the -IncludeNonManagedUsers flag.

    " 941 | 942 | WriteHtml "
      " 943 | foreach ($d in $global:ConsumerDomains.Keys) { 944 | WriteHtml "
    • $d
    • " 945 | } 946 | 947 | WriteHtml "
    " 948 | 949 | } 950 | 951 | ###if ($global:DynamicGroups.Count -gt 0) 952 | ##{ 953 | # WriteHtml "

    Additional Info: Dynamic Groups with external identities in them

    " 954 | 955 | # WriteHtml "

    There were $($global:DynamicGroups.Count) dynamic groups that contained external identities." 956 | 957 | # WriteHtml "

      " 958 | # foreach ($dynG in $global:DynamicGroups) { 959 | # WriteHtml "
    • $dynG
    • " 960 | # } 961 | 962 | # WriteHtml "
    " 963 | #} 964 | 965 | WriteHtml "" 966 | } 967 | 968 | ######################################################################################################## 969 | ### Script starts here ### 970 | ######################################################################################################## 971 | import-module AzureADPreview 972 | 973 | $ctd = $null 974 | 975 | 976 | try { 977 | Connect-AzureAD 978 | } 979 | catch{ 980 | Write-Host "" 981 | throw "Aborting. You need to sign in to Azure AD to continue." 982 | } 983 | 984 | #We seem to be connected. Let's try and see if we can find the initial domain of the tenant (.onmicrosoft.com) 985 | $tenantDetails = Get-AzureADTenantDetail 986 | $initial = $tenantDetails.VerifiedDomains | ?{$_.Initial} | SELECT -ExpandProperty Name 987 | 988 | #Which arguments was the script called with? What do we need to do? 989 | 990 | 991 | findExternalsWithoutGroupMembership 992 | 993 | RemoveDynamicGroups 994 | 995 | FindGroupsAndAppsForDomains 996 | 997 | ConstructReviewGroupAndMemberships 998 | 999 | 1000 | $now = Get-Date 1001 | $datefmt = $now.date.ToString("dd-MMM-yyyy") 1002 | 1003 | # filename includes tenant name too 1004 | if(!$filepath) 1005 | { 1006 | $global:HtmlOutputFilename = "guest-cleanup-" + $initial + "-" + $datefmt + ".htm" 1007 | $global:PSOutputFilename = "guest-cleanup-" + $initial + "-" + $datefmt + ".ps1" 1008 | } 1009 | else 1010 | { 1011 | $global:HtmlOutputFilename = $filePath.TrimEnd('\') + "\guest-cleanup-" + $initial + "-" + $datefmt + ".htm" 1012 | $global:PSOutputFilename = $filePath.TrimEnd('\') + "\guest-cleanup-" + $initial + "-" + $datefmt + ".ps1" 1013 | } 1014 | 1015 | WriteHtmlFile $datefmt $initial 1016 | 1017 | Write-output "Done, created two output files:" 1018 | Write-Output "HTML report: $global:htmlOutputFilename " 1019 | Write-Output "Powershell group creation sample: $global:psoutputfilename " 1020 | 1021 | 1022 | -------------------------------------------------------------------------------- /ExternalIdentityUse/sample-output-guest-cleanup-FrickelsoftNET.onmicrosoft.com-20-Jul-2020.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

    Azure AD External Identity Lookup: Summary of guest users potentially ready for review using the Azure AD Access Reviews

    6 |
    Generated on 20-Jul-2020 for FrickelsoftNET.onmicrosoft.com
    7 |

    External users that have no static group membership or application assignments in your tenant

    8 |

    The following table outlines all external identities that have no static group memberships and no applications assignments in your tenant. The external identities listed below could, however, have one of the following:

  • group membership in dynamic groups
  • access to Sharepoint Sites managed outside of Azure AD groups or assigned directly
  • 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
    DomainUPNDisplay NameRefresh TokenUser StateUser State Changed
    identities.wtfa712873_identities.wtf#EXT#@FrickelsoftNET.onmicrosoft.comMadeline Small07/14/2020 06:04:35
    identities.wtfespinotw_identities.wtf#EXT#@FrickelsoftNET.onmicrosoft.comElena Spinotw07/14/2020 06:09:08
    farrtoso.comjohn_farrtoso.com#EXT#@FrickelsoftNET.onmicrosoft.comJohn Farr11/18/2019 16:01:01
    microsoft.frickelpartners.netrobert_microsoft.frickelpartners.net#EXT#@FrickelsoftNET.onmicrosoft.comRobert Pattinson (EXT)01/19/2017 14:56:18
    azure-hero.competer_azure-hero.com#EXT#@FrickelsoftNET.onmicrosoft.comPeter Lammert11/18/2019 16:00:07
    identitysso.onmicrosoft.comcsp_support_admin_identitysso.onmicrosoft.com#EXT#@FrickelsoftNET.onmicrosoft.comCustomer Support Admin08/13/2018 11:32:06
    identitysso.onmicrosoft.comcsp_support_admin2_identitysso.onmicrosoft.com#EXT#@FrickelsoftNET.onmicrosoft.comCSP Support Admin 208/13/2018 11:45:13
    69 |

    Script suggestion: Create groups to try Azure AD Access Reviews disable-and-delete feature on

    70 |

    There are 5 domains of external identities having no other group memberships. Below are Powershell code snippets that will allow you to create Azure AD groups that will include the 'group less' external identities found. Using this newly created group, you can create an Access Review with Disable and Delete. 71 |

    First, create or update a group for each domain's external identities, using this script C:\temp\guest-cleanup-FrickelsoftNET.onmicrosoft.com-20-Jul-2020.ps1 that was also automatically created for you.

    72 |
     73 | # Create a group for 2 users from identities.wtf
     74 | 
     75 | $gid1 = New-AzureADGroup -DisplayName "external identities from identities.wtf" -Description "access review of external identities from identities.wtf" -MailEnabled $false -SecurityEnabled $true -MailNickname "identities.wtf"
     76 | 
     77 | # user "Madeline Small" to be added to that new group
     78 | Add-AzureADGroupMember -ObjectId $gid1.ObjectId -RefObjectId "a1c3a6f2-756e-4230-83af-dba6c7568bf1"
     79 | # user "Elena Spinotw" to be added to that new group
     80 | Add-AzureADGroupMember -ObjectId $gid1.ObjectId -RefObjectId "bdd77c5f-eb29-4b9b-a607-d97e70f4fbb9"
     81 | 
     82 | # Create a group for 1 users from farrtoso.com
     83 | 
     84 | $gid2 = New-AzureADGroup -DisplayName "external identities from farrtoso.com" -Description "access review of external identities from farrtoso.com" -MailEnabled $false -SecurityEnabled $true -MailNickname "farrtoso.com"
     85 | 
     86 | # user "John Farr" to be added to that new group
     87 | Add-AzureADGroupMember -ObjectId $gid2.ObjectId -RefObjectId "06714039-45df-439c-9195-58bc01e7a852"
     88 | 
     89 | # Create a group for 1 users from microsoft.frickelpartners.net
     90 | 
     91 | $gid3 = New-AzureADGroup -DisplayName "external identities from microsoft.frickelpartners.net" -Description "access review of external identities from microsoft.frickelpartners.net" -MailEnabled $false -SecurityEnabled $true -MailNickname "microsoft.frickelpartners.net"
     92 | 
     93 | # user "Robert Pattinson (EXT)" to be added to that new group
     94 | Add-AzureADGroupMember -ObjectId $gid3.ObjectId -RefObjectId "f0610ab7-da71-4a4a-8ee3-94743144693d"
     95 | 
     96 | # Create a group for 1 users from azure-hero.com
     97 | 
     98 | $gid4 = New-AzureADGroup -DisplayName "external identities from azure-hero.com" -Description "access review of external identities from azure-hero.com" -MailEnabled $false -SecurityEnabled $true -MailNickname "azure-hero.com"
     99 | 
    100 | # user "Peter Lammert" to be added to that new group
    101 | Add-AzureADGroupMember -ObjectId $gid4.ObjectId -RefObjectId "1dfd1478-5202-4d8c-b71b-ba5afa9d3666"
    102 | 
    103 | # Create a group for 2 users from identitysso.onmicrosoft.com
    104 | 
    105 | $gid5 = New-AzureADGroup -DisplayName "external identities from identitysso.onmicrosoft.com" -Description "access review of external identities from identitysso.onmicrosoft.com" -MailEnabled $false -SecurityEnabled $true -MailNickname "identitysso.onmicrosoft.com"
    106 | 
    107 | # user "Customer Support Admin" to be added to that new group
    108 | Add-AzureADGroupMember -ObjectId $gid5.ObjectId -RefObjectId "9b522137-01bf-456f-ab30-cfe0e792bd2a"
    109 | # user "CSP Support Admin 2" to be added to that new group
    110 | Add-AzureADGroupMember -ObjectId $gid5.ObjectId -RefObjectId "33605554-c4a4-4fec-8da4-ed60d58400e3"
    111 | 
    112 | 
    113 |

    Now, after you have created the respective groups, create an Access Review with disable-and-delete for them, following the instructions on DOCS.

    114 |
    115 |

    Additional Info: Other groups to review

    116 |

    The below groups have group members that are external identities. You may want to review these groups with Access Reviews, not to remove those external identities from the directory, but to determine if those external identities still need to be members of those groups.

    117 |
      118 |
    • Group Name: Jennys Team (objectID: e45712da-4a52-422c-94c3-b158d366945a, 2 external identities as a member)
    • 119 |
    • Group Name: App Access to AWS-Console (objectID: 5acb745d-8e46-498b-a5af-8a4e7e0b6693, 1 external identities as a member)
    • 120 |
    • Group Name: Sarahs Team of Externals (objectID: dd3251b3-0716-4906-a622-3322e042935c, 1 external identities as a member)
    • 121 |
    • Group Name: guests from identitysso.onmicrosoft.com (objectID: 48bc094f-ddd6-4e09-b0f0-3a296e4c4735, 1 external identities as a member)
    • 122 |
    • Group Name: guests from microsoft.frickelpartners.net (objectID: f85a9e42-7e48-4372-9742-926c6c330109, 1 external identities as a member)
    • 123 |
    124 |

    Additional Info: Directory roles to review

    125 |

    You may also wish to review the external identities in these directory roles, but to determine if those external identities still need to be members of those roles.

    126 |
      127 |
    • Exchange Service Administrator
    • 128 |
    • Security Administrator
    • 129 |
    130 |

    Additional Info: Apps to review

    131 |

    You may also wish to review the external identities in these apps, not to remove those external identities from the directory, but to determine if those external identities still need access.

    132 |
      133 |
    • Claims XRay (7ef3e964-72e8-499c-808f-7f88ccfff73d, 3 users)
    • 134 |
    • RolesApp (646aabd5-66d0-490c-bce7-db918af7222e, 3 users)
    • 135 |
    • AWS Console (f165b8db-eaba-4bb9-a94d-2fae8c243038, 2 users)
    • 136 |
    • Dropbox Business - Finance (372be6b2-5e97-4a42-bf49-1ab582ff8e71, 1 users)
    • 137 |
    • Expenses App (982a82aa-22ff-427e-9d10-fc5443ff4de1, 1 users)
    • 138 |
    139 |

    Additional Info: Non-Managed domains (Consumer domains) of external identities not included

    140 |

    There were 3 external identities from these domains that were not considered as candidates, as they are not from other tenants. 141 | If you wish to include them, re-run this script with the -IncludeNonManagedUsers flag.

    142 |
      143 |
    • gmx.de
    • 144 |
    145 | 146 | -------------------------------------------------------------------------------- /ExternalIdentityUse/sample-output-guest-cleanup-FrickelsoftNET.onmicrosoft.com-20-Jul-2020.ps1: -------------------------------------------------------------------------------- 1 | # Automatically generated on 20-Jul-2020 for FrickelsoftNET.onmicrosoft.com 2 | # Create a group for 2 users from identities.wtf 3 | 4 | $gid1 = New-AzureADGroup -DisplayName "external identities from identities.wtf" -Description "access review of external identities from identities.wtf" -MailEnabled $false -SecurityEnabled $true -MailNickname "identities.wtf" 5 | 6 | # user "Madeline Small" to be added to that new group 7 | Add-AzureADGroupMember -ObjectId $gid1.ObjectId -RefObjectId "a1c3a6f2-756e-4230-83af-dba6c7568bf1" 8 | # user "Elena Spinotw" to be added to that new group 9 | Add-AzureADGroupMember -ObjectId $gid1.ObjectId -RefObjectId "bdd77c5f-eb29-4b9b-a607-d97e70f4fbb9" 10 | 11 | # Create a group for 1 users from farrtoso.com 12 | 13 | $gid2 = New-AzureADGroup -DisplayName "external identities from farrtoso.com" -Description "access review of external identities from farrtoso.com" -MailEnabled $false -SecurityEnabled $true -MailNickname "farrtoso.com" 14 | 15 | # user "John Farr" to be added to that new group 16 | Add-AzureADGroupMember -ObjectId $gid2.ObjectId -RefObjectId "06714039-45df-439c-9195-58bc01e7a852" 17 | 18 | # Create a group for 1 users from microsoft.frickelpartners.net 19 | 20 | $gid3 = New-AzureADGroup -DisplayName "external identities from microsoft.frickelpartners.net" -Description "access review of external identities from microsoft.frickelpartners.net" -MailEnabled $false -SecurityEnabled $true -MailNickname "microsoft.frickelpartners.net" 21 | 22 | # user "Robert Pattinson (EXT)" to be added to that new group 23 | Add-AzureADGroupMember -ObjectId $gid3.ObjectId -RefObjectId "f0610ab7-da71-4a4a-8ee3-94743144693d" 24 | 25 | # Create a group for 1 users from azure-hero.com 26 | 27 | $gid4 = New-AzureADGroup -DisplayName "external identities from azure-hero.com" -Description "access review of external identities from azure-hero.com" -MailEnabled $false -SecurityEnabled $true -MailNickname "azure-hero.com" 28 | 29 | # user "Peter Lammert" to be added to that new group 30 | Add-AzureADGroupMember -ObjectId $gid4.ObjectId -RefObjectId "1dfd1478-5202-4d8c-b71b-ba5afa9d3666" 31 | 32 | # Create a group for 2 users from identitysso.onmicrosoft.com 33 | $gid5 = New-AzureADGroup -DisplayName "external identities from identitysso.onmicrosoft.com" -Description "access review of external identities from identitysso.onmicrosoft.com" -MailEnabled $false -SecurityEnabled $true -MailNickname "identitysso.onmicrosoft.com" 34 | 35 | # user "Customer Support Admin" to be added to that new group 36 | Add-AzureADGroupMember -ObjectId $gid5.ObjectId -RefObjectId "9b522137-01bf-456f-ab30-cfe0e792bd2a" 37 | # user "CSP Support Admin 2" to be added to that new group 38 | Add-AzureADGroupMember -ObjectId $gid5.ObjectId -RefObjectId "33605554-c4a4-4fec-8da4-ed60d58400e3" 39 | 40 | -------------------------------------------------------------------------------- /ExternalIdentityUse/screenshots/ExternalIdentityUse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/access-reviews-samples/dd300b2d3b9035c46fb6abb9ba6945a3320efc07/ExternalIdentityUse/screenshots/ExternalIdentityUse.png -------------------------------------------------------------------------------- /ExternalIdentityUse/screenshots/HTM-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/access-reviews-samples/dd300b2d3b9035c46fb6abb9ba6945a3320efc07/ExternalIdentityUse/screenshots/HTM-output.png -------------------------------------------------------------------------------- /ExternalIdentityUse/screenshots/README.MD: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ExternalIdentityUse/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | } 4 | 5 | h1 { 6 | text-decoration: underline; 7 | font-size: xx-large; 8 | } 9 | 10 | h2 { 11 | text-decoration: underline; 12 | font-size: x-large; 13 | } 14 | 15 | h3 { 16 | text-decoration: wavy; 17 | font-size: large; 18 | } 19 | 20 | table 21 | { 22 | border-collapse: collapse; 23 | } 24 | 25 | table, th, td 26 | { 27 | border: 1px solid black; 28 | } -------------------------------------------------------------------------------- /LICENSE.MD: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Microsoft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure AD Access Reviews Powershell Samples 2 | 3 | 10 | 11 | This repository contains sample scripts in Powershell that demonstrate and outline programmatic access to Azure AD Access Reviews via the Microsoft Graph. The scripts and code snippets provided here are provided "as-is", and merely serve the purpose of helping gaining the understanding for the Microsoft Graph API as well as the available functions for Azure AD Access Reviews. 12 | 13 | ## Contents 14 | 15 | This repository contains the following code snippets and Powershell samples: 16 | 17 | | File/folder | Description | 18 | |-----------------------------|--------------------------------------------| 19 | | `Apply group membership changes to on-premises groups` | Azure AD Access Reviews supports reviewing of on-premises managed groups. However, it cannot, to date, enforce review results on on-premises groups. This script reads the results and generates corresponding Powershell commands, to be executed against Windows AD to enforce the review results on-premises. | 20 | | `Read results of an Access Reviews series` | Sample code that outlines how review results can be collected over the course of recurring, scheduled reviews (monthly or quarterly reviews). | 21 | | `CHANGELOG.md` | List of changes to the sample. | 22 | | `CONTRIBUTING.md` | Guidelines for contributing to the sample. | 23 | | `README.md` | This README file. | 24 | | `LICENSE` | The license for the sample. | 25 | 26 | ## Running the sample 27 | 28 | The Powershell samples and modules provided here were written to either support interaction with the Microsoft Graph using the user's context (the user executing the script/module) or an application context. Samples that were written to support running in application context will require creation of an application registration in the Azure AD tenant, creating a client ID and a client secret, including necessary administrative consent to access Access Reviews. The steps required to set the application registration and required consent up are detailed in each sample section. 29 | 30 | ## Contributing 31 | 32 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 33 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 34 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 35 | 36 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 37 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 38 | provided by the bot. You will only need to do this once across all repos using our CLA. 39 | 40 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 41 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 42 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 43 | -------------------------------------------------------------------------------- /Refreshed-AccessReviews-API-samples/README.MD: -------------------------------------------------------------------------------- 1 | # Powershell code samples for the refreshed Access Reviews Graph API 2 | ## Synopsis 3 | 4 | This Powershell sample script is meant to describe the ways the refreshed Access Reviews Graph API can be used to 5 | * get access to Access Reviews defintions 6 | * query the status of an Access Review (definition or instance) 7 | * get instances of Access Reviews 8 | * get decisions that were made by reviewers for their Access Reviews 9 | 10 | programmatically. 11 | 12 | The Access Reviews API on Microsoft Graph is described here: [Access Reviews - Graph API](https://docs.microsoft.com/en-us/graph/api/resources/accessreviewsv2-root?view=graph-rest-beta). 13 | 14 | ## Prerequisites 15 | This Powershell module runs in an application context, which requires that you create an application registration in Azure AD for this script, and admin-consent for the required permissions for Microsoft Graph. 16 | 17 | The steps are as follows: 18 | 1. Log into the Azure portal as a global administrator. 19 | 2. In the Azure portal, go to Azure Active Directory, and then click App registrations on the left. 20 | 3. Click New registration. Give your app a name, and then click Register. 21 | 4. Copy and save for later the application (client) ID that appears after the app is registered. 22 | 5. On the left, click API permissions. 23 | 6. Click "Add a permission", click "Microsoft Graph", and then click "Application permissions". 24 | 7. In the Select permissions list, select the following permissions: AccessReview.Read.All 25 | 8. Click Add permissions. 26 | 9. Click to Grant admin consent for and then click Yes. The status for each permission the app needs should change to a green checkmark, indicating consent was granted. 27 | 10. On the left, click Certificates & secrets. 28 | 11. Click New client secret and then for Expires select an expiry date that's a month away in the future. This will allow you to test sensibly, but not infinitely keep the credentials/secret valid. Click Add. 29 | 12. Copy and save locally the value of the secret that appears- you won’t see it again after you leave this part of the UI. 30 | 31 | ## Understading the Access Reviews API 32 | 33 | The API to access Access Reviews structures information logically, such that customers and automation can query Graph as efficiently as possible. The API is comprised of three major building blocks: 34 | * Access Reviews schedule definitions – the logical “blue print” that contains the settings of an Access Review and its instances. The schedule definition schedules the recurring review instances, but does not represent a review. 35 | * An Access Review instance – which represents ana recurrence of a review that has a scope, reviewers and a status. Instances can either be recurring reviews that happen every quarter or year, or they can be multiple reviews in the same context, such as “Review all Office 365 Groups with external identities” – every O365 groups will be represented as its own instance. 36 | * Decision items recorded for a review – which represent a decision a reviewer made for a specific user, on a specific instance, including the time stamp and justification that went along with the decision. 37 | 38 | ![How definitions, instances and decisions relate to one another](./screenshots/relationships.png) 39 | 40 | ## Exported functions 41 | 42 | This sample module exports the following Azure AD functions: 43 | 44 | ### Get-AzureADARAllDefinitions 45 | 46 | .Synopsis 47 | 48 | Gets the definition for all Access Reviews, and displays their status, creation dates and creators. 49 | 50 | .Description 51 | 52 | Gets the definition for all Access Reviews, and displays their status, creation dates and creators. Will display 20 Access Reviews by default. 53 | 54 | .Parameter top 55 | 56 | The number of Access Reviews to return. 57 | 58 | .Example 59 | 60 | Get-AzureADARDefinition -top 15 61 | 62 | ### Get-AzureADARDefinition 63 | 64 | .Synopsis 65 | 66 | Gets the definition (blueprint) of an Access Review and displays its status, creation date and creator. 67 | 68 | .Description 69 | 70 | Gets the definition of an Access Review and displays its status, creation date and creator. 71 | 72 | .Parameter definitionID 73 | 74 | The ID of an Access Review, as seen from the Azure AD Portal. 75 | 76 | .Example 77 | 78 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" 79 | 80 | ### Get-AzureADARInstancesFromDefinition 81 | 82 | .Synopsis 83 | 84 | Gets the instances for an Access Review. 85 | 86 | .Description 87 | 88 | Gets the instances for an Access Review. An instance could be individual reviews in a series or many reviews under one defintiion, such as "All O365 Groups with external identities". 89 | 90 | .Parameter top 91 | 92 | The number of Access Reviews to 93 | 94 | .Example 95 | 96 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" 97 | 98 | ### Get-AzureADInstanceDetails 99 | 100 | .Synopsis 101 | 102 | Gets the instance details for an instance of an Access Review. 103 | 104 | .Description 105 | 106 | Gets the details of an instance of an Access Review. Details include the status and the results of the instance. 107 | 108 | .Parameter definitionID 109 | 110 | The definition ID for the Access Review as seen from the Azure AD Portal. 111 | 112 | .Parameter instanceID 113 | 114 | The instanceID for an Access Review that you are interested in inspecting deeply. 115 | 116 | .Example 117 | 118 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" -instanceID "a66c337b-6344-4661-a41b-a04e492baa44" 119 | 120 | ### Get-AzureADDecisionsFromInstance 121 | 122 | .Synopsis 123 | 124 | Gets the decisions that reviews submitted for an Access Review instance. 125 | 126 | .Description 127 | 128 | Gets the decisions for an instance of an Access Review. Decision details include the decision taken, the reviewer and whent he review was recorded - and also what the system recommendation was. 129 | 130 | .Parameter definitionID 131 | 132 | The definition ID for the Access Review as seen from the Azure AD Portal. 133 | 134 | .Parameter instanceID 135 | 136 | The instanceID for an Access Review that you are interested in inspecting deeply. 137 | 138 | .Example 139 | 140 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" -instanceID "a66c337b-6344-4661-a41b-a04e492baa44" 141 | 142 | 143 | ### Samples 144 | 145 | ```Powershell 146 | # Connect-AzureADMSARSample -ClientApplicationId "" -ClientSecret "" -TenantDomain "yourtenant.onmicrosoft.com" 147 | # Get-AzureADARAllDefinitions $_SampleInternalAuthNHeaders -top 6 148 | 149 | 150 | definitionID displayName 151 | ------------ ----------- 152 | 608df9d0-1558-4f9e-899c-7b88aa964196 Review guest access across Microsoft 365 groups 153 | 384c0424-2946-47ed-9c3d-a7ba83627a16 Florian's Graph-initiated review (Group review for external identities) 154 | b3a028bd-f01f-419e-b50f-4b0e18528a05 Jenny's review 155 | 9674ef14-cb5a-43e2-bfea-710de89e730b No owners review 156 | 07b34c8d-467a-4905-b5e7-714d40c75b1a Review of external identities that have not signed in a long time 157 | 4cbc9f92-4366-4bd0-91d8-7568473e0d4a Review of external identities that have never signed in. 158 | 159 | 160 | 161 | # Get-AzureADARInstancesFromDefinition $_SampleInternalAuthNHeaders "608df9d0-1558-4f9e-899c-7b88aa964196" 162 | 163 | definitionID instanceID status authHeaders 164 | ------------ ---------- ------ ----------- 165 | 608df9d0-1558-4f9e-899c-7b88aa964196 de0f0323-0a33-4437-8427-14b3238c041e InProgress {Authorization, Content-Type, ExpiresOn} 166 | 608df9d0-1558-4f9e-899c-7b88aa964196 1e2fcd2f-ec3a-4db5-a193-f9bcef93a34b InProgress {Authorization, Content-Type, ExpiresOn} 167 | 608df9d0-1558-4f9e-899c-7b88aa964196 c42dcd66-c57f-4c7c-9399-a76c6d644e11 InProgress {Authorization, Content-Type, ExpiresOn} 168 | 169 | 170 | # Get-AzureADDecisionsFromInstance $_SampleInternalAuthNHeaders "b3a028bd-f01f-419e-b50f-4b0e18528a05" "b3a028bd-f01f-419e-b50f-4b0e18528a05" 171 | 172 | decisionID : 6ad36c5a-0999-495c-ab8e-54178c9154d1 173 | decision : Approve 174 | recommendation : Deny 175 | target : Robert Pettke (EXT) 176 | reviewedBy : Jenny Baechtel 177 | 178 | decisionID : 65e6168e-b4a0-4b6c-8bb4-a6fa2eb9e49b 179 | decision : Approve 180 | recommendation : Approve 181 | target : Florian Frommherz 182 | reviewedBy : Jenny Baechtel 183 | 184 | decisionID : 842467d7-1bf2-44c2-9619-bbc86320b517 185 | decision : Approve 186 | recommendation : Approve 187 | target : Jenny Baechtel 188 | reviewedBy : Jenny Baechtel 189 | 190 | decisionID : 3857a57a-d555-48e7-a85c-f10f7d3e2334 191 | decision : Approve 192 | recommendation : Deny 193 | target : John Doe 194 | reviewedBy : Jenny Baechtel 195 | ``` 196 | 197 | 198 | 199 | -------------------------------------------------------------------------------- /Refreshed-AccessReviews-API-samples/Refreshed-AccessReviews-Powershell-Samples-DEC2020.ps1: -------------------------------------------------------------------------------- 1 | function Get-GraphExampleAuthTokenServicePrincipal { 2 | [cmdletbinding()] 3 | param 4 | ( 5 | [Parameter(Mandatory = $true)] 6 | $ClientId, 7 | 8 | [Parameter(Mandatory = $true)] 9 | $ClientSecret, 10 | 11 | [Parameter(Mandatory = $true)] 12 | $TenantDomain 13 | ) 14 | 15 | 16 | $tenant = $TenantDomain 17 | 18 | 19 | Write-Verbose "Checking for AzureAD module..." 20 | 21 | $AadModule = Get-Module -Name "AzureAD" -ListAvailable 22 | if ($AadModule -eq $null) { 23 | write-verbose "AzureAD PowerShell module not found, looking for AzureADPreview" 24 | $AadModule = Get-Module -Name "AzureADPreview" -ListAvailable 25 | } 26 | 27 | if ($AadModule -eq $null) { 28 | write-output 29 | write-error "AzureAD Powershell module not installed..." 30 | write-output "Install by running 'Install-Module AzureAD' or 'Install-Module AzureADPreview' from an elevated PowerShell prompt" 31 | write-output "Script can't continue..." 32 | write-output 33 | return "" 34 | } 35 | # Getting path to ActiveDirectory Assemblies 36 | # If the module count is greater than 1 find the latest version 37 | 38 | if ($AadModule.count -gt 1) { 39 | write-verbose "multiple module versions" 40 | $Latest_Version = ($AadModule | select version | Sort-Object)[-1] 41 | $aadModule = $AadModule | ? { $_.version -eq $Latest_Version.version } 42 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 43 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 44 | } 45 | 46 | else { 47 | write-verbose "single module version" 48 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 49 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 50 | } 51 | 52 | Write-verbose "loading $adal and $adalforms" 53 | 54 | 55 | [System.Reflection.Assembly]::LoadFrom($adal) | Out-Null 56 | [System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null 57 | 58 | write-verbose "DLLs loaded" 59 | 60 | # $redirectUri = "urn:ietf:wg:oauth:2.0:oob" 61 | $resourceAppIdURI = "https://graph.microsoft.com" 62 | 63 | $authority = "https://login.microsoftonline.com/$Tenant" 64 | 65 | try { 66 | write-verbose "instantiating ADAL objects for $authority" 67 | $authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority 68 | 69 | write-verbose "client $ClientId $clientSecret" 70 | 71 | $clientCredential = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.ClientCredential" -ArgumentList ($ClientId,$ClientSecret) 72 | 73 | write-verbose "acquiring token for $resourceAppIdURI" 74 | # AuthenticationResult authResult = await authContext.AcquireTokenAsync(BatchResourceUri, new ClientCredential(ClientId, ClientKey)); 75 | # if you get an error about PowerShell not being able to find this method with 2 parameters, it means there is another version of ADAL DLL already in the process space of your PowerShell environment. 76 | 77 | $authResult = $authContext.AcquireTokenAsync($resourceAppIdURI, $clientCredential).Result 78 | # If the accesstoken is valid then create the authentication header 79 | if ($authResult.AccessToken) { 80 | write-verbose "acquired token" 81 | # Creating header for Authorization token 82 | $authHeader = @{ 83 | 'Content-Type' = 'application/json' 84 | 'Authorization' = "Bearer " + $authResult.AccessToken 85 | 'ExpiresOn' = $authResult.ExpiresOn 86 | } 87 | return $authHeader 88 | } 89 | else { 90 | write-output "" 91 | write-output "Authorization Access Token is null, please re-run authentication..." 92 | write-output "" 93 | break 94 | } 95 | } 96 | catch { 97 | write-output $_.Exception.Message 98 | write-output $_.Exception.ItemName 99 | write-output "" 100 | break 101 | } 102 | } 103 | #endregion 104 | 105 | $_instanceIDs = @() 106 | 107 | # exported module member 108 | function Connect-AzureADMSARSample { 109 | [CmdletBinding()] 110 | param( 111 | [Parameter(Mandatory=$true)] 112 | [ValidateScript({ 113 | try { 114 | [System.Guid]::Parse($_) | Out-Null 115 | $true 116 | } catch { 117 | throw "$_ is not a valid GUID" 118 | } 119 | })] 120 | [string]$ClientApplicationId, 121 | 122 | [Parameter(Mandatory=$true)] 123 | [string]$ClientSecret, # base64 client secret. Note this as a command line parameter is for testing purposes only 124 | 125 | [Parameter(Mandatory=$true)] 126 | [string]$TenantDomain # e.g., microsoft.onmicrosoft.com 127 | ) 128 | 129 | $script:_SampleInternalAuthNHeaders = @() 130 | 131 | 132 | $authHeaders = Get-GraphExampleAuthTokenServicePrincipal -ClientId $ClientApplicationId -ClientSecret $ClientSecret -TenantDomain $TenantDomain 133 | 134 | $script:_SampleInternalAuthNHeaders = $authHeaders 135 | 136 | } 137 | 138 | 139 | function Get-InternalAuthNHeaders { 140 | [CmdletBinding()] 141 | param() 142 | 143 | try { 144 | 145 | $authResult = $script:_SampleInternalAuthNHeaders 146 | if ($authResult.Length -eq @()) { 147 | Throw "Connect-AzureADMSARSample must be called first" 148 | } 149 | 150 | } catch { 151 | Throw # "Connect-AzureADMSControls must be called first" 152 | } 153 | return $authResult 154 | } 155 | 156 | 157 | <# 158 | .Synopsis 159 | Gets the definition (blueprint) of an Access Review and displays its status, creation date and creator. 160 | 161 | .Description 162 | Gets the definition of an Access Review and displays its status, creation date and creator. 163 | 164 | .Parameter definitionID 165 | The ID of an Access Review, as seen from the Azure AD Portal. 166 | 167 | .Example 168 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" 169 | #> 170 | function Get-AzureADARDefinition() 171 | { 172 | #Parameter bindings - we expect authHeaders and definitionID. 173 | [CmdletBinding()] 174 | Param( 175 | [Parameter(ValueFromPipelineByPropertyName)]$authHeaders, 176 | [Parameter(ValueFromPipelineByPropertyName)]$definitionID 177 | ) 178 | 179 | 180 | #Let's build the call for Microsoft Graph. 181 | $definitionURL = "https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions/$definitionID" 182 | 183 | 184 | $definitionResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $definitionURL -Method Get 185 | 186 | #See if the response makes sense and if there's a response: 187 | if ($definitionResponse -eq $null -or $definitionResponse.Content -eq $null) { 188 | throw "ERROR: We did not get a response from $definitionURL" 189 | } 190 | 191 | #bring the results into a right format. We convert it to a PSObject, so we can pipe. 192 | $definitionResult = ConvertFrom-Json $definitionResponse.Content 193 | $result = New-Object PSCustomObject 194 | $result | Add-Member NoteProperty "definitionID" $definitionResult.id 195 | $result | Add-Member NoteProperty "displayName" $definitionResult.displayName 196 | $result | Add-Member NoteProperty "status" $definitionResult.status 197 | $result | Add-Member NoteProperty "createdBy" $definitionResult.createdBy.displayName 198 | $result | Add-Member NoteProperty "createdDateTime" $definitionResult.createdDateTime 199 | $result | Add-Member NoteProperty "authHeaders" $authHeaders 200 | 201 | $result 202 | } 203 | 204 | <# 205 | .Synopsis 206 | Gets the definition for all Access Reviews, and displays their status, creation dates and creators. 207 | 208 | .Description 209 | Gets the definition for all Access Reviews, and displays their status, creation dates and creators. Will display 20 Access Reviews by default. 210 | 211 | .Parameter top 212 | The number of Access Reviews to return. 213 | 214 | .Example 215 | Get-AzureADARDefinition -top 15 216 | #> 217 | function Get-AzureADARAllDefinitions() 218 | { 219 | [CmdletBinding()] 220 | Param( 221 | [Parameter(ValueFromPipelineByPropertyName)]$authHeaders, 222 | [Parameter(ValueFromPipelineByPropertyName)][int]$top=20 223 | ) 224 | 225 | #Let's build the call for Microsoft Graph. 226 | $allDefinitionsURL = "https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions" 227 | $allDefinitionsURL = $allDefinitionsURL + '/?$top=' + $top 228 | 229 | $allDefinitionResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $allDefinitionsURL -Method Get 230 | 231 | #See if the response makes sense and if there's a response: 232 | if ($allDefinitionResponse -eq $null -or $allDefinitionResponse.Content -eq $null) { 233 | throw "ERROR: We did not get a response from $alldefinitionsURL" 234 | } 235 | 236 | # Pull the result set and convert it back from the result JSON. 237 | $allDefinitionsResult = ConvertFrom-Json $allDefinitionResponse.Content 238 | $resultSet = @() 239 | New-Object PsCustomObject 240 | 241 | #bring the results into a right format. We convert it to a PSObject, so we can pipe. 242 | foreach($def in $allDefinitionsResult.Value) 243 | { 244 | $result = New-Object PSCustomObject 245 | $result | Add-Member NoteProperty "definitionID" $def.id 246 | $result | Add-Member NoteProperty "displayName" $def.displayName 247 | $resultSet += $result 248 | } 249 | $resultSet 250 | } 251 | 252 | <# 253 | .Synopsis 254 | Gets the instances for an Access Review. 255 | 256 | .Description 257 | Gets the instances for an Access Review. An instance could be individual reviews in a series or many reviews under one defintiion, such as "All O365 Groups with external identities". 258 | 259 | .Parameter top 260 | The number of Access Reviews to 261 | 262 | .Example 263 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" 264 | #> 265 | function Get-AzureADARInstancesFromDefinition() 266 | { 267 | 268 | [CmdletBinding()] 269 | Param( 270 | [Parameter(ValueFromPipelineByPropertyName)]$authHeaders, 271 | [Parameter(ValueFromPipelineByPropertyName)]$definitionID 272 | ) 273 | 274 | #Let's build the call for Microsoft Graph. 275 | $listURL = "https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions/" + $definitionID + "/instances/" 276 | $listURL = $listURL + '?$top=20' 277 | 278 | $listResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $listURL -Method Get 279 | 280 | if ($listResponse -eq $null -or $listResponse.Content -eq $null) { 281 | throw "ERROR: We did not get a response from $listURL" 282 | } 283 | 284 | $listResult = ConvertFrom-Json $listResponse.Content 285 | $data = $listResult.Value 286 | 287 | #Let's check if Graph told us there are more results for us to fetch. If so, let's loop through the results until we have all. 288 | while($listResult.'@odata.nextLink') 289 | { 290 | $nextURL = $listResult.'@odata.nextLink' 291 | 292 | $listResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $nextURL -Method Get 293 | 294 | if ($listResponse -eq $null -or $listResponse.Content -eq $null) { 295 | # throw "ERROR: We did not get a response from $nextURL" 296 | } 297 | 298 | $listResult = ConvertFrom-Json $listResponse.Content 299 | $data += $listResult.Value 300 | } 301 | 302 | #Let us bring the results into a format that can be used with Pipe. 303 | $instanceResults = @() 304 | foreach($inst in $listResult.Value) 305 | { 306 | $result = New-Object PSCustomObject 307 | $result | Add-Member NoteProperty "definitionID" $definitionID 308 | $result | Add-Member NoteProperty "instanceID" $inst.id 309 | $result | Add-Member NoteProperty "status" $inst.status 310 | $result | Add-Member NoteProperty "authHeaders" $authHeaders 311 | $instanceResults += $result 312 | } 313 | $instanceResults 314 | } 315 | 316 | <# 317 | .Synopsis 318 | Gets the instance details for an instance of an Access Review. 319 | 320 | .Description 321 | Gets the details of an instance of an Access Review. Details include the status and the results of the instance. 322 | 323 | .Parameter definitionID 324 | The definition ID for the Access Review as seen from the Azure AD Portal. 325 | 326 | .Parameter instanceID 327 | The instanceID for an Access Review that you are interested in inspecting deeply. 328 | 329 | .Example 330 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" -instanceID "a66c337b-6344-4661-a41b-a04e492baa44" 331 | #> 332 | function Get-AzureADInstanceDetails() 333 | { 334 | 335 | [CmdletBinding()] 336 | Param( 337 | [Parameter(ValueFromPipelineByPropertyName)]$authHeaders, 338 | [Parameter(ValueFromPipelineByPropertyName)]$definitionID, 339 | [Parameter(ValueFromPipelineByPropertyName)]$instanceID 340 | ) 341 | 342 | 343 | #Let's build the call for Microsoft Graph. 344 | $instanceURL = "https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions/" + $definitionID + "/instances/" + $instanceID 345 | 346 | $instanceResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $instanceURL -Method Get 347 | 348 | if ($instanceResponse -eq $null -or $instanceResponse.Content -eq $null) { 349 | throw "ERROR: We did not get a response from $instanceURL" 350 | } 351 | 352 | #Let us bring the results into a format that can be used with Pipe. 353 | $instanceResult = ConvertFrom-Json $instanceResponse.Content 354 | $result = New-Object PSCustomObject 355 | $result | Add-Member NoteProperty "definitionID" $definitionID 356 | $result | Add-Member NoteProperty "instanceID" $instanceResult.id 357 | $result | Add-Member NoteProperty "status" $instanceResult.status 358 | $result | Add-Member NoteProperty "authHeaders" $authHeaders 359 | 360 | $result 361 | } 362 | 363 | <# 364 | .Synopsis 365 | Gets the decisions that reviews submitted for an Access Review instance. 366 | 367 | .Description 368 | Gets the decisions for an instance of an Access Review. Decision details include the decision taken, the reviewer and whent he review was recorded - and also what the system recommendation was. 369 | 370 | .Parameter definitionID 371 | The definition ID for the Access Review as seen from the Azure AD Portal. 372 | 373 | .Parameter instanceID 374 | The instanceID for an Access Review that you are interested in inspecting deeply. 375 | 376 | .Example 377 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" -instanceID "a66c337b-6344-4661-a41b-a04e492baa44" 378 | #> 379 | function Get-AzureADDecisionsFromInstance() 380 | { 381 | 382 | [CmdletBinding()] 383 | Param( 384 | [Parameter(ValueFromPipelineByPropertyName)]$authHeaders, 385 | [Parameter(ValueFromPipelineByPropertyName)]$definitionID, 386 | [Parameter(ValueFromPipelineByPropertyName)]$instanceID 387 | ) 388 | 389 | 390 | #Let's build the call for Microsoft Graph. 391 | $listDecisionsURL = "https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions/" + $definitionID + "/instances/" + $instanceID + "/decisions/" 392 | $listDecisionsURL = $listDecisionsURL + '?$top=10' 393 | 394 | $listDecisionsResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $listDecisionsURL -Method Get 395 | 396 | if ($listDecisionsResponse -eq $null -or $listDecisionsResponse.Content -eq $null) { 397 | throw "ERROR: We did not get a response from $listDecisionsURL" 398 | } 399 | $listDecisionsResult = ConvertFrom-Json $listDecisionsResponse.Content 400 | $data = $listDecisionsResult.Value 401 | 402 | #Let's check if Graph told us there are more results for us to fetch. If so, let's loop through the results until we have all. 403 | while($listDecisionsResult.'@odata.nextLink') 404 | { 405 | $nextDecisionURL = $listDecisionsResult.'@odata.nextLink' 406 | 407 | $listDecisionsResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $nextDecisionsURL -Method Get 408 | 409 | if ($listDecisionsResponse -eq $null -or $listDecisionsResponse.Content -eq $null) { 410 | # throw "ERROR: We did not get a response from $nextURL" 411 | } 412 | 413 | $listDecisionsResult = ConvertFrom-Json $listDecisionsResponse.Content 414 | } 415 | 416 | $instancedecisionResults = @() 417 | 418 | #Let us bring the results into a format that can be used with Pipe. 419 | New-Object PsCustomObject 420 | foreach($dec in $listDecisionsResult.Value) 421 | { 422 | $result = New-Object PSCustomObject 423 | ##$result | Add-Member NoteProperty "definitionID" $definitionID 424 | ##$result | Add-Member NoteProperty "instanceID" $instanceID 425 | $result | Add-Member NoteProperty "decisionID" $dec.id 426 | $result | Add-Member NoteProperty "decision" $dec.decision 427 | $result | Add-Member NoteProperty "recommendation" $dec.recommendation 428 | $result | Add-Member NoteProperty "target" $dec.target.userDisplayName 429 | $result | Add-Member NoteProperty "reviewedBy" $dec.reviewedBy.displayName 430 | $instancedecisionResults += $result 431 | } 432 | $instancedecisionResults 433 | } 434 | 435 | <# 436 | .Synopsis 437 | Gets a few statistics from an Access Review instance and its decisions. 438 | 439 | .Description 440 | Gets a few statistics from an Access Review instance and its decisions. It returns the accpetance/decline rate for reviewed users - and how reviewers responded. 441 | 442 | .Parameter definitionID 443 | The definition ID for the Access Review as seen from the Azure AD Portal. 444 | 445 | .Parameter instanceID 446 | The instanceID for an Access Review that you are interested in inspecting deeply. 447 | 448 | .Example 449 | Get-AzureADARDefinition -definitionID "a66c337b-6344-4661-a41b-a04e492baa44" -instanceID "a66c337b-6344-4661-a41b-a04e492baa44" 450 | #> 451 | function Get-AzureADInstanceStatistics($authHeaders, $definitionID, $instanceID) 452 | { 453 | 454 | #Let's build the call for Microsoft Graph. 455 | $getDecisionsURL = "https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions/" + $definitionID + "/instances/" + $instanceID + "/decisions/" 456 | $getDecisionsURL = $getDecisionsURL + '?$top=10' 457 | 458 | $getDecisionsResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $getDecisionsURL -Method Get 459 | 460 | if ($getDecisionsResponse -eq $null -or $getDecisionsResponse.Content -eq $null) { 461 | throw "ERROR: We did not get a response from $getDecisionsURL" 462 | } 463 | $getDecisionsResult = ConvertFrom-Json $getDecisionsResponse.Content 464 | $data = $getDecisionsResult.Value 465 | 466 | #Let's check if Graph told us there are more results for us to fetch. If so, let's loop through the results until we have all. 467 | while($getDecisionsResult.'@odata.nextLink') 468 | { 469 | $nextDecisionURL = $getDecisionsResult.'@odata.nextLink' 470 | 471 | $getDecisionsResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $nextDecisionsURL -Method Get 472 | 473 | if ($getDecisionsResponse -eq $null -or $getDecisionsResponse.Content -eq $null) { 474 | # throw "ERROR: We did not get a response from $nextURL" 475 | } 476 | 477 | $getDecisionsResult = ConvertFrom-Json $getDecisionsResponse.Content 478 | $data += $getDecisionsResult.Value 479 | } 480 | 481 | #collected the decision results - now let's parse them and display them. 482 | 483 | Write-Host "Statistics for this review:" 484 | Write-Host "There are $($data.Count) decisions." 485 | 486 | #declare a few variables: 487 | $approved = @{} 488 | $denied = @{} 489 | $dontknow = @{} 490 | $matchRecommendation = 0 491 | $justificationCount = 0 492 | $justificationChars = 0 493 | 494 | #Let's loop through the decisions - and process them into "approve/deny" buckets. 495 | foreach($d in $data) 496 | { 497 | switch($d.decision) 498 | { 499 | "Approve" { if($approved.Contains($d.reviewedBy.id)) { $approved[$d.reviewedBy.id]++ } else { $approved.Add($d.reviewedBy.ID, 1) } } 500 | "Deny" { if($denied.Contains($d.reviewedBy.id)) { $denied[$d.reviewedBy.id]++ } else { $denied.Add($d.reviewedBy.ID, 1) } } 501 | } 502 | 503 | #in case the reviewer voted in line with the system recommendation. 504 | if($d.decision -eq $d.recommendation) { $matchRecommendation++ } 505 | 506 | #also, let's look at what the reviewer provided as a justification. What's the char number? Are they entering something sensible vs. random characters just to get past the box? 507 | if($d.justification -ne $null) { $justificationCount++; $justificationChars += $d.justification.Length } 508 | } 509 | 510 | 511 | $approvals = 0 512 | 513 | #Now, on to parsing, displaying the information we've gathered. 514 | foreach($a in $approved.Values) { $approvals = $approvals + $a } 515 | if($approvals -gt 0) { Write-Host "We had $approvals approvals ($($approvals/$($data.Count)*100)% approval rate)"; $approved } 516 | $denies = 0 517 | foreach($d in $denied.Values) { $denies = $denies + $d } 518 | if($denies -gt 0) { Write-Host "We had $denies deny decisions ($($denies/$($data.Count)*100) % denial rate)"; $denied } 519 | $justificationPercent = $justificationChars/$justificationCount 520 | Write-Host "There were $justificationCount justifications provided - with an average of $justificationPercent characters." 521 | 522 | ##reviewer statistics. 523 | } 524 | 525 | 526 | Connect-AzureADMSARSample -ClientApplicationId "" -ClientSecret "" -TenantDomain "yourtenant.onmicrosoft.com" 527 | 528 | #Get-AzureADARAllDefinitions $_SampleInternalAuthNHeaders -top 6 529 | #Get-AzureADARInstancesFromDefinition $_SampleInternalAuthNHeaders "f255caaa-1c44-405f-870d-da4ca645db4a" 530 | #Get-AzureADInstanceDetails $_SampleInternalAuthNHeaders "f255caaa-1c44-405f-870d-da4ca645db4a" "f56e2f4f-7fae-4852-949f-d2ef0d80dfd4" 531 | #Get-AzureADDecisionsFromInstance $_SampleInternalAuthNHeaders "f255caaa-1c44-405f-870d-da4ca645db4a" "f56e2f4f-7fae-4852-949f-d2ef0d80dfd4" 532 | #Get-AzureADInstanceStatistics $_SampleInternalAuthNHeaders "f255caaa-1c44-405f-870d-da4ca645db4a" "f56e2f4f-7fae-4852-949f-d2ef0d80dfd4" 533 | 534 | #Export-ModuleMember Get-AzureADARAllDefinitions 535 | #Export-ModuleMember Get-AzureADARDefinition 536 | #Export-ModuleMember Get-AzureADARInstancesFromDefinition 537 | #Export-ModuleMember Get-AzureADInstanceDetails 538 | #Export-ModuleMember Get-AzureADDecisionsFromInstance -------------------------------------------------------------------------------- /Refreshed-AccessReviews-API-samples/screenshots/README.MD: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Refreshed-AccessReviews-API-samples/screenshots/relationships.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/access-reviews-samples/dd300b2d3b9035c46fb6abb9ba6945a3320efc07/Refreshed-AccessReviews-API-samples/screenshots/relationships.png -------------------------------------------------------------------------------- /Refreshed-AccessReviews-API-samples/screenshots/structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/access-reviews-samples/dd300b2d3b9035c46fb6abb9ba6945a3320efc07/Refreshed-AccessReviews-API-samples/screenshots/structure.png -------------------------------------------------------------------------------- /ReviewStaleExternals/ARtemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "<>", 3 | "descriptionForAdmins": "<>", 4 | "descriptionForReviewers": "<>", 5 | "scope": { 6 | "query": "/groups/<>/transitiveMembers/microsoft.graph.user/?$count=true&$filter=(userType eq 'Guest')", 7 | "queryType": "MicrosoftGraph" 8 | }, 9 | "instanceEnumerationScope": { 10 | "query": "/groups/<>", 11 | "queryType": "MicrosoftGraph" 12 | }, 13 | "reviewers": [], 14 | "settings": { 15 | "mailNotificationsEnabled": true, 16 | "reminderNotificationsEnabled": true, 17 | "justificationRequiredOnApproval": true, 18 | "defaultDecisionEnabled": true, 19 | "defaultDecision": "Deny", 20 | "instanceDurationInDays": 0, 21 | "autoApplyDecisionsEnabled": false, 22 | "recommendationsEnabled": true, 23 | "recurrence": { 24 | "pattern": null, 25 | "range": { 26 | "type": "numbered", 27 | "numberOfOccurrences": 0, 28 | "recurrenceTimeZone": null, 29 | "startDate": "<>", 30 | "endDate": "<>" 31 | } 32 | }, 33 | "applyActions": [ 34 | { 35 | "@odata.type": "#microsoft.graph.disableAndDeleteUserApplyAction" 36 | } 37 | ] 38 | } 39 | } -------------------------------------------------------------------------------- /ReviewStaleExternals/README.MD: -------------------------------------------------------------------------------- 1 | # Finding and reviewing external identities ("B2B Guests") that have never signed into your tenant, or have signed in to your tenant a long time ago 2 | ## Synopsis 3 | 4 | This Powershell sample script is meant to enumerate the external identities in your tenant and check when their lastSignInDateTime was. When external identities have never signed in to your tenant, or have signed in a long time ago, the script can also: 5 | * put the external identities that it found never signed in or signed in a long time ago into respective security groups (which it creates new), and add them as members 6 | * create Access Reviews for these two groups, and request the external identities to self-attest that they still need access. 7 | * honor external identities who have been invited but not yet accepted the invitation, so that they are not removed. 8 | 9 | This script has two files, both are required: 10 | 1. PS1 file that is the main script. All logic is in this script file. 11 | 2. A JSON text file (ARtemplate.JSON) that contains the description for an Access Review. The script uses this JSON file to create Access Review with the settings defined in the JSON, so that the right review settings are applied. 12 | 13 | This Powershell script can be used to get an overview of external identities that do have not come back in a while, hence, should be reviewed via Access Reviews and the option Disable and Delete (https://docs.microsoft.com/en-us/azure/active-directory/governance/access-reviews-external-users#disable-and-delete-external-identities-with-azure-ad-access-reviews-preview) from the tenant. 14 | 15 | The script automates a few steps that - previously - needed to happen in multiple, partially manual, steps. An overview how you can think of this script is outlined in the following picture: 16 | 17 | ![How we suggest you use this script](./screenshots/StaleIDs.png) 18 | 19 | ## How to use this script and its output 20 | 21 | This sample script intends to assist Administrators and Compliance Auditors in organizations that use Azure AD for Business-to-Business (B2B) collaobration in finding, reviewing and - should need be - clean up external identity references from their Azure AD. As with internal users and employees - you want to ensure when collaborating with external partners, vendors and supplies that 22 | 23 | > the right people have the right access at the right time. 24 | 25 | This script is the first step in discovering external identities in your Azure AD tenant. It outlines what external identities in your tenant exist and when if they have not signed in recently. 26 | 27 | This script drives awareness of external identities and prepares administrators to be able to plan their Access Reviews deployment and setup, to review external identities' access, as well as the need for continued presence for external identities in their tenant. 28 | 29 | Learn more: 30 | 31 | [Azure AD - Identity Governance](https://docs.microsoft.com/en-us/azure/active-directory/governance/identity-governance-overview) 32 | 33 | [Azure AD - Access Reviews](https://docs.microsoft.com/en-us/azure/active-directory/governance/access-reviews-overview) 34 | 35 | [Azure AD - External Collaboration](https://docs.microsoft.com/en-us/azure/active-directory/b2b/what-is-b2b) 36 | 37 | ## Prerequisites and starting the script 38 | This Powershell module runs in application context, which requires that a Service Principal is created in Azure AD that has a clientID (application ID) and a client secret. 39 | 40 | The script can perform three actions for you - and you can decide whether you want only the first, or all of them to be performed: 41 | * Go through the tenant and enumerate all external identities and find out if they ever signed in to the tenant, or if their last signin date is far in the past 42 | * The external identities found that never signed in or whose sign in date is far in the past, can be added as members to two groups that the script creates 43 | * The script creates two Access Reviews automatically for the two groups it just created. 44 | 45 | Therefore, depending on which steps you want the script to undertake, you need to add the following Microsoft Graph OAuth2 scopes to the Service Principal that the script uses: 46 | * Users.Read.All - to read external identity user accounts. 47 | * AuditLog.Read.All - to read the lastSignInDateTime for the external identities. That information is not stored as part of the user object, but in the audit log portions in Azure AD. 48 | * Organization.Read.All - to read the organization details - also required for the lastSignInDateTime to be readable. 49 | * Group.Create - to create the two security groups to add stale external identities as members. 50 | * GroupMember.ReadWrite.All - to add the stale external identities as members. 51 | * AccessReview.Read.All - to create the Access Review 52 | * AccessReview.ReadWrite.Membership - to create the Access Review 53 | 54 | Also, please make sure you admin-consent to the Microsoft Graph scopes. The script uses the Access Reviews API V2. 55 | 56 | ![The app-context OAuth scopes required to run this script](./screenshots/appPermissions.png) 57 | 58 | The clientID and the client secret must be supplied in last two lines of the script, alongside your tenant name: 59 | ```Powershell 60 | Connect-AzureADMSARSample -ClientApplicationId "" -ClientSecret "" -TenantDomain "yourtenant.onmicrosoft.com" 61 | ``` 62 | The last two lines in the script are (a) there to connect to Azure AD and acquire an access_token for making Microsoft Graph calls, as well as (b) start the script with the relevant parameters. 63 | 64 | If you want to download the script and gather information, download the script file, as well as the .JSON style file, to schedule Access Reviews automatically: 65 | * ARtemplate.JSON 66 | * review-stale-externals-DEC2020.ps1 67 | 68 | As always - use this script in a test environment first, before you carry it forward to a (near-) production environment. This is a sample script, for you to adjust and make it your own. 69 | 70 | ## Exported functions 71 | The script will run in the context of the Service Principal you have created (application context) and could therefore be used to run "headless", as a scheduled script or task that runs just like a scheduled tasks, every three months. 72 | 73 | You can either modify the PS1 script file, such that it contains all relevant information about client ID, client secret and the parameters you want to call the script with, or you comment out the last two lines, run the script, and call the relevent functions yourself: 74 | 75 | After modifying the script: 76 | ```Powershell 77 | .\review-stale-externals-DEC2020.PS1 78 | ``` 79 | 80 | Or commenting out the last two lines and then calling the methods manually: 81 | ```Powershell 82 | .\review-stale-externals-DEC2020.PS1 83 | Connect-AzureADMSARSample -ClientApplicationId "" -ClientSecret "" -TenantDomain "yourtenant.onmicrosoft.com" 84 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders -$staleDays 60 -createReviewGroups $true -scheduleReviews $true -JSONPath "C:\temp\ARtemplate.json" 85 | ``` 86 | If you do NOT want Access Reviews scheduled for you, but want the guest identities made members of newly created groups (set "-scheduleReviews" to $false): 87 | ```Powershell 88 | .\review-stale-externals-DEC2020.PS1 89 | Connect-AzureADMSARSample -ClientApplicationId "" -ClientSecret "" -TenantDomain "yourtenant.onmicrosoft.com" 90 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders -$staleDays 60 -createReviewGroups $true -scheduleReviews $false -JSONPath "C:\temp\ARtemplate.json" 91 | ``` 92 | 93 | ## Supported parameters: 94 | 95 | [-staleDays] to define the maximum number of days that external identities can be "stale" with not having signed into your tenant, before the script catches them. 96 | 97 | [-createReviewGroups] indicates with $true or $false whether the script should - in case it found external identities - create two security groups and add the found external identities as members: 98 | * A group with displayName REVIEW_GUESTS_NOT_SIGNED_IN_LAST_XX_DAYS_ will be created with all external identities as members that have not signed in the last XX days. 99 | * A group with displayName REVIEW_GUESTS_NEVER_SIGNED_IN_ will be created with all external identities as members that have NEVER signed in to your tenant. 100 | 101 | [-scheduleReviews] indicates with $true or $false whether the script should - in case it found external identities - create an Access Review for the two security groups. The Access Review will have the settings as outlined in the JSON template file. 102 | 103 | [-JSONPath] indicates the literal (exact) path where to find the .JSON file that outlines the settings for the Access Reviews that will be scheduled. 104 | 105 | ## Access Reviews that are scheduled 106 | The template JSON file ("ARtemplate.JSON") that you need to download alongside the script has a definition of how Access Reviews for "stale" external identities will be scheduled. The template has pre-defined settings - but clearly, you can adjust the JSON template file according to your needs. The downloadable JSON file from here has the following settings for the Access Reviews that it schedules: 107 | 108 | * One-Time Access Review 109 | * Reviews Guest identities only 110 | * Starts the review immediately (Start Date: Today) 111 | * The external identities perform a self-review - there is no pre-defined reviewer. All external identities will be emailed and asked to attest if they still need access to the tenant. 112 | * In case they come back and say they still need access to the tenant, they must provide a business justification. 113 | * [Optional] External Identities that do NOT come back or come back and say they don't need access any more are blocked from signing in to your tenant and removed after 30 days ("Disable and Delete" feature in Azure AD Access Reviews) automatically. You can enable automatic blocking and deleting external identities, by changing the following line in the ARtemplate.JSON, before running the script: 114 | Before: 115 | ```Powershell 116 | "autoApplyDecisionsEnabled": false, 117 | ``` 118 | Change to: 119 | ```Powershell 120 | "autoApplyDecisionsEnabled": true, 121 | ``` 122 | Changing the template file will result in the Access Review to enforce disabling and after 30 days deleting of external identities, if they don't respond or declare they don't need access any more: 123 | ![Changing the template and therefore the Access Review config enables disable and delete](./screenshots/disable-and-delete.png) 124 | -------------------------------------------------------------------------------- /ReviewStaleExternals/ReviewStaleExternals-DEC2020.ps1: -------------------------------------------------------------------------------- 1 | # This material is provided "AS-IS" and has no warranty. 2 | # 3 | # Last updated October 2020 4 | # 5 | # Read the Terms of Use on https://github.com/microsoft/access-reviews-samples 6 | 7 | 8 | #region AuthToken 9 | 10 | #This was borrowed from Mark's sample at https://techcommunity.microsoft.com/t5/azure-active-directory/example-how-to-create-azure-ad-access-reviews-using-microsoft/m-p/807241 11 | function Get-GraphExampleAuthTokenServicePrincipal { 12 | [cmdletbinding()] 13 | param 14 | ( 15 | [Parameter(Mandatory = $true)] 16 | $ClientId, 17 | 18 | [Parameter(Mandatory = $true)] 19 | $ClientSecret, 20 | 21 | [Parameter(Mandatory = $true)] 22 | $TenantDomain 23 | ) 24 | 25 | 26 | $tenant = $TenantDomain 27 | 28 | 29 | Write-Verbose "Checking for AzureAD module..." 30 | 31 | $AadModule = Get-Module -Name "AzureAD" -ListAvailable 32 | if ($AadModule -eq $null) { 33 | write-verbose "AzureAD PowerShell module not found, looking for AzureADPreview" 34 | $AadModule = Get-Module -Name "AzureADPreview" -ListAvailable 35 | } 36 | 37 | if ($AadModule -eq $null) { 38 | write-output 39 | write-error "AzureAD Powershell module not installed..." 40 | write-output "Install by running 'Install-Module AzureAD' or 'Install-Module AzureADPreview' from an elevated PowerShell prompt" 41 | write-output "Script can't continue..." 42 | write-output 43 | return "" 44 | } 45 | # Getting path to ActiveDirectory Assemblies 46 | # If the module count is greater than 1 find the latest version 47 | 48 | if ($AadModule.count -gt 1) { 49 | write-verbose "multiple module versions" 50 | $Latest_Version = ($AadModule | select version | Sort-Object)[-1] 51 | $aadModule = $AadModule | ? { $_.version -eq $Latest_Version.version } 52 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 53 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 54 | } 55 | 56 | else { 57 | write-verbose "single module version" 58 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 59 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 60 | } 61 | 62 | Write-verbose "loading $adal and $adalforms" 63 | 64 | 65 | [System.Reflection.Assembly]::LoadFrom($adal) | Out-Null 66 | [System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null 67 | 68 | write-verbose "DLLs loaded" 69 | 70 | # $redirectUri = "urn:ietf:wg:oauth:2.0:oob" 71 | $resourceAppIdURI = "https://graph.microsoft.com" 72 | 73 | $authority = "https://login.microsoftonline.com/$Tenant" 74 | 75 | try { 76 | write-verbose "instantiating ADAL objects for $authority" 77 | $authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority 78 | 79 | write-verbose "client $ClientId $clientSecret" 80 | 81 | $clientCredential = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.ClientCredential" -ArgumentList ($ClientId,$ClientSecret) 82 | 83 | write-verbose "acquiring token for $resourceAppIdURI" 84 | # AuthenticationResult authResult = await authContext.AcquireTokenAsync(BatchResourceUri, new ClientCredential(ClientId, ClientKey)); 85 | # if you get an error about PowerShell not being able to find this method with 2 parameters, it means there is another version of ADAL DLL already in the process space of your PowerShell environment. 86 | 87 | $authResult = $authContext.AcquireTokenAsync($resourceAppIdURI, $clientCredential).Result 88 | # If the accesstoken is valid then create the authentication header 89 | if ($authResult.AccessToken) { 90 | write-verbose "acquired token" 91 | # Creating header for Authorization token 92 | $authHeader = @{ 93 | 'Content-Type' = 'application/json' 94 | 'Authorization' = "Bearer " + $authResult.AccessToken 95 | 'ExpiresOn' = $authResult.ExpiresOn 96 | } 97 | return $authHeader 98 | } 99 | else { 100 | write-output "" 101 | write-output "Authorization Access Token is null, please re-run authentication..." 102 | write-output "" 103 | break 104 | } 105 | } 106 | catch { 107 | write-output $_.Exception.Message 108 | write-output $_.Exception.ItemName 109 | write-output "" 110 | break 111 | } 112 | } 113 | 114 | function Connect-AzureADMSARSample { 115 | [CmdletBinding()] 116 | param( 117 | [Parameter(Mandatory=$true)] 118 | [ValidateScript({ 119 | try { 120 | [System.Guid]::Parse($_) | Out-Null 121 | $true 122 | } catch { 123 | throw "$_ is not a valid GUID" 124 | } 125 | })] 126 | [string]$ClientApplicationId, 127 | 128 | [Parameter(Mandatory=$true)] 129 | [string]$ClientSecret, # base64 client secret. Note this as a command line parameter is for testing purposes only 130 | 131 | [Parameter(Mandatory=$true)] 132 | [string]$TenantDomain # e.g., microsoft.onmicrosoft.com 133 | ) 134 | 135 | $script:_SampleInternalAuthNHeaders = @() 136 | 137 | 138 | $authHeaders = Get-GraphExampleAuthTokenServicePrincipal -ClientId $ClientApplicationId -ClientSecret $ClientSecret -TenantDomain $TenantDomain 139 | 140 | $script:_SampleInternalAuthNHeaders = $authHeaders 141 | 142 | } 143 | 144 | 145 | function Get-InternalAuthNHeaders { 146 | [CmdletBinding()] 147 | param() 148 | 149 | try { 150 | 151 | $authResult = $script:_SampleInternalAuthNHeaders 152 | if ($authResult.Length -eq @()) { 153 | Throw "Connect-AzureADMSARSample must be called first" 154 | } 155 | 156 | } catch { 157 | Throw # "Connect-AzureADMSControls must be called first" 158 | } 159 | return $authResult 160 | } 161 | 162 | #endregion 163 | 164 | #We define two arrays that we collect the external identities in that have either never logged on, or signed in a long time ago. 165 | $_guestsOutsideCutOff = @() 166 | $_guestsNeverSignedIn = @() 167 | 168 | 169 | <# 170 | .Synopsis 171 | Finds external identities (Guests) in your tenant and checks when they have last signed in to your tenant. 172 | 173 | .Description 174 | Finds external identities (Guests) in your tenant and checks when they have last signed in to your tenant. For external identities that have never signed in to your tenant or longer ago than 'staleDays', they are added as members to a newly created group. This group can then be used for an Access Review. 175 | 176 | .Parameter staleDays 177 | The number of days that external identities can not have signed in, without being found by the script. (Default 180) 178 | 179 | .Parameter createReviewGroups 180 | Indicates whether security groups will be automatically created in your tenant, that will contain the found users that have never or a long time ago signed into your tenant. (Default $false) 181 | 182 | .Parameter scheduleReviews 183 | Indicates whether Access Reviews are scheduled for the newly created groups that contain stale external identities. (Default $false) 184 | 185 | .Parameter JSONPath 186 | The literal (exact) path to a JSON file that describes how the Access Review must be created. 187 | 188 | .Example 189 | # Show a external identities that never signed in or have signed in more than 60 days ago on the console. 190 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders 60 191 | 192 | .Example 193 | # Find external identities that have never signed in or have signed in more than 120 days ago and put them into new security groups. Don't schedule Access Reviews. 194 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders 60 -createReviewGroups $true 195 | 196 | .Example 197 | # Find external identities that have never signed in or have signed in more than 120 days ago and put them into new security groups. Schedule Access Reviews - and find the definition for the Access Review in c:\AccessReviews\template.JSON. 198 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders 60 -createReviewGroups $true -scheduleReviews $true -JSONPath "C:\AccessReviews\template.JSON" 199 | #> 200 | function Find-AzureADStaleExternals($authHeaders, $staleDays=180, $createReviewGroups=$false, $scheduleReviews=$false, $JSONPath = "C:\temp\ARtemplate.json") 201 | { 202 | ##Make sure $staleDays is in sensible boundaries. 203 | if(($staleDays >180) -or ($staleDays -eq $null)) 204 | { $staleDays = 180 } 205 | else 206 | { $cutOffDate = (Get-Date (Get-Date).AddDays(-$staleDays) -Format s) + "Z" } 207 | 208 | $pendingAcceptanceGracePeriodInDays = 30 209 | $pendingAcceptanceCutOffDate = (Get-Date (Get-Date).AddDays(-$pendingAcceptanceGracePeriodInDays) -Format s) + "Z" 210 | 211 | ##This is the Graph Call for getting all external identities. 212 | $listURL = 'https://graph.microsoft.com/beta/users?$select=id,displayName,userprincipalname,userType,signInActivity,externalUserState,externalUserStateChangeDateTime&$filter=userType eq ''Guest''' 213 | 214 | $listResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $listURL -Method Get 215 | 216 | if ($listResponse -eq $null -or $listResponse.Content -eq $null) { 217 | throw "ERROR: We did not get a response from $listURL" 218 | } 219 | 220 | $listResult = ConvertFrom-Json $listResponse.Content 221 | $data = $listResult.Value 222 | 223 | #Let's check if Graph told us there are more results for us to fetch. If so, let's loop through the results until we have all. 224 | while($listResult.'@odata.nextLink') 225 | { 226 | $nextURL = $listResult.'@odata.nextLink' 227 | 228 | $listResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $nextURL -Method Get 229 | 230 | if ($listResponse -eq $null -or $listResponse.Content -eq $null) { 231 | # throw "ERROR: We did not get a response from $nextURL" 232 | } 233 | 234 | $listResult = ConvertFrom-Json $listResponse.Content 235 | $data += $listResult.Value 236 | } 237 | ##$data.count 238 | ##For every external identity that we found, let's loop through the list and check (a) if they do NOT have a lastSignInDateTime = they never signed in, (b) if the date is beyond the threshold for stale days. 239 | foreach($d in $data) 240 | { 241 | if(($d.signInActivity.lastSignInDateTime -eq $null) -or ($d.signInActivity.lastSignInDateTime -eq "")) 242 | { 243 | #Now that we're here, we know the person has not signed into our tenant at all. Let's catch one corner case: the person was *just* invited 244 | #to the tenant and did not have a chance to accept the invitation. In that case, let's NOT add them to the AR group, but give them time to 245 | #accept the invite: 246 | if(($d.externalUserState -eq "PendingAcceptance") -and ($d.externalUserStateChangeDateTime -gt $pendingAcceptanceCutOffDate)) 247 | { 248 | Continue; 249 | } 250 | # add them to the array. 251 | $_guestsNeverSignedIn = $_guestsNeverSignedIn + $d.id 252 | } 253 | else 254 | { 255 | if($d.signInActivity.lastSignInDateTime -lt $cutOffDate) 256 | { 257 | ##add them to the array. 258 | $_guestsOutsideCutOff = $_guestsOutsideCutOff + $d.id 259 | } 260 | } 261 | #for debugging 262 | #Write-Host $d.id 263 | #Write-Host $d.signInActivity.lastSignInDateTime 264 | } 265 | 266 | ##If the caller wants us to create the review groups for them, we'll call the methods below. 267 | if($createReviewGroups) 268 | { 269 | $neverSignedInGroupObjectID = Add-NeverSignedInGroup $authHeaders 270 | $beyondCutOffDAysGroupObjectID = Add-BeyondCutOffDaysGroup $authHeaders $staleDays 271 | } 272 | else { 273 | Write-Host "External identities that have not logged on in the last $staleDays days: $($_guestsOutsideCutOff.Count)" 274 | Write-Host $_guestsOutsideCutOff 275 | Write-Host "---------------------------------" 276 | Write-Host "External identities that have never logged on in your tenant: $($_guestsNeverSignedIn.Count)" 277 | Write-Host $_guestsNeverSignedIn 278 | } 279 | Start-Sleep -Seconds 20 280 | 281 | #if the caller wants us to create the Access Reviews for them, we'll call the methods below. 282 | if($scheduleReviews) 283 | { 284 | if($_guestsNeverSignedIn.Count -gt 0) { $neverGroupCreated = Check-GroupHasMembers $authHeaders $neverSignedInGroupObjectID } 285 | if($_guestsOutsideCutOff.Count -gt 0) { $beyondGroupCreated = Check-GroupHasMembers $authHeaders $beyondCutOffDAysGroupObjectID} 286 | 287 | Start-Sleep -seconds 40 288 | 289 | if($_guestsNeverSignedIn.Count -gt 0 -and $neverGroupCreated) { Create-AzureADARScheduleDefinition $authHeaders $JSONPath $neverSignedInGroupObjectID "never" } 290 | if($_guestsOutsideCutOff.Count -gt 0 -and $beyondGroupCreated) { Create-AzureADARScheduleDefinition $authHeaders $JSONPath $beyondCutOffDAysGroupObjectID "beyond" } 291 | } 292 | } 293 | 294 | function Add-NeverSignedInGroup($authHeaders) 295 | { 296 | #Let's see if we even found external identities that never signed in 297 | if($_guestsNeverSignedIn.Count -gt 0) 298 | { 299 | #Set the name for the newly created group. We have a name and a date suffix: REVIEW_GUESTS_NEVER_SIGNED_IN_23-OCT-2020 300 | $groupNameNeverSignedIn = "REVIEW_GUESTS_NEVER_SIGNED_IN_$(Get-Date -Format 'dd-MMM-yyyy')" 301 | 302 | $createGroupURI = 'https://graph.microsoft.com/v1.0/groups' 303 | 304 | #We want to create a new group, so this will be a POST with the following group properties: security group that is not mail enabled 305 | $createGroupBody = "{""groupTypes"":[],""description"":""Automatically created group that contains external identities (aka Guests) that have never logged on."",""displayName"":""$groupNameNeverSignedIn"",""mailenabled"":false,""securityEnabled"":true,""mailNickName"":""$groupNameNeverSignedIn"",""members@odata.bind"": [" 306 | 307 | #we are adding all the members to the call body for Graph, so that we can commit the new group creation + all members in the same call. 308 | foreach($user in $_guestsNeverSignedIn) 309 | { 310 | $createGroupBody = $createGroupBody + """https://graph.microsoft.com/v1.0/users/$user""," 311 | } 312 | $createGroupBody = $createGroupBody.TrimEnd(",") 313 | $createGroupBody = $createGroupBody + "] }" 314 | #$createGroupBody = $createGroupBody | ConvertTo-Json 315 | 316 | #Create the group with its members. It's a POST this time. NOTE that the Service Principal needs Groups.Create and GroupMember.ReadWrite.All in the tenant. 317 | $createGroupResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $createGroupURI -Method Post -Body $createGroupBody -ContentType "application/json" 318 | if ($createGroupResponse -eq $null -or $createGroupResponse.Content -eq $null) { 319 | throw "ERROR: We did not get a response from $createGroupURI" 320 | } 321 | 322 | if($createGroupResponse.StatusCode -eq 201) 323 | { 324 | Write-Host "Created group with name $groupNameNeverSignedIn with $($_guestsNeverSignedIn.Count) members." 325 | $parsedJSON = ConvertFrom-Json $createGroupResponse.Content 326 | return $parsedJSON.ID 327 | } 328 | else { throw "We could not create the group."} 329 | } 330 | } 331 | 332 | function Check-GroupHasMembers($authHeaders, $groupObjectID) 333 | { 334 | $groupURL = "https://graph.microsoft.com/v1.0/groups/" + $groupObjectID + "/members" 335 | $groupResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $groupURL -Method Get 336 | 337 | $groupResult = ConvertFrom-Json $groupResponse.Content 338 | 339 | #Did we get a result? 340 | if ($groupResult -eq $null -and $groupResult.Content -eq $null) { 341 | throw "ERROR: We did not get a response from Graph, asking for the group, $groupURL" 342 | } 343 | #Qualifying the result. If the SID OR the onPremisesLastSyncDateTime are null or empty, we have reason to believe it's not an on-premises group. 344 | #We can abort then. 345 | if($groupResponse.Content.Count -gt 0) { return $true } 346 | else { return $false } 347 | } 348 | 349 | 350 | function Add-BeyondCutOffDaysGroup($authHeaders, $staleDays) 351 | { 352 | if($_guestsOutsideCutOff.Count -gt 0) 353 | { 354 | $groupNameOutsideCutOff = "REVIEW_GUESTS_NOT_SIGNED_IN_LAST_$($staleDays)_DAYS_$(Get-Date -Format 'dd-MMM-yyyy')" 355 | 356 | $createGroupURI2 = 'https://graph.microsoft.com/v1.0/groups' 357 | 358 | #We want to create a new group, so this will be a POST with the following group properties: security group that is not mail enabled 359 | $createGroupBody2 = "{""groupTypes"":[],""description"":""Automatically created group that contains external identities (aka Guests) that have never logged on."",""displayName"":""$groupNameOutsideCutOff"",""mailenabled"":false,""securityEnabled"":true,""mailNickName"":""$groupNameOutsideCutOff"",""members@odata.bind"": [" 360 | foreach($users in $_guestsOutsideCutOff) 361 | { 362 | $createGroupBody2 = $createGroupBody2 + """https://graph.microsoft.com/v1.0/users/$users""," 363 | } 364 | $createGroupBody2 = $createGroupBody2.TrimEnd(",") 365 | $createGroupBody2 = $createGroupBody2 + "] }" 366 | 367 | #Create the group with its members. It's a POST this time. NOTE that the Service Principal needs Groups.Create and GroupMember.ReadWrite.All in the tenant. 368 | $createGroupResponse2 = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $createGroupURI2 -Method Post -Body $createGroupBody2 -ContentType "application/json" 369 | if ($createGroupResponse2 -eq $null -or $createGroupResponse2.Content -eq $null) { 370 | throw "ERROR: We did not get a response from $createGroupURI2" 371 | } 372 | 373 | if($createGroupResponse2.StatusCode -eq 201) 374 | { 375 | Write-Host "Created group with name $groupNameOutsideCutOff with $($_guestsOutsideCutOff.Count) members." 376 | $parsedJSON = ConvertFrom-Json $createGroupResponse2.Content 377 | return $parsedJSON.ID 378 | } 379 | else { throw "We could not create the group."} 380 | } 381 | } 382 | 383 | function Create-AzureADARScheduleDefinition($authHeaders, $JSONPath, $groupObjectID, $groupType) 384 | { 385 | #The JSON Path points us to a text file that has JSON-formatted content. It outlines a template to create an Access Review. 386 | #If we can't find a file in the path we were given, let's throw an error. We expect a file there and it should be JSON. 387 | if(-not $(Test-Path -LiteralPath $JSONPath -PathType Leaf)) 388 | { 389 | throw "ERROR: File $($JSONPath) does not exist or cannot be found. Please enter a valid path to a JSON-formatted file, such as 'C:\temp\ARSamples\create-access-review.JSON'" 390 | } 391 | 392 | #Let's see if the file contents is JSON formatted. If it's not, let's throw an error and stop. 393 | $createJSON = Get-Content $JSONPath 394 | #depending on which group we're creating this review for, we want to replace variables in the template with sensible description(s) 395 | switch ($groupType) 396 | { 397 | "never" 398 | { 399 | $createJSON = $createJSON.Replace("<>", "Review of external identities that have never signed in.") 400 | $createJSON = $createJSON.Replace("<>", "This review was automatically generated by a script. It reviews an also auto-created security group that contains external identities (guests) that have never logged on to your tenant.") 401 | $createJSON = $createJSON.Replace("<>", "Please review your continued need to access this tenant.") 402 | } 403 | "beyond" 404 | { 405 | $createJSON = $createJSON.Replace("<>", "Review of external identities that have not signed in a long time") 406 | $createJSON = $createJSON.Replace("<>", "This review was automatically generated by a script. It reviews an also auto-created security group that contains external identities (guests) that have not logged on to your tenant for a long time.") 407 | $createJSON = $createJSON.Replace("<>", "Please review your continued need to access this tenant.") 408 | } 409 | } 410 | 411 | ##replace start and end dates for the review. We do a 30-day review. 412 | $startDate = Get-Date -format "yyyy-MM-dd" 413 | $endDate = (Get-Date).AddDays(30).ToString("yyyy-MM-dd") 414 | $createJSON = $createJSON.Replace("<>", $startDate) 415 | $createJSON = $createJSON.Replace("<>", $endDate) 416 | 417 | ##fill in the objectID of the group we just created. We want to review that group. 418 | $createJSON = $createJSON.Replace("<>", $groupObjectID) 419 | 420 | $createURL = 'https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions' 421 | 422 | $createResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Body $createJSON -Uri $createURL -Method POST 423 | 424 | if ($createResponse -eq $null -or $createResponse.Content -eq $null) { 425 | throw "ERROR: We did not get a response from $createURL" 426 | } 427 | 428 | if($createResponse.StatusCode -eq "201") 429 | { 430 | $data = ConvertFrom-JSON $createResponse 431 | Write-Host "Access Review $($data.ID) created. It is currently in status $($data.status)" 432 | } 433 | else 434 | { 435 | throw "ERROR: Could not create new Access Review schedule definition" 436 | } 437 | 438 | } 439 | 440 | Connect-AzureADMSARSample -ClientApplicationId "" -ClientSecret "" -TenantDomain ".onmicrosoft.com" 441 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders -staleDays 60 -createReviewGroups $true -scheduleReviews $true -JSONPath "C:\temp\ARtemplate.json" 442 | -------------------------------------------------------------------------------- /ReviewStaleExternals/ReviewStaleExternals-OCT2020.ps1: -------------------------------------------------------------------------------- 1 | # This material is provided "AS-IS" and has no warranty. 2 | # 3 | # Last updated October 2020 4 | # 5 | # Read the Terms of Use on https://github.com/microsoft/access-reviews-samples 6 | 7 | 8 | #region AuthToken 9 | 10 | #This was borrowed from Mark's sample at https://techcommunity.microsoft.com/t5/azure-active-directory/example-how-to-create-azure-ad-access-reviews-using-microsoft/m-p/807241 11 | function Get-GraphExampleAuthTokenServicePrincipal { 12 | [cmdletbinding()] 13 | param 14 | ( 15 | [Parameter(Mandatory = $true)] 16 | $ClientId, 17 | 18 | [Parameter(Mandatory = $true)] 19 | $ClientSecret, 20 | 21 | [Parameter(Mandatory = $true)] 22 | $TenantDomain 23 | ) 24 | 25 | 26 | $tenant = $TenantDomain 27 | 28 | 29 | Write-Verbose "Checking for AzureAD module..." 30 | 31 | $AadModule = Get-Module -Name "AzureAD" -ListAvailable 32 | if ($AadModule -eq $null) { 33 | write-verbose "AzureAD PowerShell module not found, looking for AzureADPreview" 34 | $AadModule = Get-Module -Name "AzureADPreview" -ListAvailable 35 | } 36 | 37 | if ($AadModule -eq $null) { 38 | write-output 39 | write-error "AzureAD Powershell module not installed..." 40 | write-output "Install by running 'Install-Module AzureAD' or 'Install-Module AzureADPreview' from an elevated PowerShell prompt" 41 | write-output "Script can't continue..." 42 | write-output 43 | return "" 44 | } 45 | # Getting path to ActiveDirectory Assemblies 46 | # If the module count is greater than 1 find the latest version 47 | 48 | if ($AadModule.count -gt 1) { 49 | write-verbose "multiple module versions" 50 | $Latest_Version = ($AadModule | select version | Sort-Object)[-1] 51 | $aadModule = $AadModule | ? { $_.version -eq $Latest_Version.version } 52 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 53 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 54 | } 55 | 56 | else { 57 | write-verbose "single module version" 58 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 59 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 60 | } 61 | 62 | Write-verbose "loading $adal and $adalforms" 63 | 64 | 65 | [System.Reflection.Assembly]::LoadFrom($adal) | Out-Null 66 | [System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null 67 | 68 | write-verbose "DLLs loaded" 69 | 70 | # $redirectUri = "urn:ietf:wg:oauth:2.0:oob" 71 | $resourceAppIdURI = "https://graph.microsoft.com" 72 | 73 | $authority = "https://login.microsoftonline.com/$Tenant" 74 | 75 | try { 76 | write-verbose "instantiating ADAL objects for $authority" 77 | $authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority 78 | 79 | write-verbose "client $ClientId $clientSecret" 80 | 81 | $clientCredential = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.ClientCredential" -ArgumentList ($ClientId,$ClientSecret) 82 | 83 | write-verbose "acquiring token for $resourceAppIdURI" 84 | # AuthenticationResult authResult = await authContext.AcquireTokenAsync(BatchResourceUri, new ClientCredential(ClientId, ClientKey)); 85 | # if you get an error about PowerShell not being able to find this method with 2 parameters, it means there is another version of ADAL DLL already in the process space of your PowerShell environment. 86 | 87 | $authResult = $authContext.AcquireTokenAsync($resourceAppIdURI, $clientCredential).Result 88 | # If the accesstoken is valid then create the authentication header 89 | if ($authResult.AccessToken) { 90 | write-verbose "acquired token" 91 | # Creating header for Authorization token 92 | $authHeader = @{ 93 | 'Content-Type' = 'application/json' 94 | 'Authorization' = "Bearer " + $authResult.AccessToken 95 | 'ExpiresOn' = $authResult.ExpiresOn 96 | } 97 | return $authHeader 98 | } 99 | else { 100 | write-output "" 101 | write-output "Authorization Access Token is null, please re-run authentication..." 102 | write-output "" 103 | break 104 | } 105 | } 106 | catch { 107 | write-output $_.Exception.Message 108 | write-output $_.Exception.ItemName 109 | write-output "" 110 | break 111 | } 112 | } 113 | 114 | function Connect-AzureADMSARSample { 115 | [CmdletBinding()] 116 | param( 117 | [Parameter(Mandatory=$true)] 118 | [ValidateScript({ 119 | try { 120 | [System.Guid]::Parse($_) | Out-Null 121 | $true 122 | } catch { 123 | throw "$_ is not a valid GUID" 124 | } 125 | })] 126 | [string]$ClientApplicationId, 127 | 128 | [Parameter(Mandatory=$true)] 129 | [string]$ClientSecret, # base64 client secret. Note this as a command line parameter is for testing purposes only 130 | 131 | [Parameter(Mandatory=$true)] 132 | [string]$TenantDomain # e.g., microsoft.onmicrosoft.com 133 | ) 134 | 135 | $script:_SampleInternalAuthNHeaders = @() 136 | 137 | 138 | $authHeaders = Get-GraphExampleAuthTokenServicePrincipal -ClientId $ClientApplicationId -ClientSecret $ClientSecret -TenantDomain $TenantDomain 139 | 140 | $script:_SampleInternalAuthNHeaders = $authHeaders 141 | 142 | } 143 | 144 | 145 | function Get-InternalAuthNHeaders { 146 | [CmdletBinding()] 147 | param() 148 | 149 | try { 150 | 151 | $authResult = $script:_SampleInternalAuthNHeaders 152 | if ($authResult.Length -eq @()) { 153 | Throw "Connect-AzureADMSARSample must be called first" 154 | } 155 | 156 | } catch { 157 | Throw # "Connect-AzureADMSControls must be called first" 158 | } 159 | return $authResult 160 | } 161 | 162 | #endregion 163 | 164 | #We define two arrays that we collect the external identities in that have either never logged on, or signed in a long time ago. 165 | $_guestsOutsideCutOff = @() 166 | $_guestsNeverSignedIn = @() 167 | 168 | 169 | <# 170 | .Synopsis 171 | Finds external identities (Guests) in your tenant and checks when they have last signed in to your tenant. 172 | 173 | .Description 174 | Finds external identities (Guests) in your tenant and checks when they have last signed in to your tenant. For external identities that have never signed in to your tenant or longer ago than 'staleDays', they are added as members to a newly created group. This group can then be used for an Access Review. 175 | 176 | .Parameter staleDays 177 | The number of days that external identities can not have signed in, without being found by the script. (Default 180) 178 | 179 | .Parameter createReviewGroups 180 | Indicates whether security groups will be automatically created in your tenant, that will contain the found users that have never or a long time ago signed into your tenant. (Default $false) 181 | 182 | .Parameter scheduleReviews 183 | Indicates whether Access Reviews are scheduled for the newly created groups that contain stale external identities. (Default $false) 184 | 185 | .Parameter JSONPath 186 | The literal (exact) path to a JSON file that describes how the Access Review must be created. 187 | 188 | .Example 189 | # Show a external identities that never signed in or have signed in more than 60 days ago on the console. 190 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders 60 191 | 192 | .Example 193 | # Find external identities that have never signed in or have signed in more than 120 days ago and put them into new security groups. Don't schedule Access Reviews. 194 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders 60 -createReviewGroups $true 195 | 196 | .Example 197 | # Find external identities that have never signed in or have signed in more than 120 days ago and put them into new security groups. Schedule Access Reviews - and find the definition for the Access Review in c:\AccessReviews\template.JSON. 198 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders 60 -createReviewGroups $true -scheduleReviews $true -JSONPath "C:\AccessReviews\template.JSON" 199 | #> 200 | function Find-AzureADStaleExternals($authHeaders, $staleDays=180, $createReviewGroups=$false, $scheduleReviews=$false, $JSONPath = "C:\temp\ARtemplate.json") 201 | { 202 | ##Make sure $staleDays is in sensible boundaries. 203 | if(($staleDays >180) -or ($staleDays -eq $null)) 204 | { $staleDays = 180 } 205 | else 206 | { $cutOffDate = (Get-Date (Get-Date).AddDays(-$staleDays) -Format s) + "Z" } 207 | 208 | ##This is the Graph Call for getting all external identities. 209 | $listURL = 'https://graph.microsoft.com/beta/users?$select=id,displayName,userprincipalname,userType,signInActivity&$filter=userType eq ''Guest''' 210 | 211 | $listResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $listURL -Method Get 212 | 213 | if ($listResponse -eq $null -or $listResponse.Content -eq $null) { 214 | throw "ERROR: We did not get a response from $listURL" 215 | } 216 | 217 | $listResult = ConvertFrom-Json $listResponse.Content 218 | $data = $listResult.Value 219 | 220 | #Let's check if Graph told us there are more results for us to fetch. If so, let's loop through the results until we have all. 221 | while($listResult.'@odata.nextLink') 222 | { 223 | $nextURL = $listResult.'@odata.nextLink' 224 | 225 | $listResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $nextURL -Method Get 226 | 227 | if ($listResponse -eq $null -or $listResponse.Content -eq $null) { 228 | # throw "ERROR: We did not get a response from $nextURL" 229 | } 230 | 231 | $listResult = ConvertFrom-Json $listResponse.Content 232 | $data += $listResult.Value 233 | } 234 | ##$data.count 235 | ##For every external identity that we found, let's loop through the list and check (a) if they do NOT have a lastSignInDateTime = they never signed in, (b) if the date is beyond the threshold for stale days. 236 | foreach($d in $data) 237 | { 238 | if(($d.signInActivity.lastSignInDateTime -eq $null) -or ($d.signInActivity.lastSignInDateTime -eq "")) 239 | { 240 | # add them to the array. 241 | $_guestsNeverSignedIn = $_guestsNeverSignedIn + $d.id 242 | } 243 | else 244 | { 245 | if($d.signInActivity.lastSignInDateTime -lt $cutOffDate) 246 | { 247 | ##add them to the array. 248 | $_guestsOutsideCutOff = $_guestsOutsideCutOff + $d.id 249 | } 250 | } 251 | #for debugging 252 | #Write-Host $d.id 253 | #Write-Host $d.signInActivity.lastSignInDateTime 254 | } 255 | 256 | ##If the caller wants us to create the review groups for them, we'll call the methods below. 257 | if($createReviewGroups) 258 | { 259 | $neverSignedInGroupObjectID = Add-NeverSignedInGroup $authHeaders 260 | $beyondCutOffDAysGroupObjectID = Add-BeyondCutOffDaysGroup $authHeaders $staleDays 261 | } 262 | else { 263 | Write-Host "External identities that have not logged on in the last $staleDays days: $($_guestsOutsideCutOff.Count)" 264 | Write-Host $_guestsOutsideCutOff 265 | Write-Host "---------------------------------" 266 | Write-Host "External identities that have never logged on in your tenant: $($_guestsNeverSignedIn.Count)" 267 | Write-Host $_guestsNeverSignedIn 268 | } 269 | Start-Sleep -Seconds 20 270 | 271 | #if the caller wants us to create the Access Reviews for them, we'll call the methods below. 272 | if($scheduleReviews) 273 | { 274 | if($_guestsNeverSignedIn.Count -gt 0) { $neverGroupCreated = Check-GroupHasMembers $authHeaders $neverSignedInGroupObjectID } 275 | if($_guestsOutsideCutOff.Count -gt 0) { $beyondGroupCreated = Check-GroupHasMembers $authHeaders $beyondCutOffDAysGroupObjectID} 276 | 277 | Start-Sleep -seconds 40 278 | 279 | if($_guestsNeverSignedIn.Count -gt 0 -and $neverGroupCreated) { Create-AzureADARScheduleDefinition $authHeaders $JSONPath $neverSignedInGroupObjectID "never" } 280 | if($_guestsOutsideCutOff.Count -gt 0 -and $beyondGroupCreated) { Create-AzureADARScheduleDefinition $authHeaders $JSONPath $beyondCutOffDAysGroupObjectID "beyond" } 281 | } 282 | } 283 | 284 | function Add-NeverSignedInGroup($authHeaders) 285 | { 286 | #Let's see if we even found external identities that never signed in 287 | if($_guestsNeverSignedIn.Count -gt 0) 288 | { 289 | #Set the name for the newly created group. We have a name and a date suffix: REVIEW_GUESTS_NEVER_SIGNED_IN_23-OCT-2020 290 | $groupNameNeverSignedIn = "REVIEW_GUESTS_NEVER_SIGNED_IN_$(Get-Date -Format 'dd-MMM-yyyy')" 291 | 292 | $createGroupURI = 'https://graph.microsoft.com/v1.0/groups' 293 | 294 | #We want to create a new group, so this will be a POST with the following group properties: security group that is not mail enabled 295 | $createGroupBody = "{""groupTypes"":[],""description"":""Automatically created group that contains external identities (aka Guests) that have never logged on."",""displayName"":""$groupNameNeverSignedIn"",""mailenabled"":false,""securityEnabled"":true,""mailNickName"":""$groupNameNeverSignedIn"",""members@odata.bind"": [" 296 | 297 | #we are adding all the members to the call body for Graph, so that we can commit the new group creation + all members in the same call. 298 | foreach($user in $_guestsNeverSignedIn) 299 | { 300 | $createGroupBody = $createGroupBody + """https://graph.microsoft.com/v1.0/users/$user""," 301 | } 302 | $createGroupBody = $createGroupBody.TrimEnd(",") 303 | $createGroupBody = $createGroupBody + "] }" 304 | #$createGroupBody = $createGroupBody | ConvertTo-Json 305 | 306 | #Create the group with its members. It's a POST this time. NOTE that the Service Principal needs Groups.Create and GroupMember.ReadWrite.All in the tenant. 307 | $createGroupResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $createGroupURI -Method Post -Body $createGroupBody -ContentType "application/json" 308 | if ($createGroupResponse -eq $null -or $createGroupResponse.Content -eq $null) { 309 | throw "ERROR: We did not get a response from $createGroupURI" 310 | } 311 | 312 | if($createGroupResponse.StatusCode -eq 201) 313 | { 314 | Write-Host "Created group with name $groupNameNeverSignedIn with $($_guestsNeverSignedIn.Count) members." 315 | $parsedJSON = ConvertFrom-Json $createGroupResponse.Content 316 | return $parsedJSON.ID 317 | } 318 | else { throw "We could not create the group."} 319 | } 320 | } 321 | 322 | function Check-GroupHasMembers($authHeaders, $groupObjectID) 323 | { 324 | $groupURL = "https://graph.microsoft.com/v1.0/groups/" + $groupObjectID + "/members" 325 | $groupResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $groupURL -Method Get 326 | 327 | $groupResult = ConvertFrom-Json $groupResponse.Content 328 | 329 | #Did we get a result? 330 | if ($groupResult -eq $null -and $groupResult.Content -eq $null) { 331 | throw "ERROR: We did not get a response from Graph, asking for the group, $groupURL" 332 | } 333 | #Qualifying the result. If the SID OR the onPremisesLastSyncDateTime are null or empty, we have reason to believe it's not an on-premises group. 334 | #We can abort then. 335 | if($groupResponse.Content.Count -gt 0) { return $true } 336 | else { return $false } 337 | } 338 | 339 | 340 | function Add-BeyondCutOffDaysGroup($authHeaders, $staleDays) 341 | { 342 | if($_guestsOutsideCutOff.Count -gt 0) 343 | { 344 | $groupNameOutsideCutOff = "REVIEW_GUESTS_NOT_SIGNED_IN_LAST_$($staleDays)_DAYS_$(Get-Date -Format 'dd-MMM-yyyy')" 345 | 346 | $createGroupURI2 = 'https://graph.microsoft.com/v1.0/groups' 347 | 348 | #We want to create a new group, so this will be a POST with the following group properties: security group that is not mail enabled 349 | $createGroupBody2 = "{""groupTypes"":[],""description"":""Automatically created group that contains external identities (aka Guests) that have never logged on."",""displayName"":""$groupNameOutsideCutOff"",""mailenabled"":false,""securityEnabled"":true,""mailNickName"":""$groupNameOutsideCutOff"",""members@odata.bind"": [" 350 | foreach($users in $_guestsOutsideCutOff) 351 | { 352 | $createGroupBody2 = $createGroupBody2 + """https://graph.microsoft.com/v1.0/users/$users""," 353 | } 354 | $createGroupBody2 = $createGroupBody2.TrimEnd(",") 355 | $createGroupBody2 = $createGroupBody2 + "] }" 356 | 357 | #Create the group with its members. It's a POST this time. NOTE that the Service Principal needs Groups.Create and GroupMember.ReadWrite.All in the tenant. 358 | $createGroupResponse2 = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Uri $createGroupURI2 -Method Post -Body $createGroupBody2 -ContentType "application/json" 359 | if ($createGroupResponse2 -eq $null -or $createGroupResponse2.Content -eq $null) { 360 | throw "ERROR: We did not get a response from $createGroupURI2" 361 | } 362 | 363 | if($createGroupResponse2.StatusCode -eq 201) 364 | { 365 | Write-Host "Created group with name $groupNameOutsideCutOff with $($_guestsOutsideCutOff.Count) members." 366 | $parsedJSON = ConvertFrom-Json $createGroupResponse2.Content 367 | return $parsedJSON.ID 368 | } 369 | else { throw "We could not create the group."} 370 | } 371 | } 372 | 373 | function Create-AzureADARScheduleDefinition($authHeaders, $JSONPath, $groupObjectID, $groupType) 374 | { 375 | #The JSON Path points us to a text file that has JSON-formatted content. It outlines a template to create an Access Review. 376 | #If we can't find a file in the path we were given, let's throw an error. We expect a file there and it should be JSON. 377 | if(-not $(Test-Path -LiteralPath $JSONPath -PathType Leaf)) 378 | { 379 | throw "ERROR: File $($JSONPath) does not exist or cannot be found. Please enter a valid path to a JSON-formatted file, such as 'C:\temp\ARSamples\create-access-review.JSON'" 380 | } 381 | 382 | #Let's see if the file contents is JSON formatted. If it's not, let's throw an error and stop. 383 | $createJSON = Get-Content $JSONPath 384 | #depending on which group we're creating this review for, we want to replace variables in the template with sensible description(s) 385 | switch ($groupType) 386 | { 387 | "never" 388 | { 389 | $createJSON = $createJSON.Replace("<>", "Review of external identities that have never signed in.") 390 | $createJSON = $createJSON.Replace("<>", "This review was automatically generated by a script. It reviews an also auto-created security group that contains external identities (guests) that have never logged on to your tenant.") 391 | $createJSON = $createJSON.Replace("<>", "Please review your continued need to access this tenant.") 392 | } 393 | "beyond" 394 | { 395 | $createJSON = $createJSON.Replace("<>", "Review of external identities that have not signed in a long time") 396 | $createJSON = $createJSON.Replace("<>", "This review was automatically generated by a script. It reviews an also auto-created security group that contains external identities (guests) that have not logged on to your tenant for a long time.") 397 | $createJSON = $createJSON.Replace("<>", "Please review your continued need to access this tenant.") 398 | } 399 | } 400 | 401 | ##replace start and end dates for the review. We do a 30-day review. 402 | $startDate = Get-Date -format "yyyy-MM-dd" 403 | $endDate = (Get-Date).AddDays(30).ToString("yyyy-MM-dd") 404 | $createJSON = $createJSON.Replace("<>", $startDate) 405 | $createJSON = $createJSON.Replace("<>", $endDate) 406 | 407 | ##fill in the objectID of the group we just created. We want to review that group. 408 | $createJSON = $createJSON.Replace("<>", $groupObjectID) 409 | 410 | $createURL = 'https://graph.microsoft.com/beta/identityGovernance/accessReviews/definitions' 411 | 412 | $createResponse = Invoke-WebRequest -UseBasicParsing -headers $authHeaders -Body $createJSON -Uri $createURL -Method POST 413 | 414 | if ($createResponse -eq $null -or $createResponse.Content -eq $null) { 415 | throw "ERROR: We did not get a response from $createURL" 416 | } 417 | 418 | if($createResponse.StatusCode -eq "201") 419 | { 420 | $data = ConvertFrom-JSON $createResponse 421 | Write-Host "Access Review $($data.ID) created. It is currently in status $($data.status)" 422 | } 423 | else 424 | { 425 | throw "ERROR: Could not create new Access Review schedule definition" 426 | } 427 | 428 | } 429 | 430 | Connect-AzureADMSARSample -ClientApplicationId "ABCD" -ClientSecret "DEFG" -TenantDomain ".onmicrosoft.com" 431 | Find-AzureADStaleExternals $_SampleInternalAuthNHeaders -staleDays 60 -createReviewGroups $true -scheduleReviews $true -JSONPath "C:\temp\ARtemplate.json" 432 | -------------------------------------------------------------------------------- /ReviewStaleExternals/SECURITY.MD: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /ReviewStaleExternals/screenshots/StaleIDs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/access-reviews-samples/dd300b2d3b9035c46fb6abb9ba6945a3320efc07/ReviewStaleExternals/screenshots/StaleIDs.png -------------------------------------------------------------------------------- /ReviewStaleExternals/screenshots/appPermissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/access-reviews-samples/dd300b2d3b9035c46fb6abb9ba6945a3320efc07/ReviewStaleExternals/screenshots/appPermissions.png -------------------------------------------------------------------------------- /ReviewStaleExternals/screenshots/disable-and-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/access-reviews-samples/dd300b2d3b9035c46fb6abb9ba6945a3320efc07/ReviewStaleExternals/screenshots/disable-and-delete.png -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | --------------------------------------------------------------------------------