PowerShell Setup: Control 2.8 — Access Control and Segregation of Duties
Read the FSI PowerShell baseline first
Before running any command in this playbook, read the PowerShell Authoring Baseline for FSI Implementations. It is the canonical source for module version pinning, sovereign-cloud (GCC / GCC High / DoD) endpoints, mutation safety (-WhatIf / SupportsShouldProcess), Dataverse compatibility, and SHA-256 evidence emission. Snippets below show abbreviated patterns; the baseline is authoritative.
Last Updated: April 2026
Modules Required: Microsoft.Graph (v2.x, pinned), Microsoft.PowerApps.Administration.PowerShell (Windows PowerShell 5.1 only)
Audience: M365 administrators in US financial services
Mutation safety: All scripts below support -WhatIf via SupportsShouldProcess; run with -WhatIf first in regulated tenants.
Prerequisites
# Pin to the version approved by your CAB (replace <version>):
Install-Module -Name Microsoft.Graph `
-RequiredVersion '<version>' -Repository PSGallery `
-Scope CurrentUser -AllowClobber -AcceptLicense
# Power Apps Administration is Windows PowerShell 5.1 (Desktop) only:
if ($PSVersionTable.PSEdition -ne 'Desktop') {
Write-Warning "Run Power Apps Administration cmdlets from Windows PowerShell 5.1."
}
Install-Module -Name Microsoft.PowerApps.Administration.PowerShell `
-RequiredVersion '<version>' -Scope CurrentUser -AllowClobber
Required Graph scopes (delegated):
| Script | Scopes |
|---|---|
| Group creation | Group.ReadWrite.All, Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory |
| Membership / SoD audit | Group.Read.All, User.Read.All, RoleManagement.Read.Directory |
| PIM eligibility audit | RoleManagementPolicy.Read.Directory, PrivilegedAccess.Read.AzureADGroup |
Sovereign cloud note: GCC / GCC High / DoD tenants must pass
-Environment USGov/USGovHigh/USGovDoDtoConnect-MgGraphand the equivalent endpoint toAdd-PowerAppsAccount. See the baseline.
Script 1 — Create role-assignable security groups
<#
.SYNOPSIS
Creates the five SG-Agent-* security groups for Control 2.8.
Marks Approvers / Release Managers / Platform Admins as role-assignable.
.NOTES
Run with -WhatIf first. Idempotent: skips groups that already exist.
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[string]$EvidencePath = ".\evidence\2.8"
)
Connect-MgGraph -Scopes "Group.ReadWrite.All","Directory.ReadWrite.All","RoleManagement.ReadWrite.Directory" -NoWelcome
$null = New-Item -ItemType Directory -Path $EvidencePath -Force
$groups = @(
@{ DisplayName = "SG-Agent-Developers"; MailNickname = "sg-agent-developers"; RoleAssignable = $false; Description = "Copilot Studio authoring identities (makers)" }
@{ DisplayName = "SG-Agent-Reviewers"; MailNickname = "sg-agent-reviewers"; RoleAssignable = $false; Description = "Independent reviewers of agent submissions" }
@{ DisplayName = "SG-Agent-Approvers"; MailNickname = "sg-agent-approvers"; RoleAssignable = $true; Description = "PIM-gated approvers for production publish" }
@{ DisplayName = "SG-Agent-ReleaseManagers"; MailNickname = "sg-agent-releasemgrs"; RoleAssignable = $true; Description = "PIM-gated release managers (Zone 3 deploy)" }
@{ DisplayName = "SG-Agent-PlatformAdmins"; MailNickname = "sg-agent-platformadmins"; RoleAssignable = $true; Description = "PIM-eligible Power Platform / Environment admins" }
)
$report = foreach ($g in $groups) {
$existing = Get-MgGroup -Filter "displayName eq '$($g.DisplayName)'" -ErrorAction SilentlyContinue
if ($existing) {
[PSCustomObject]@{ Group = $g.DisplayName; Action = "Exists"; Id = $existing.Id; RoleAssignable = $existing.IsAssignableToRole }
}
elseif ($PSCmdlet.ShouldProcess($g.DisplayName, "Create security group (RoleAssignable=$($g.RoleAssignable))")) {
$params = @{
DisplayName = $g.DisplayName
Description = $g.Description
MailNickname = $g.MailNickname
MailEnabled = $false
SecurityEnabled = $true
IsAssignableToRole = $g.RoleAssignable
}
$new = New-MgGroup @params
[PSCustomObject]@{ Group = $g.DisplayName; Action = "Created"; Id = $new.Id; RoleAssignable = $new.IsAssignableToRole }
}
}
$reportPath = Join-Path $EvidencePath "groups-$(Get-Date -Format 'yyyyMMdd-HHmm').csv"
$report | Export-Csv -Path $reportPath -NoTypeInformation
# SHA-256 evidence hash (per baseline)
$hash = (Get-FileHash -Path $reportPath -Algorithm SHA256).Hash
"$reportPath,$hash" | Out-File (Join-Path $EvidencePath "evidence-hashes.txt") -Append
Disconnect-MgGraph
Script 2 — Segregation-of-Duties detector
Detects identities that hold conflicting role pairs. Output is the canonical evidence artefact for examiners.
<#
.SYNOPSIS
Detects SoD violations across the five agent governance groups
AND across PIM-eligible / Active assignments to high-privileged roles.
.OUTPUTS
CSV: SoD-Violations-yyyyMMdd-HHmm.csv with one row per (User, ConflictPair).
.EXAMPLE
.\Test-Control28-SoD.ps1 -EvidencePath .\evidence\2.8
#>
[CmdletBinding()]
param(
[string]$EvidencePath = ".\evidence\2.8",
[switch]$IncludePIMEligible
)
Connect-MgGraph -Scopes "Group.Read.All","User.Read.All","RoleManagement.Read.Directory" -NoWelcome
$null = New-Item -ItemType Directory -Path $EvidencePath -Force
# Conflict matrix — anchored to NIST AC-5 / SOX 404 SoD principles
$conflictPairs = @(
@{ A = "SG-Agent-Developers"; B = "SG-Agent-Approvers"; Rule = "Maker cannot approve own work (FINRA 3110, SOX 404)" }
@{ A = "SG-Agent-Developers"; B = "SG-Agent-ReleaseManagers"; Rule = "Maker cannot deploy to production (SOX 404)" }
@{ A = "SG-Agent-Approvers"; B = "SG-Agent-ReleaseManagers"; Rule = "Approver cannot self-deploy (NIST AC-5)" }
@{ A = "SG-Agent-Reviewers"; B = "SG-Agent-Approvers"; Rule = "Reviewer should not also approve (independent review)" }
@{ A = "SG-Agent-Developers"; B = "SG-Agent-PlatformAdmins"; Rule = "Maker cannot hold platform admin (Copilot Studio author/admin separation)" }
)
function Get-GroupMemberSet {
param([string]$Name)
$g = Get-MgGroup -Filter "displayName eq '$Name'" -ErrorAction SilentlyContinue
if (-not $g) { return @{} }
$members = Get-MgGroupTransitiveMember -GroupId $g.Id -All -ErrorAction SilentlyContinue
$set = @{}
foreach ($m in $members) {
if ($m.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.user') {
$set[$m.Id] = $m.AdditionalProperties.userPrincipalName
}
}
return $set
}
$violations = New-Object System.Collections.Generic.List[object]
foreach ($p in $conflictPairs) {
$setA = Get-GroupMemberSet $p.A
$setB = Get-GroupMemberSet $p.B
$overlap = $setA.Keys | Where-Object { $setB.ContainsKey($_) }
foreach ($id in $overlap) {
$violations.Add([PSCustomObject]@{
UserId = $id
UserPrincipalName = $setA[$id]
GroupA = $p.A
GroupB = $p.B
Rule = $p.Rule
DetectedAtUtc = (Get-Date).ToUniversalTime().ToString("o")
})
}
}
# Optional: cross-check directory-role overlaps (Maker vs Power Platform Admin via PIM)
if ($IncludePIMEligible) {
$ppaRole = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq 'Power Platform Administrator'"
if ($ppaRole) {
$eligible = Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "roleDefinitionId eq '$($ppaRole.Id)'" -All
$devSet = Get-GroupMemberSet "SG-Agent-Developers"
foreach ($e in $eligible) {
if ($devSet.ContainsKey($e.PrincipalId)) {
$violations.Add([PSCustomObject]@{
UserId = $e.PrincipalId
UserPrincipalName = $devSet[$e.PrincipalId]
GroupA = "SG-Agent-Developers"
GroupB = "Power Platform Administrator (PIM-eligible)"
Rule = "Maker cannot be eligible for Power Platform Admin (Zone 3)"
DetectedAtUtc = (Get-Date).ToUniversalTime().ToString("o")
})
}
}
}
}
$ts = Get-Date -Format 'yyyyMMdd-HHmm'
$out = Join-Path $EvidencePath "SoD-Violations-$ts.csv"
$violations | Export-Csv -Path $out -NoTypeInformation
if ($violations.Count -eq 0) {
Write-Host "[PASS] No SoD violations detected." -ForegroundColor Green
} else {
Write-Host "[FAIL] $($violations.Count) SoD violation(s) detected. See $out" -ForegroundColor Red
}
# Evidence hash
$hash = (Get-FileHash -Path $out -Algorithm SHA256).Hash
"$out,$hash" | Out-File (Join-Path $EvidencePath "evidence-hashes.txt") -Append
Disconnect-MgGraph
exit ([int]($violations.Count -gt 0))
The exit code is non-zero when violations exist — wire this script into your monthly governance pipeline (Azure DevOps, GitHub Actions, or Power Automate Desktop) and treat a failed run as a Sev-2 control incident.
Script 3 — Membership and PIM eligibility export
<#
.SYNOPSIS
Exports (a) members of each SG-Agent-* group and (b) PIM-eligible
members of high-privileged directory roles. Used as quarterly
access-review evidence.
#>
[CmdletBinding()]
param([string]$EvidencePath = ".\evidence\2.8")
Connect-MgGraph -Scopes "Group.Read.All","User.Read.All","RoleManagement.Read.Directory" -NoWelcome
$null = New-Item -ItemType Directory -Path $EvidencePath -Force
$ts = Get-Date -Format 'yyyyMMdd-HHmm'
# (a) Group memberships
$groupNames = "SG-Agent-Developers","SG-Agent-Reviewers","SG-Agent-Approvers","SG-Agent-ReleaseManagers","SG-Agent-PlatformAdmins"
$rows = foreach ($name in $groupNames) {
$g = Get-MgGroup -Filter "displayName eq '$name'" -ErrorAction SilentlyContinue
if (-not $g) { continue }
Get-MgGroupTransitiveMember -GroupId $g.Id -All | ForEach-Object {
[PSCustomObject]@{
Group = $name
UserPrincipalName = $_.AdditionalProperties.userPrincipalName
DisplayName = $_.AdditionalProperties.displayName
JobTitle = $_.AdditionalProperties.jobTitle
AccountEnabled = $_.AdditionalProperties.accountEnabled
}
}
}
$rows | Export-Csv (Join-Path $EvidencePath "Membership-$ts.csv") -NoTypeInformation
# (b) PIM-eligible holders of priority roles
$priorityRoles = "Power Platform Administrator","AI Administrator","Global Administrator","Privileged Role Administrator"
$pim = foreach ($rn in $priorityRoles) {
$rd = Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq '$rn'" -ErrorAction SilentlyContinue
if (-not $rd) { continue }
$assigns = Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance `
-Filter "roleDefinitionId eq '$($rd.Id)'" -All
foreach ($a in $assigns) {
$u = Get-MgUser -UserId $a.PrincipalId -ErrorAction SilentlyContinue
[PSCustomObject]@{
Role = $rn
UserPrincipalName = $u.UserPrincipalName
AssignmentType = "Eligible"
EndDateTime = $a.EndDateTime
}
}
}
$pim | Export-Csv (Join-Path $EvidencePath "PIM-Eligible-$ts.csv") -NoTypeInformation
Disconnect-MgGraph
Script 4 — Dataverse System Administrator audit (Zone 3)
Dataverse System Administrator is outside the Entra group model, so this check uses the Power Platform Admin module (Windows PowerShell 5.1).
<#
.SYNOPSIS
Lists every identity holding the Dataverse System Administrator
role across all production-aligned environments.
.NOTES
Requires Microsoft.PowerApps.Administration.PowerShell on PS 5.1.
#>
if ($PSVersionTable.PSEdition -ne 'Desktop') {
throw "Run from Windows PowerShell 5.1 (Desktop edition)."
}
Add-PowerAppsAccount # add -Endpoint usgov / usgovhigh / dod for sovereign clouds
$envs = Get-AdminPowerAppEnvironment | Where-Object { $_.EnvironmentType -in 'Production','Sandbox' }
$rows = foreach ($e in $envs) {
$roles = Get-AdminPowerAppEnvironmentRoleAssignment -EnvironmentName $e.EnvironmentName -ErrorAction SilentlyContinue
foreach ($r in $roles | Where-Object { $_.RoleName -eq 'EnvironmentAdmin' -or $_.RoleName -eq 'SystemAdministrator' }) {
[PSCustomObject]@{
Environment = $e.DisplayName
EnvironmentId = $e.EnvironmentName
EnvironmentType = $e.EnvironmentType
Role = $r.RoleName
PrincipalType = $r.PrincipalType
PrincipalEmail = $r.PrincipalEmail
}
}
}
$rows | Export-Csv ".\evidence\2.8\Dataverse-SysAdmins-$(Get-Date -Format 'yyyyMMdd-HHmm').csv" -NoTypeInformation
Any standing (non-PIM) System Administrator in a Zone 3 environment must be either (a) an approved break-glass identity in your runbook, or (b) treated as a Sev-2 SoD finding.
Validation Script (Composite)
<#
.SYNOPSIS
Composite validation for Control 2.8. Returns 0 on full pass.
.EXAMPLE
.\Validate-Control-2.8.ps1 -EvidencePath .\evidence\2.8
#>
param([string]$EvidencePath = ".\evidence\2.8")
$results = @()
# 1. Groups exist with correct role-assignable flag
Connect-MgGraph -Scopes "Group.Read.All" -NoWelcome
$expected = @{
"SG-Agent-Developers" = $false
"SG-Agent-Reviewers" = $false
"SG-Agent-Approvers" = $true
"SG-Agent-ReleaseManagers" = $true
"SG-Agent-PlatformAdmins" = $true
}
foreach ($name in $expected.Keys) {
$g = Get-MgGroup -Filter "displayName eq '$name'" -ErrorAction SilentlyContinue
if (-not $g) {
$results += [PSCustomObject]@{ Check = "Group $name exists"; Result = "FAIL"; Detail = "Not found" }
} elseif ($g.IsAssignableToRole -ne $expected[$name]) {
$results += [PSCustomObject]@{ Check = "Group $name role-assignable"; Result = "FAIL"; Detail = "Expected $($expected[$name]), found $($g.IsAssignableToRole)" }
} else {
$results += [PSCustomObject]@{ Check = "Group $name OK"; Result = "PASS"; Detail = "" }
}
}
# 2. Run SoD detector (exit code 0 = pass)
& (Join-Path $PSScriptRoot 'Test-Control28-SoD.ps1') -EvidencePath $EvidencePath -IncludePIMEligible | Out-Null
$results += [PSCustomObject]@{ Check = "SoD detector"; Result = ($(if ($LASTEXITCODE -eq 0){"PASS"}else{"FAIL"})); Detail = "Exit $LASTEXITCODE" }
# 3. Reminder-only checks (manual verification)
$results += [PSCustomObject]@{ Check = "Access Reviews scheduled"; Result = "MANUAL"; Detail = "Verify in Entra Identity Governance portal" }
$results += [PSCustomObject]@{ Check = "PIM for Groups onboarded"; Result = "MANUAL"; Detail = "Verify in PIM → Groups blade" }
$results | Format-Table -AutoSize
$failCount = ($results | Where-Object Result -eq 'FAIL').Count
exit $failCount
Scheduling
| Cadence | Script | Owner |
|---|---|---|
| At change window | Script 1 (group create) | Entra Privileged Role Admin |
| Daily | Script 2 (SoD detector, -IncludePIMEligible) |
AI Governance Lead (automated pipeline) |
| Quarterly | Script 3 (membership + PIM export) | Compliance Officer |
| Quarterly | Script 4 (Dataverse SysAdmin audit) | Power Platform Admin |
| Per release | Validation Script | Release Manager |
All emitted CSVs should be appended to evidence-hashes.txt (SHA-256) and stored in your immutable evidence repository per the WORM expectations of FINRA 4511 / SEC 17a-4.
Back to Control 2.8 | Portal Walkthrough | Verification & Testing | Troubleshooting