├── .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 | 
38 |
39 | The HTM output this script generates outlines found external identities in the tenant and their use:
40 |
41 | 
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
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.
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.
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.
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.
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.
"
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
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.
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------