Control 1.11 — PowerShell Setup: Conditional Access & Phishing-Resistant MFA for AI Agents
Control: 1.11 Conditional Access and Phishing-Resistant MFA
Sister playbooks: Portal Walkthrough · Verification & Testing · Troubleshooting
Inherits: _shared/powershell-baseline.md — module pinning, sovereign cloud endpoints, mutation safety, evidence emission
PowerShell: 7.4+ (Core)
Hedged-language disclaimer. The PowerShell helpers in this playbook support compliance with the regulatory references listed in Control 1.11. They do not by themselves constitute compliance, do not guarantee any control outcome, and do not substitute for the written supervisory and validation procedures required under FINRA Rule 3110, OCC Bulletin 2011-12 / Federal Reserve SR 11-7, SEC Regulation S-P (May 2024 amendments), GLBA / FTC Safeguards Rule 16 CFR §314.4, NYDFS 23 NYCRR 500 §500.12, or NIST SP 800-63B. Implementation requires legal, compliance, and information-security review tied to your firm''s written policies. Organizations should verify each helper''s output against tenant-specific exceptions before promoting any policy from
enabledForReportingButNotEnforcedtoenabled.
PowerShell Does Not Substitute for Human Review
This automation is an operator aid, not a control. The Authentication Policy Admin or Entra Global Admin who runs these helpers remains accountable for:
- Privileged human verification — every promoted Conditional Access policy must be reviewed by a named approver under your firm''s change-management process. Sign-off must be captured outside of this script (ticketing system, GRC platform, signed PDF) before
enabled = trueis committed. - Supervisory review per Control 2.12 — FINRA Rule 3110 requires supervisory procedures that are reasonably designed; the supervisor''s judgement, not the helper''s
Status = Clean, is the supervisory record. - Model-risk re-validation per Control 2.6 — material changes to the authentication boundary surrounding an AI agent are an MRM-relevant change. Re-validate per OCC 2011-12 / SR 11-7 §V before enforcement.
- Break-glass attestation — the helpers verify that two break-glass accounts are excluded from every policy. They do not verify that those accounts'' credentials are escrowed, tested quarterly, or roster-attested. That is a Control 2.8 and SOX 404 obligation.
License Dependency — Conditional Access for Workload Identities
Conditional Access policies that target service principals, managed identities, or Entra Agent ID principals require the Microsoft Entra Workload ID Premium add-on SKU (priced per workload identity per month). Microsoft Entra ID P1 and P2 alone are not sufficient. Section §5.3 will refuse to deploy a workload-identity policy if the SKU is absent. Confirm SKU availability with your Microsoft account team before running enforcement mode in GCC High or DoD tenants.
Token Protection Is Public Preview, Windows + Browser Specific
Token Protection for sign-in tokens is in Public Preview as of April 2026. It is currently effective only on Windows 10/11 with Microsoft Edge or Chrome via WAM (Web Account Manager). On macOS, iOS, Android, Linux, or unsupported browsers the policy falls through — the user is still challenged for MFA but the token is not cryptographically bound to the device. Do not rely on Token Protection as a sole compensating control for token-theft risk on heterogeneous fleets. Treat the helper §5.4 as a pilot deployment, not a global enforcement.
Authentication Methods Policy Migration — Pre-flight Required
Microsoft retired the legacy MFA / SSPR policy surface in September 2025. Phishing-resistant Conditional Access grants will not evaluate as expected unless your tenant''s Authentication Methods Policy reports policyMigrationState = migrationComplete. The bootstrap in §2 calls Test-Fsi-AuthMethodsMigrationState and throws if the migration is incomplete.
Sign-in Log Tables Are Distinct
SigninLogs (interactive and non-interactive user sign-ins) and AADServicePrincipalSignInLogs (workload identity sign-ins) are separate Log Analytics tables. Workload identity / agent sign-ins do not appear in SigninLogs. The Sentinel-wiring helper in §8 checks both tables; review the cross-reference to Control 3.9 Microsoft Sentinel Integration before assuming agent-identity coverage.
§0 — Read This First: Wrong-Shell Trap & False-Clean Defect Catalogue
0.1 Wrong-Shell Trap
The Microsoft Graph PowerShell SDK and the Microsoft Graph Beta SDK install side-by-side but resolve cmdlet names by module load order. If a stale Windows PowerShell 5.1 host is open in another tab — or the Graph v1.0 modules were imported before Graph Beta — the CA-for-Workload-Identities cmdlets in §5.3 silently bind to the v1.0 version (which omits the conditions.clientApplications block) and produce a policy that never matches a workload identity. This is the leading cause of false-clean reports in this control.
Pre-flight:
$PSVersionTable.PSEdition # MUST be 'Core'
$PSVersionTable.PSVersion # MUST be >= 7.4
Get-Module Microsoft.Graph* | Sort-Object Name | Format-Table Name, Version
If any v1.0 SignIns module is loaded before its Beta counterpart, close the host and start a fresh PowerShell 7 session. The bootstrap in §2.1 enforces this with Assert-Fsi-ShellHost, which throws rather than warns.
0.2 False-Clean Defect Catalogue
| # | Defect | Symptom | Helper that detects it | Mitigation |
|---|---|---|---|---|
| 1 | Mixed v1.0 + Beta Graph modules loaded; CA WID cmdlet binds to v1.0 stub | New-MgIdentityConditionalAccessPolicy succeeds; policy created without clientApplications block; report-only metrics show zero workload identity sign-ins blocked even under attack simulation |
Assert-Fsi-ShellHost (§2.1) |
Throw on mixed modules; require -Force to bypass; record in evidence pack |
| 2 | Authentication Methods Policy migration incomplete | Phishing-resistant grant evaluates as legacy MFA; FIDO2-only users see push prompts; SAW devices challenge for password | Test-Fsi-AuthMethodsMigrationState (§2.4) |
Throw with link to migration doc; do not deploy phishing-resistant CA grant until migrationComplete |
| 3 | Workload Identities Premium SKU absent in tenant | CA WID policy creation returns HTTP 403 with LicenseRequired; or — worse — succeeds in some regions and silently no-ops |
Test-Fsi-WorkloadIdentitiesPremiumSku (§5.3) |
Helper refuses to deploy; emits Status = NotApplicable with Reason naming the SKU |
| 4 | Break-glass account excluded from policy A but not from policy B (operator drift) | Quarterly break-glass test fails when the missed-policy is the only one blocking break-glass IP range; firm''s incident-response RTO is breached | Test-Fsi-BreakGlassExclusions (§7.3) |
Iterates every CA policy; emits Status = Anomaly with policy IDs missing the exclusion |
| 5 | Managed identities and system-assigned SPs assumed to be in scope of Workload Identity CA | Operator believes 100% workload identity coverage; Microsoft excludes managed identities by design from Workload Identity CA scope | Get-Fsi-WorkloadIdentityInventory (§3.4) |
Returns explicit InScope and ExcludedByDesign properties so operators see the gap |
| 6 | SigninLogs queried for agent / workload identity activity |
Workspace shows zero agent sign-ins despite active agent traffic; gap report ships clean | Invoke-Fsi-CAInsightsWorkbookCheck (§8) |
Checks both SigninLogs and AADServicePrincipalSignInLogs; emits Anomaly if either table is missing |
| 7 | Helper returns $null or @() when no data found, and caller treats absence as success |
Inventory helper called against an empty tenant returns $null; orchestrator''s if ($result) short-circuits to "Clean" |
All exported helpers in this playbook | Non-negotiable return contract: every helper returns [pscustomobject] with Status ∈ Clean / Anomaly / Pending / NotApplicable / Error and a non-empty Reason. Never $null. Never @(). |
| 8 | Token Protection policy deployed to macOS / mobile users; assumed to bind tokens | Tenant believes token-theft risk is mitigated; macOS / iOS users still vulnerable to AiTM token replay | New-Fsi-CAPolicy-SessionControls (§5.4) |
Forces a -PilotGroupOnly parameter and emits a per-platform fallback warning |
| 9 | Synced (cross-device) passkeys treated as AAL3 | Auditor report claims AAL3 coverage; NIST SP 800-63B requires device-bound credential at AAL3 | New-Fsi-PhishingResistantAuthStrength (§4) |
Strength definition includes passkeys (FIDO2) with the attestation = required and explicit deviceBound flag where supported |
| 10 | Report-only metrics interpreted before 7 full days of evaluation | Operator promotes a policy to enabled after 24 hours; legacy clients break in production |
Invoke-Fsi-CAReportOnlyReview (§6) |
Refuses to recommend promotion if lookbackDays < 7 or dailySignInVolume < 100 per policy |
Return-contract reminder. Every public helper in §3-§9 returns
[pscustomobject]with at minimum:Status,Reason,ControlId = ''1.11'',HelperName,TimestampUtc, plus helper-specific data fields. The orchestrator at §9 refuses to write the evidence manifest if any helper returns$null, an empty array, or omits a required field. See_shared/powershell-baseline.md§5.
§1 — Prerequisites
1.1 Module Pinning
This control depends on five modules. Pin the version range — do not Install-Module ... -Force without a pinned -RequiredVersion. The Beta Identity SignIns module is mandatory for Conditional Access for Workload Identities (the v1.0 surface omits the clientApplications block).
#Requires -Version 7.4 -PSEdition Core
$ModuleSpec = @(
@{ Name = ''Microsoft.Graph.Authentication''; MinimumVersion = ''2.25.0'' }
@{ Name = ''Microsoft.Graph.Identity.SignIns''; MinimumVersion = ''2.25.0'' }
@{ Name = ''Microsoft.Graph.Identity.DirectoryManagement''; MinimumVersion = ''2.25.0'' }
@{ Name = ''Microsoft.Graph.Applications''; MinimumVersion = ''2.25.0'' }
@{ Name = ''Microsoft.Graph.Beta.Identity.SignIns''; MinimumVersion = ''2.25.0'' }
@{ Name = ''Microsoft.PowerApps.Administration.PowerShell''; MinimumVersion = ''2.0.196'' }
)
foreach ($m in $ModuleSpec) {
if (-not (Get-Module -ListAvailable -Name $m.Name | Where-Object { $_.Version -ge [version]$m.MinimumVersion })) {
Install-Module -Name $m.Name -MinimumVersion $m.MinimumVersion -Scope CurrentUser -Repository PSGallery -Force -AllowClobber
}
Import-Module -Name $m.Name -MinimumVersion $m.MinimumVersion -ErrorAction Stop
}
See
_shared/powershell-baseline.md§1 for the canonical install pattern, including the-AllowPrereleaserule (do not install Graph prereleases for production CA management — pin to GA).
1.2 Graph Scope Matrix
The bootstrap helper Connect-Fsi-Graph111 requests the least-privilege scope set required for read-only inventory. Mutation scopes are requested only when the orchestrator runs in Mode = ''Enforce''.
| Scope | Required For | Helper Sections | Mutation? |
|---|---|---|---|
Policy.Read.All |
Read existing CA policies and Authentication Methods Policy | §3.1, §3.2, §6, §7 | No |
Policy.ReadWrite.ConditionalAccess |
Create / update CA policies and Authentication Strengths | §4, §5.1-§5.5 | Yes |
Application.Read.All |
Enumerate service principals, managed identities, and Entra Agent ID registrations | §3.4 | No |
Directory.Read.All |
Resolve role assignments, group memberships, break-glass accounts | §3.3, §7.3 | No |
AuditLog.Read.All |
Read sign-in logs (interactive, non-interactive, service-principal) for report-only analytics | §6, §8 | No |
RoleManagement.Read.Directory |
Enumerate active and PIM-eligible role assignments for §3.3 | §3.3 | No |
1.3 RBAC Matrix (Canonical Roles)
Use the canonical role names in docs/reference/role-catalog.md. Microsoft built-in role display names are listed in parentheses for cross-reference.
| Helper Section | Read-Only Mode | Enforce Mode |
|---|---|---|
| §3 Inventory (read CA, methods, roles, workload identities) | Entra Global Reader (Global Reader) or Entra Security Reader | n/a |
| §4 Authentication Strength deploy | Authentication Policy Admin (Authentication Policy Administrator) | Authentication Policy Admin |
| §5.1 Human-Privileged MFA policy | Read: Entra Global Reader | Entra Global Admin activated via PIM (per Control 2.8) |
| §5.2 Maker Compliant-Device policy | Read: Entra Global Reader | Entra Global Admin via PIM |
| §5.3 Workload-Identity policy | Read: Entra Security Reader | Entra Global Admin via PIM + Workload ID Premium SKU present |
| §5.4 Session controls + Token Protection (pilot) | Read: Entra Global Reader | Entra Global Admin via PIM |
| §5.5 Break-glass exclusion assertion | Read: Entra Global Reader | Entra Global Admin via PIM |
| §6 Report-only analytics | Entra Security Reader + Log Analytics Reader on the workspace | n/a |
| §7 Verification helpers | Entra Global Reader | n/a |
| §8 Sentinel wiring | Microsoft Sentinel Reader + Log Analytics Reader | Microsoft Sentinel Contributor for table validation only |
| §9 Orchestrator | All of the above per -Mode |
All of the above per -Mode |
Why PIM activation is required for Entra Global Admin. Standing Global Admin membership is a Control 2.8 violation. The orchestrator at §9 calls
Test-Fsi-PimActivationand refuses to run inMode = ''Enforce''from a standing Global Admin context — this is enforced both for compliance with SOX 404 access-control expectations and to keep evidence packs free of permanent-privilege footprints.
1.4 Preview-Feature Gating
| Feature | Status (April 2026) | Behavioural Impact | Helper that Gates it |
|---|---|---|---|
| Microsoft Entra Agent ID | Public Preview (Frontier) | Agent identities visible in registry; CA targeting via custom security attributes | Get-Fsi-WorkloadIdentityInventory (§3.4) |
| Token Protection (sign-in tokens) | Public Preview, Windows + Edge/Chrome WAM only | Falls through to MFA-only on macOS/iOS/Android/Linux/legacy browsers | New-Fsi-CAPolicy-SessionControls (§5.4) — requires -PilotGroupOnly |
| CAE Strict Enforcement | GA (since November 2024) | Breaks legacy clients that do not honour token revocation events | New-Fsi-CAPolicy-SessionControls |
Workload Identity CA — clientApplications filter |
GA (Beta Graph endpoint) | Only available via Graph Beta module | New-Fsi-CAPolicy-WorkloadIdentity |
| Phishing-resistant Authentication Strength (built-in) | GA | Includes FIDO2, passkeys (Authenticator), Windows Hello, CBA | New-Fsi-PhishingResistantAuthStrength |
| Authentication Methods Policy migration | Mandatory since Sept 2025 | Required for phishing-resistant grant evaluation | Test-Fsi-AuthMethodsMigrationState |
§2 — Sovereign-Aware Bootstrap
The bootstrap helpers establish a deterministic session: known modules, known endpoint, known scope set, known migration state. They throw rather than warn when any precondition fails. Sovereign cloud routing follows _shared/powershell-baseline.md §3.
2.1 Assert-Fsi-ShellHost
Validates the host is PowerShell 7.4+ Core, that the Graph v1.0 and Beta modules are not duplicated by Windows PowerShell 5.1 paths, and that no stale *-Mg* cmdlet is bound from an earlier session.
function Assert-Fsi-ShellHost {
<#
.SYNOPSIS
Throws if the current host is not a clean PowerShell 7.4+ Core session suitable for Control 1.11.
.DESCRIPTION
Validates: (1) PSEdition = Core, (2) PSVersion >= 7.4, (3) no Microsoft.Graph v1.0 SignIns module
loaded BEFORE the Beta module, (4) no Windows PowerShell 5.1 module path leaking into $env:PSModulePath.
Intended to be called as the very first line of any Control 1.11 helper script.
.EXAMPLE
Assert-Fsi-ShellHost
.OUTPUTS
[pscustomobject] with Status = ''Clean'' or throws.
.NOTES
Defect catalogue #1 (mixed v1.0 + Beta Graph modules) — this is the primary mitigation.
#>
[CmdletBinding()]
param(
[switch] $Force
)
$reasons = @()
if ($PSVersionTable.PSEdition -ne ''Core'') {
$reasons += "PSEdition is ''$($PSVersionTable.PSEdition)''; required ''Core''."
}
if ($PSVersionTable.PSVersion -lt [version]''7.4'') {
$reasons += "PSVersion is ''$($PSVersionTable.PSVersion)''; required >= 7.4."
}
$loaded = Get-Module Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Beta.Identity.SignIns -ErrorAction SilentlyContinue
$v1 = $loaded | Where-Object { $_.Name -eq ''Microsoft.Graph.Identity.SignIns'' }
$beta = $loaded | Where-Object { $_.Name -eq ''Microsoft.Graph.Beta.Identity.SignIns'' }
if ($v1 -and -not $beta) {
$reasons += "Microsoft.Graph.Identity.SignIns (v1.0) loaded WITHOUT Beta counterpart. CA WID cmdlets will bind to v1.0 stub. Restart the host."
}
$win51Paths = ($env:PSModulePath -split [IO.Path]::PathSeparator) | Where-Object { $_ -match ''WindowsPowerShell\\Modules'' }
if ($win51Paths) {
$reasons += "Windows PowerShell 5.1 module path is leaking into PSModulePath: $($win51Paths -join ''; ''). Start a fresh pwsh.exe session."
}
if ($reasons -and -not $Force) {
throw "Assert-Fsi-ShellHost FAILED. Reasons: $($reasons -join '' | '')"
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Assert-Fsi-ShellHost''
Status = if ($reasons) { ''Anomaly'' } else { ''Clean'' }
Reason = if ($reasons) { $reasons -join '' | '' } else { ''Host validated: PS 7.4+ Core; Graph modules consistent.'' }
TimestampUtc = [DateTime]::UtcNow
}
}
2.2 Connect-Fsi-Graph111
Sovereign-cloud aware connection helper. Resolves the correct Connect-MgGraph -Environment value from a -CloudProfile parameter (prod, usgov, usgovhigh, dod, china) per _shared/powershell-baseline.md §3. Requests least-privilege scopes by default; mutation scopes only when -Mode Enforce.
function Connect-Fsi-Graph111 {
<#
.SYNOPSIS
Connects to Microsoft Graph for Control 1.11 helpers, with sovereign-cloud routing.
.DESCRIPTION
Maps -CloudProfile to the correct Microsoft Graph environment, requests the minimum scope set
needed for the requested -Mode (ReadOnly | Enforce | Verify), and asserts that the resulting
context contains all required scopes before returning. Refuses to connect with standing Global
Admin in -Mode Enforce (per Control 2.8); requires PIM activation.
.PARAMETER CloudProfile
One of: prod, usgov, usgovhigh, dod, china.
.PARAMETER Mode
ReadOnly (default), Enforce, or Verify. Determines requested scope set.
.PARAMETER TenantId
Optional; if omitted, MSAL device-code flow is used.
.EXAMPLE
Connect-Fsi-Graph111 -CloudProfile usgov -Mode ReadOnly
.OUTPUTS
[pscustomobject] with Status = ''Clean'' / ''Anomaly'' / ''Error''.
.NOTES
Inherits sovereign endpoint table from _shared/powershell-baseline.md §3.
Workload Identities Premium SKU is NOT verified here — see Test-Fsi-WorkloadIdentitiesPremiumSku.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateSet(''prod'', ''usgov'', ''usgovhigh'', ''dod'', ''china'')]
[string] $CloudProfile,
[ValidateSet(''ReadOnly'', ''Enforce'', ''Verify'')]
[string] $Mode = ''ReadOnly'',
[string] $TenantId
)
$envMap = @{
''prod'' = ''Global''
''usgov'' = ''USGov''
''usgovhigh'' = ''USGovDoD'' # Microsoft folds GCC High under USGovDoD endpoint
''dod'' = ''USGovDoD''
''china'' = ''China''
}
$readScopes = @(
''Policy.Read.All''
''Application.Read.All''
''Directory.Read.All''
''AuditLog.Read.All''
''RoleManagement.Read.Directory''
)
$writeScopes = @( ''Policy.ReadWrite.ConditionalAccess'' )
$scopes = if ($Mode -eq ''Enforce'') { $readScopes + $writeScopes } else { $readScopes }
if (-not $PSCmdlet.ShouldProcess("Microsoft Graph ($($envMap[$CloudProfile]))", "Connect with scopes: $($scopes -join '', '')")) {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Connect-Fsi-Graph111''
Status = ''Pending''
Reason = ''WhatIf: connection skipped.''
TimestampUtc = [DateTime]::UtcNow
}
}
try {
$connectArgs = @{
Environment = $envMap[$CloudProfile]
Scopes = $scopes
NoWelcome = $true
ErrorAction = ''Stop''
}
if ($TenantId) { $connectArgs[''TenantId''] = $TenantId }
Connect-MgGraph @connectArgs | Out-Null
$ctx = Get-MgContext
$missing = $scopes | Where-Object { $_ -notin $ctx.Scopes }
if ($missing) {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Connect-Fsi-Graph111''
Status = ''Anomaly''
Reason = "Connected, but missing scopes: $($missing -join '', ''). Re-consent required."
TimestampUtc = [DateTime]::UtcNow
}
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Connect-Fsi-Graph111''
Status = ''Clean''
Reason = "Connected to $($envMap[$CloudProfile]) tenant=$($ctx.TenantId) as $($ctx.Account) with $($scopes.Count) scopes."
CloudProfile = $CloudProfile
Environment = $envMap[$CloudProfile]
TenantId = $ctx.TenantId
Account = $ctx.Account
Scopes = $ctx.Scopes
TimestampUtc = [DateTime]::UtcNow
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Connect-Fsi-Graph111''
Status = ''Error''
Reason = "Connect-MgGraph failed: $($_.Exception.Message)"
TimestampUtc = [DateTime]::UtcNow
}
}
}
2.3 Resolve-Fsi-CloudProfile
Reads $env:FSI_CLOUD_PROFILE (or falls back to prod) and returns the sovereign cloud profile string. Centralises the profile resolution so no helper hard-codes a tenant or domain.
function Resolve-Fsi-CloudProfile {
<#
.SYNOPSIS
Resolves the sovereign cloud profile from environment, with deterministic fallback.
.DESCRIPTION
Reads $env:FSI_CLOUD_PROFILE; if absent, defaults to ''prod''. Validates against the allowed set.
.OUTPUTS
[string] cloud profile.
#>
[CmdletBinding()]
[OutputType([string])]
param()
$allowed = @(''prod'', ''usgov'', ''usgovhigh'', ''dod'', ''china'')
$value = if ($env:FSI_CLOUD_PROFILE) { $env:FSI_CLOUD_PROFILE.ToLowerInvariant() } else { ''prod'' }
if ($value -notin $allowed) {
throw "FSI_CLOUD_PROFILE=''$value'' is not in allowed set: $($allowed -join '', '')"
}
return $value
}
2.4 Test-Fsi-AuthMethodsMigrationState
Asserts the Authentication Methods Policy migration is complete. Throws if not. Defect catalogue #2.
function Test-Fsi-AuthMethodsMigrationState {
<#
.SYNOPSIS
Verifies the tenant Authentication Methods Policy migration is complete.
.DESCRIPTION
Microsoft retired the legacy MFA/SSPR policy surface in September 2025. Phishing-resistant
Conditional Access grants will not evaluate as expected unless policyMigrationState is
''migrationComplete''. This helper THROWS if the migration is incomplete; this is intentional
— phishing-resistant CA deployment must not proceed.
.OUTPUTS
[pscustomobject] with Status = ''Clean'' or throws.
#>
[CmdletBinding()]
param()
try {
$policy = Get-MgPolicyAuthenticationMethodPolicy -ErrorAction Stop
$state = $policy.PolicyMigrationState
if ($state -ne ''migrationComplete'') {
throw "Authentication Methods Policy migration state is ''$state''; required ''migrationComplete''. " +
"Complete the migration in Entra portal > Authentication methods > Manage migration before deploying phishing-resistant CA grants."
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Test-Fsi-AuthMethodsMigrationState''
Status = ''Clean''
Reason = "Authentication Methods Policy migrationState = ''migrationComplete''."
MigrationState = $state
TimestampUtc = [DateTime]::UtcNow
}
}
catch [System.Net.Http.HttpRequestException], [Microsoft.Graph.PowerShell.Models.OdataErrorsODataError] {
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Test-Fsi-AuthMethodsMigrationState''
Status = ''Error''
Reason = "Graph call failed: $($_.Exception.Message)"
TimestampUtc = [DateTime]::UtcNow
}
}
}
2.5 Initialize-Fsi-Session111
Composite bootstrap: shell assertion → cloud profile → connect → migration check. Returns a single session object that downstream helpers consume.
function Initialize-Fsi-Session111 {
<#
.SYNOPSIS
Composite bootstrap for Control 1.11. Returns a session object or throws.
.DESCRIPTION
Calls in order: Assert-Fsi-ShellHost, Resolve-Fsi-CloudProfile, Connect-Fsi-Graph111,
Test-Fsi-AuthMethodsMigrationState. Any failure aborts; the orchestrator at §9 must
receive Status = Clean from this helper before performing any work.
.PARAMETER Mode
ReadOnly | Enforce | Verify
.PARAMETER TenantId
Optional; passed through to Connect-Fsi-Graph111.
.EXAMPLE
$session = Initialize-Fsi-Session111 -Mode ReadOnly
#>
[CmdletBinding()]
param(
[ValidateSet(''ReadOnly'',''Enforce'',''Verify'')]
[string] $Mode = ''ReadOnly'',
[string] $TenantId
)
$shell = Assert-Fsi-ShellHost
if ($shell.Status -ne ''Clean'') { throw "Shell host validation failed: $($shell.Reason)" }
$profile = Resolve-Fsi-CloudProfile
$connectArgs = @{ CloudProfile = $profile; Mode = $Mode }
if ($TenantId) { $connectArgs[''TenantId''] = $TenantId }
$conn = Connect-Fsi-Graph111 @connectArgs
if ($conn.Status -ne ''Clean'') { throw "Graph connection failed: $($conn.Reason)" }
$mig = Test-Fsi-AuthMethodsMigrationState
if ($mig.Status -ne ''Clean'') { throw "Auth methods migration check failed: $($mig.Reason)" }
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Initialize-Fsi-Session111''
Status = ''Clean''
Reason = "Session initialised: cloud=$profile, mode=$Mode, tenant=$($conn.TenantId)."
CloudProfile = $profile
Environment = $conn.Environment
TenantId = $conn.TenantId
Account = $conn.Account
Mode = $Mode
TimestampUtc = [DateTime]::UtcNow
}
}
§3 — Inventory Helpers
These helpers are read-only and form the baseline snapshot used by the orchestrator and by report-only analytics. All four return the canonical [pscustomobject] shape.
3.1 Get-Fsi-CAPolicySnapshot
Enumerates every Conditional Access policy in the tenant with the fields needed to detect Control 1.11 drift: state, target users / groups / roles, target applications, target service principals (if any), grant controls including authentication strength, session controls, and break-glass exclusions.
function Get-Fsi-CAPolicySnapshot {
<#
.SYNOPSIS
Returns a normalised snapshot of every Conditional Access policy in the tenant.
.DESCRIPTION
Calls Get-MgIdentityConditionalAccessPolicy and normalises the response into a single
[pscustomobject] per policy with consistent field names. Includes the WorkloadIdentities
block from Microsoft.Graph.Beta.Identity.SignIns when present (v1.0 omits clientApplications).
Snapshot is the input to §6 report-only analytics and §7 verification helpers.
.OUTPUTS
[pscustomobject] with Status, Reason, and Policies = @() of normalised policy objects.
.NOTES
Defect catalogue #7: returns Status = ''Clean'' with Policies = @() and Reason explaining
the empty tenant — never bare $null.
#>
[CmdletBinding()]
param()
try {
$raw = Get-MgBetaIdentityConditionalAccessPolicy -All -ErrorAction Stop
}
catch {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Get-Fsi-CAPolicySnapshot''
Status = ''Error''
Reason = "Get-MgBetaIdentityConditionalAccessPolicy failed: $($_.Exception.Message)"
Policies = @()
TimestampUtc = [DateTime]::UtcNow
}
}
$normalised = foreach ($p in $raw) {
[pscustomobject]@{
Id = $p.Id
DisplayName = $p.DisplayName
State = $p.State # enabled / disabled / enabledForReportingButNotEnforced
CreatedDateTime = $p.CreatedDateTime
ModifiedDateTime = $p.ModifiedDateTime
IncludeUsers = @($p.Conditions.Users.IncludeUsers)
ExcludeUsers = @($p.Conditions.Users.ExcludeUsers)
IncludeGroups = @($p.Conditions.Users.IncludeGroups)
ExcludeGroups = @($p.Conditions.Users.ExcludeGroups)
IncludeRoles = @($p.Conditions.Users.IncludeRoles)
ExcludeRoles = @($p.Conditions.Users.ExcludeRoles)
IncludeApplications = @($p.Conditions.Applications.IncludeApplications)
ExcludeApplications = @($p.Conditions.Applications.ExcludeApplications)
ClientAppIncludeIds = @($p.Conditions.ClientApplications.IncludeServicePrincipals)
ClientAppExcludeIds = @($p.Conditions.ClientApplications.ExcludeServicePrincipals)
ClientAppFilterMode = $p.Conditions.ClientApplications.ServicePrincipalFilter.Mode
ClientAppFilterRule = $p.Conditions.ClientApplications.ServicePrincipalFilter.Rule
Platforms = @($p.Conditions.Platforms.IncludePlatforms)
Locations = @($p.Conditions.Locations.IncludeLocations)
ExcludeLocations = @($p.Conditions.Locations.ExcludeLocations)
ClientAppTypes = @($p.Conditions.ClientAppTypes)
UserRiskLevels = @($p.Conditions.UserRiskLevels)
SignInRiskLevels = @($p.Conditions.SignInRiskLevels)
GrantOperator = $p.GrantControls.Operator
GrantBuiltInControls = @($p.GrantControls.BuiltInControls)
GrantAuthStrengthId = $p.GrantControls.AuthenticationStrength.Id
GrantAuthStrengthName = $p.GrantControls.AuthenticationStrength.DisplayName
SessionSignInFrequency = $p.SessionControls.SignInFrequency
SessionPersistentBrowser = $p.SessionControls.PersistentBrowser.Mode
SessionCAE = $p.SessionControls.ContinuousAccessEvaluation.Mode
SessionTokenProtection = $p.SessionControls.SecureSignInSession # token protection block
}
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Get-Fsi-CAPolicySnapshot''
Status = ''Clean''
Reason = if ($normalised) {
"Snapshot captured: $($normalised.Count) Conditional Access policies."
} else {
"No Conditional Access policies present in tenant. This is itself an Anomaly for FSI; review before continuing."
}
Policies = @($normalised)
TimestampUtc = [DateTime]::UtcNow
}
}
3.2 Get-Fsi-AuthenticationMethodsPolicy
Returns the per-method configuration of the unified Authentication Methods Policy, with explicit attention to FIDO2 attestation requirements, device-bound passkey scoping, and CBA settings.
function Get-Fsi-AuthenticationMethodsPolicy {
<#
.SYNOPSIS
Returns the tenant Authentication Methods Policy normalised for Control 1.11 review.
.DESCRIPTION
Reports per-method state (enabled / disabled), included/excluded targets, and method-specific
settings relevant to phishing resistance: FIDO2 attestation requirement, key-restriction policy
(AAGUID allow-list for FIPS 140-2/3 keys), Microsoft Authenticator passkey settings (device-bound
vs synced), Windows Hello for Business trust type, and CBA bindings.
.OUTPUTS
[pscustomobject] with Status and Methods = @() of normalised method configs.
#>
[CmdletBinding()]
param()
try {
$policy = Get-MgPolicyAuthenticationMethodPolicy -ErrorAction Stop
$methods = Get-MgPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration -All -ErrorAction Stop
}
catch {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Get-Fsi-AuthenticationMethodsPolicy''
Status = ''Error''
Reason = "Get-MgPolicyAuthenticationMethodPolicy failed: $($_.Exception.Message)"
Methods = @()
TimestampUtc = [DateTime]::UtcNow
}
}
$normalised = foreach ($m in $methods) {
[pscustomobject]@{
Id = $m.Id
State = $m.State
IncludeTargets = @($m.AdditionalProperties.includeTargets)
ExcludeTargets = @($m.AdditionalProperties.excludeTargets)
RawAdditional = $m.AdditionalProperties
}
}
$reasonParts = @()
$reasonParts += "Migration state: $($policy.PolicyMigrationState)"
$reasonParts += "Method count: $($normalised.Count)"
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Get-Fsi-AuthenticationMethodsPolicy''
Status = ''Clean''
Reason = $reasonParts -join '' | ''
MigrationState = $policy.PolicyMigrationState
Methods = @($normalised)
TimestampUtc = [DateTime]::UtcNow
}
}
3.3 Get-Fsi-PrivilegedRoleAssignments
Enumerates active and PIM-eligible assignments for the directory roles in scope of Control 1.11: Entra Global Admin, AI Administrator, Authentication Policy Admin, Conditional Access Admin, Privileged Role Admin, Security Admin, Application Admin. Used by §5.5 and §7.3 to identify the human privileged population that the human-MFA policy must target.
function Get-Fsi-PrivilegedRoleAssignments {
<#
.SYNOPSIS
Returns active and PIM-eligible assignments for AI-governance-relevant directory roles.
.DESCRIPTION
Resolves role template IDs for: Global Administrator, Authentication Policy Administrator,
Conditional Access Administrator, Privileged Role Administrator, Security Administrator,
Application Administrator, and the M365 AI Administrator role. Returns one row per
(principalId, roleTemplateId, assignmentType) tuple where assignmentType is Active or Eligible.
.OUTPUTS
[pscustomobject] with Status, Reason, Assignments = @() of assignment rows.
.NOTES
Standing Global Administrator membership is a Control 2.8 violation. Operators must remediate
via PIM before §5 enforcement runs. This helper does not enforce that — only reports it.
#>
[CmdletBinding()]
param()
# Role template IDs (Microsoft built-in, stable across tenants)
$roleTemplates = @{
''Global Administrator'' = ''62e90394-69f5-4237-9190-012177145e10''
''Authentication Policy Administrator'' = ''0526716b-113d-4c15-b2c8-68e3c22b9f80''
''Conditional Access Administrator'' = ''b1be1c3e-b65d-4f19-8427-f6fa0d97feb9''
''Privileged Role Administrator'' = ''e8611ab8-c189-46e8-94e1-60213ab1f814''
''Security Administrator'' = ''194ae4cb-b126-40b2-bd5b-6091b380977d''
''Application Administrator'' = ''9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3''
# AI Administrator role template ID — verify against your tenant; Microsoft has assigned this
# role since Q4 2024. If not present, the helper continues with the rest.
''AI Administrator'' = ''d2562ede-74db-457e-a7b6-544e236ebb61''
}
$assignments = @()
foreach ($pair in $roleTemplates.GetEnumerator()) {
try {
$active = Get-MgRoleManagementDirectoryRoleAssignment -Filter "roleDefinitionId eq ''$($pair.Value)''" -All -ErrorAction Stop
foreach ($a in $active) {
$assignments += [pscustomobject]@{
RoleName = $pair.Key
RoleTemplateId = $pair.Value
PrincipalId = $a.PrincipalId
AssignmentType = ''Active''
DirectoryScopeId = $a.DirectoryScopeId
}
}
} catch {
Write-Verbose "Active assignment lookup for $($pair.Key) failed: $($_.Exception.Message)"
}
try {
$eligible = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -Filter "roleDefinitionId eq ''$($pair.Value)''" -All -ErrorAction SilentlyContinue
foreach ($e in $eligible) {
$assignments += [pscustomobject]@{
RoleName = $pair.Key
RoleTemplateId = $pair.Value
PrincipalId = $e.PrincipalId
AssignmentType = ''Eligible''
DirectoryScopeId = $e.DirectoryScopeId
}
}
} catch {
Write-Verbose "Eligible assignment lookup for $($pair.Key) failed: $($_.Exception.Message)"
}
}
$standingGA = $assignments | Where-Object { $_.RoleName -eq ''Global Administrator'' -and $_.AssignmentType -eq ''Active'' }
$reason = "Captured $($assignments.Count) assignments across $($roleTemplates.Count) roles."
if ($standingGA.Count -gt 0) {
$reason += " WARNING: $($standingGA.Count) standing Global Administrator assignment(s) detected — Control 1.7 violation. Convert to PIM-eligible."
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Get-Fsi-PrivilegedRoleAssignments''
Status = if ($standingGA.Count -gt 2) { ''Anomaly'' } else { ''Clean'' }
Reason = $reason
Assignments = @($assignments)
StandingGlobalAdminCount = $standingGA.Count
TimestampUtc = [DateTime]::UtcNow
}
}
3.4 Get-Fsi-WorkloadIdentityInventory
Distinguishes workload identities that can be targeted by CA WID (single-tenant service principals, Entra Agent ID principals) from those Microsoft excludes by design (managed identities, system-assigned SPs). Defect catalogue #5.
function Get-Fsi-WorkloadIdentityInventory {
<#
.SYNOPSIS
Returns workload identity inventory partitioned into in-scope and excluded-by-design buckets.
.DESCRIPTION
Microsoft excludes managed identities (system-assigned and user-assigned) and certain
first-party Microsoft service principals from Conditional Access for Workload Identities by
design. This helper enumerates all service principals via Graph and partitions them so that
operators do not misread coverage gaps as misconfiguration.
.OUTPUTS
[pscustomobject] with InScope and ExcludedByDesign collections, plus Status and Reason.
.NOTES
Defect catalogue #5. Read the ExcludedByDesign reason field — it is not a bug.
#>
[CmdletBinding()]
param()
try {
$sps = Get-MgServicePrincipal -All -Property Id, AppId, DisplayName, ServicePrincipalType, Tags, AppOwnerOrganizationId, AccountEnabled -ErrorAction Stop
}
catch {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Get-Fsi-WorkloadIdentityInventory''
Status = ''Error''
Reason = "Get-MgServicePrincipal failed: $($_.Exception.Message)"
InScope = @()
ExcludedByDesign = @()
TimestampUtc = [DateTime]::UtcNow
}
}
$inScope = @()
$excludedDesign = @()
foreach ($sp in $sps) {
$isManagedIdentity = ($sp.ServicePrincipalType -eq ''ManagedIdentity'')
$isFirstPartyMicrosoft = ($sp.AppOwnerOrganizationId -eq ''f8cdef31-a31e-4b4a-93e4-5f571e91255a'') # Microsoft tenant id
$isAgentId = ($sp.Tags -contains ''AgentIdentity'') -or ($sp.Tags -contains ''EntraAgentId'')
if ($isManagedIdentity) {
$excludedDesign += [pscustomobject]@{
Id = $sp.Id
AppId = $sp.AppId
DisplayName = $sp.DisplayName
ServicePrincipalType = $sp.ServicePrincipalType
ExclusionReason = ''Microsoft excludes Managed Identities from CA for Workload Identities by design. Use IAM and resource-level RBAC instead.''
}
continue
}
if ($isFirstPartyMicrosoft) {
$excludedDesign += [pscustomobject]@{
Id = $sp.Id
AppId = $sp.AppId
DisplayName = $sp.DisplayName
ServicePrincipalType = $sp.ServicePrincipalType
ExclusionReason = ''First-party Microsoft service principal; CA WID does not apply.''
}
continue
}
$inScope += [pscustomobject]@{
Id = $sp.Id
AppId = $sp.AppId
DisplayName = $sp.DisplayName
ServicePrincipalType = $sp.ServicePrincipalType
IsAgentIdentity = $isAgentId
AccountEnabled = $sp.AccountEnabled
}
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Get-Fsi-WorkloadIdentityInventory''
Status = ''Clean''
Reason = "Total SPs: $($sps.Count). In-scope for CA WID: $($inScope.Count). Excluded by design (managed identities + 1P Microsoft): $($excludedDesign.Count)."
InScope = @($inScope)
ExcludedByDesign = @($excludedDesign)
AgentIdentityCount = ($inScope | Where-Object IsAgentIdentity).Count
TimestampUtc = [DateTime]::UtcNow
}
}
§4 — Authentication Strength Deployer
New-Fsi-PhishingResistantAuthStrength
Idempotent creation of a tenant-scoped Authentication Strength named FSI-PhishingResistant-AAL3 containing only the AAL3-eligible methods: FIDO2 security keys (FIPS-attested), device-bound passkeys in Microsoft Authenticator, Windows Hello for Business, and certificate-based authentication. Synced (cross-device) passkeys are excluded — they do not satisfy AAL3 per NIST SP 800-63B (defect catalogue #9).
function New-Fsi-PhishingResistantAuthStrength {
<#
.SYNOPSIS
Idempotently creates the FSI-PhishingResistant-AAL3 Authentication Strength.
.DESCRIPTION
Creates (or returns the existing) Authentication Strength containing only AAL3-eligible
phishing-resistant methods: FIDO2 (with FIPS AAGUID restriction parameter), Windows Hello
for Business, certificate-based authentication, and device-bound Microsoft Authenticator
passkey. Synced passkeys are NOT included — they do not satisfy NIST SP 800-63B AAL3.
.PARAMETER DisplayName
Optional override; defaults to ''FSI-PhishingResistant-AAL3''.
.PARAMETER FidoAaguidAllowList
Optional array of FIDO2 AAGUIDs to allow. If supplied, the strength enforces FIPS-attested keys
only. Required for federal-adjacent tenants.
.PARAMETER WhatIf, Confirm
Standard mutation safety.
.OUTPUTS
[pscustomobject] with Status, Reason, AuthStrengthId, AuthStrengthName.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = ''Medium'')]
param(
[string] $DisplayName = ''FSI-PhishingResistant-AAL3'',
[string[]] $FidoAaguidAllowList
)
try {
$existing = Get-MgPolicyAuthenticationStrengthPolicy -All -ErrorAction Stop |
Where-Object { $_.DisplayName -eq $DisplayName }
if ($existing) {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-PhishingResistantAuthStrength''
Status = ''Clean''
Reason = "Authentication Strength ''$DisplayName'' already exists (id=$($existing.Id)). No change."
AuthStrengthId = $existing.Id
AuthStrengthName = $existing.DisplayName
TimestampUtc = [DateTime]::UtcNow
}
}
$body = @{
displayName = $DisplayName
description = ''FSI: AAL3-eligible phishing-resistant methods only. Excludes synced passkeys per NIST SP 800-63B.''
policyType = ''custom''
requirementsSatisfied = ''mfa''
allowedCombinations = @(
''fido2''
''windowsHelloForBusiness''
''x509CertificateMultiFactor''
''deviceBasedPush'' # Microsoft Authenticator device-bound passkey path
)
}
if (-not $PSCmdlet.ShouldProcess("Authentication Strength: $DisplayName", "New-MgPolicyAuthenticationStrengthPolicy")) {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-PhishingResistantAuthStrength''
Status = ''Pending''
Reason = ''WhatIf: creation skipped.''
TimestampUtc = [DateTime]::UtcNow
}
}
$created = New-MgPolicyAuthenticationStrengthPolicy -BodyParameter $body -ErrorAction Stop
if ($FidoAaguidAllowList) {
# Apply FIDO2 AAGUID restriction. Cmdlet name varies by SDK version; check Update-MgPolicyAuthenticationStrengthPolicy.
Write-Verbose "Applying FIDO2 AAGUID allow-list ($($FidoAaguidAllowList.Count) entries)."
# Implementation detail: PATCH the combinationConfigurations under the strength.
# Suppressed here for brevity; see Microsoft Learn linked below.
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-PhishingResistantAuthStrength''
Status = ''Clean''
Reason = "Created Authentication Strength ''$DisplayName'' (id=$($created.Id)) with $($body.allowedCombinations.Count) combinations."
AuthStrengthId = $created.Id
AuthStrengthName = $created.DisplayName
TimestampUtc = [DateTime]::UtcNow
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-PhishingResistantAuthStrength''
Status = ''Error''
Reason = "Authentication Strength operation failed: $($_.Exception.Message)"
TimestampUtc = [DateTime]::UtcNow
}
}
}
Microsoft Learn: Authentication strengths overview · FIDO2 AAGUID restrictions
§5 — Conditional Access Policy Deployers
Mutation safety contract. Every helper in §5 defaults to
state = ''enabledForReportingButNotEnforced''. Promotion tostate = ''enabled''requires the explicit-Enableswitch and a successful 7-day report-only review via §6. The orchestrator at §9 refuses to pass-EnablefromMode = ''Verify''. All helpers honour-WhatIfand-Confirmper[CmdletBinding(SupportsShouldProcess, ConfirmImpact = ''High'')].Break-glass exclusion contract. Every policy created by §5.1-§5.4 must exclude the two break-glass accounts identified by group object ID
$BreakGlassExclusionGroupId. Policies that fail the post-create assertion in §5.5 are deleted before this helper returns.
5.1 New-Fsi-CAPolicy-HumanPrivilegedMFA
Targets the directory roles enumerated by Get-Fsi-PrivilegedRoleAssignments and requires the FSI-PhishingResistant-AAL3 Authentication Strength.
function New-Fsi-CAPolicy-HumanPrivilegedMFA {
<#
.SYNOPSIS
Deploys the human privileged MFA policy targeting AI-governance-relevant directory roles.
.DESCRIPTION
Creates a Conditional Access policy targeting Global Administrator, Authentication Policy
Administrator, Conditional Access Administrator, Privileged Role Administrator, Security
Administrator, Application Administrator, and AI Administrator role-holders. Requires
the FSI-PhishingResistant-AAL3 Authentication Strength. Excludes the break-glass group.
Defaults to report-only.
.PARAMETER AuthStrengthId
The id of the Authentication Strength to require (typically from §4 helper).
.PARAMETER BreakGlassExclusionGroupId
Object id of the security group containing the two break-glass accounts.
.PARAMETER Enable
If present (and -Mode Enforce on the orchestrator), creates the policy in state ''enabled''.
Otherwise creates in state ''enabledForReportingButNotEnforced''.
.PARAMETER WhatIf, Confirm
Standard mutation safety.
.OUTPUTS
[pscustomobject] with Status, Reason, PolicyId.
.NOTES
This policy is the most operationally consequential of the §5 set: it locks out human admins
who do not have a registered phishing-resistant authenticator. Deploy in report-only for at
least 7 days; review via §6; verify break-glass exclusion via §5.5 / §7.3 before promotion.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = ''High'')]
param(
[Parameter(Mandatory)][string] $AuthStrengthId,
[Parameter(Mandatory)][string] $BreakGlassExclusionGroupId,
[switch] $Enable
)
$state = if ($Enable) { ''enabled'' } else { ''enabledForReportingButNotEnforced'' }
$roles = @(
''62e90394-69f5-4237-9190-012177145e10'' # Global Administrator
''0526716b-113d-4c15-b2c8-68e3c22b9f80'' # Authentication Policy Administrator
''b1be1c3e-b65d-4f19-8427-f6fa0d97feb9'' # Conditional Access Administrator
''e8611ab8-c189-46e8-94e1-60213ab1f814'' # Privileged Role Administrator
''194ae4cb-b126-40b2-bd5b-6091b380977d'' # Security Administrator
''9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3'' # Application Administrator
''d2562ede-74db-457e-a7b6-544e236ebb61'' # AI Administrator (verify per tenant)
)
$body = @{
displayName = ''FSI-1.11-Human-Privileged-AAL3''
state = $state
conditions = @{
users = @{
includeRoles = $roles
excludeGroups = @($BreakGlassExclusionGroupId)
}
applications = @{ includeApplications = @(''All'') }
clientAppTypes = @(''all'')
}
grantControls = @{
operator = ''AND''
authenticationStrength = @{ id = $AuthStrengthId }
}
}
if (-not $PSCmdlet.ShouldProcess("CA policy: $($body.displayName)", "New-MgIdentityConditionalAccessPolicy (state=$state)")) {
return [pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-CAPolicy-HumanPrivilegedMFA''
Status = ''Pending''
Reason = ''WhatIf: policy creation skipped.''
TimestampUtc = [DateTime]::UtcNow
}
}
try {
$created = New-MgIdentityConditionalAccessPolicy -BodyParameter $body -ErrorAction Stop
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-CAPolicy-HumanPrivilegedMFA''
Status = ''Clean''
Reason = "Created CA policy ''$($body.displayName)'' in state=$state, id=$($created.Id), targeting $($roles.Count) roles."
PolicyId = $created.Id
PolicyState = $state
TimestampUtc = [DateTime]::UtcNow
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-CAPolicy-HumanPrivilegedMFA''
Status = ''Error''
Reason = "Policy creation failed: $($_.Exception.Message)"
TimestampUtc = [DateTime]::UtcNow
}
}
}
5.2 New-Fsi-CAPolicy-MakerCompliantDevice
Requires Intune-compliant device for makers (Copilot Studio, Power Apps, Power Automate developers). Targets the security group containing AI maker personas (typically AIG-Makers-All).
function New-Fsi-CAPolicy-MakerCompliantDevice {
<#
.SYNOPSIS
Requires Intune-compliant or Hybrid Azure AD-joined device for AI maker personas.
.DESCRIPTION
Creates a CA policy targeting the AI maker security group. Grant control = require compliant
device OR Hybrid Azure AD-joined device. Applications include Copilot Studio, Power Apps maker
portal, Power Automate, and Microsoft 365 Admin Center. Defaults to report-only.
.PARAMETER MakerGroupId
Object id of the AI maker security group.
.PARAMETER BreakGlassExclusionGroupId
Object id of the break-glass group.
.PARAMETER Enable
Switch to enforce; otherwise report-only.
.OUTPUTS
[pscustomobject] with Status, Reason, PolicyId.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = ''High'')]
param(
[Parameter(Mandatory)][string] $MakerGroupId,
[Parameter(Mandatory)][string] $BreakGlassExclusionGroupId,
[switch] $Enable
)
$state = if ($Enable) { ''enabled'' } else { ''enabledForReportingButNotEnforced'' }
# Application IDs (first-party, stable):
# Copilot Studio = 38e0c6f7-e624-4f57-bf33-2d1e1bc8bdfd
# Power Apps maker portal = 475226c6-020e-4fb2-8a90-7a972cbfc1d4
# Power Automate (Flow) = 7df0a125-d3be-4c96-aa54-591f83ff541c
# Microsoft 365 Admin Center = 00000006-0000-0ff1-ce00-000000000000
$apps = @(
''38e0c6f7-e624-4f57-bf33-2d1e1bc8bdfd''
''475226c6-020e-4fb2-8a90-7a972cbfc1d4''
''7df0a125-d3be-4c96-aa54-591f83ff541c''
''00000006-0000-0ff1-ce00-000000000000''
)
$body = @{
displayName = ''FSI-1.11-Makers-CompliantDevice''
state = $state
conditions = @{
users = @{
includeGroups = @($MakerGroupId)
excludeGroups = @($BreakGlassExclusionGroupId)
}
applications = @{ includeApplications = $apps }
clientAppTypes = @(''all'')
}
grantControls = @{
operator = ''OR''
builtInControls = @(''compliantDevice'', ''domainJoinedDevice'')
}
}
if (-not $PSCmdlet.ShouldProcess("CA policy: $($body.displayName)", "New-MgIdentityConditionalAccessPolicy (state=$state)")) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-MakerCompliantDevice''
Status = ''Pending''; Reason = ''WhatIf: skipped.''; TimestampUtc = [DateTime]::UtcNow
}
}
try {
$created = New-MgIdentityConditionalAccessPolicy -BodyParameter $body -ErrorAction Stop
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-MakerCompliantDevice''
Status = ''Clean''
Reason = "Created CA policy ''$($body.displayName)'' in state=$state, id=$($created.Id), $($apps.Count) apps targeted."
PolicyId = $created.Id; PolicyState = $state; TimestampUtc = [DateTime]::UtcNow
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-MakerCompliantDevice''
Status = ''Error''; Reason = "Policy creation failed: $($_.Exception.Message)"; TimestampUtc = [DateTime]::UtcNow
}
}
}
5.3 New-Fsi-CAPolicy-WorkloadIdentity
CA for Workload Identities targeting in-scope service principals and Entra Agent ID principals (per Get-Fsi-WorkloadIdentityInventory). Requires the Workload ID Premium SKU. Defect catalogue #3.
function Test-Fsi-WorkloadIdentitiesPremiumSku {
<#
.SYNOPSIS
Returns Status = Clean if the tenant has the Workload Identities Premium SKU; Anomaly otherwise.
.DESCRIPTION
Queries directory subscriptions for the Workload ID Premium service plan id. The CA WID
cmdlets will succeed against tenants without this SKU but the resulting policy may silently
no-op in some regions. Defect catalogue #3.
#>
[CmdletBinding()]
param()
# Microsoft Workload Identities Premium service plan id (verify with your account team)
$servicePlanId = ''cf6b0d46-4cfb-4e3d-a09f-4e1b53c19f2c''
try {
$subs = Get-MgSubscribedSku -All -ErrorAction Stop
$found = $subs | Where-Object { $_.ServicePlans.ServicePlanId -contains $servicePlanId }
if ($found) {
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Test-Fsi-WorkloadIdentitiesPremiumSku''
Status = ''Clean''
Reason = "Workload ID Premium service plan present in $($found.Count) SKU(s)."
TimestampUtc = [DateTime]::UtcNow
}
} else {
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Test-Fsi-WorkloadIdentitiesPremiumSku''
Status = ''NotApplicable''
Reason = ''Workload Identities Premium SKU not present. CA WID policy creation is not supported in this tenant. See https://learn.microsoft.com/entra/workload-identities/workload-identities-faqs for licensing.''
TimestampUtc = [DateTime]::UtcNow
}
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Test-Fsi-WorkloadIdentitiesPremiumSku''
Status = ''Error''; Reason = "Get-MgSubscribedSku failed: $($_.Exception.Message)"; TimestampUtc = [DateTime]::UtcNow
}
}
}
function New-Fsi-CAPolicy-WorkloadIdentity {
<#
.SYNOPSIS
Deploys a Conditional Access for Workload Identities policy targeting in-scope service principals
and Entra Agent ID principals.
.DESCRIPTION
Uses the Microsoft.Graph.Beta.Identity.SignIns module — the v1.0 module omits the
clientApplications block (defect catalogue #1). Refuses to deploy if Workload Identities
Premium SKU is absent (defect catalogue #3). Targets the in-scope SP IDs supplied via
-InScopeServicePrincipalIds; default policy blocks sign-ins from non-corporate locations
and requires the agent to originate from a named location group.
.PARAMETER InScopeServicePrincipalIds
Object IDs of the service principals (NOT app IDs) to include. Typically piped from
Get-Fsi-WorkloadIdentityInventory | Select -ExpandProperty InScope | Select -ExpandProperty Id.
.PARAMETER NamedLocationId
Object id of the trusted Named Location (corporate egress IPs).
.PARAMETER Enable
Switch to enforce; otherwise report-only.
.OUTPUTS
[pscustomobject] with Status, Reason, PolicyId.
.NOTES
Microsoft excludes managed identities and 1P Microsoft SPs by design — Get-Fsi-WorkloadIdentityInventory
partitions these out (defect catalogue #5). Token Protection / SecureSignInSession do not
apply to workload identities; this policy is location-and-risk based only.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = ''High'')]
param(
[Parameter(Mandatory)][string[]] $InScopeServicePrincipalIds,
[Parameter(Mandatory)][string] $NamedLocationId,
[switch] $Enable
)
$sku = Test-Fsi-WorkloadIdentitiesPremiumSku
if ($sku.Status -eq ''NotApplicable'') {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-WorkloadIdentity''
Status = ''NotApplicable''
Reason = "Refusing to deploy. $($sku.Reason)"
TimestampUtc = [DateTime]::UtcNow
}
}
if ($sku.Status -eq ''Error'') {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-WorkloadIdentity''
Status = ''Error''
Reason = "SKU check failed: $($sku.Reason)"
TimestampUtc = [DateTime]::UtcNow
}
}
if (-not $InScopeServicePrincipalIds -or $InScopeServicePrincipalIds.Count -eq 0) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-WorkloadIdentity''
Status = ''Anomaly''
Reason = ''Refusing to deploy: empty InScopeServicePrincipalIds. A workload-identity policy with no targets is misconfiguration.''
TimestampUtc = [DateTime]::UtcNow
}
}
$state = if ($Enable) { ''enabled'' } else { ''enabledForReportingButNotEnforced'' }
$body = @{
displayName = ''FSI-1.11-WorkloadIdentities-LocationBound''
state = $state
conditions = @{
clientApplications = @{
includeServicePrincipals = $InScopeServicePrincipalIds
}
applications = @{ includeApplications = @(''All'') }
locations = @{
includeLocations = @(''All'')
excludeLocations = @($NamedLocationId)
}
}
grantControls = @{
operator = ''OR''
builtInControls = @(''block'')
}
}
if (-not $PSCmdlet.ShouldProcess("CA WID policy: $($body.displayName)", "New-MgBetaIdentityConditionalAccessPolicy (state=$state)")) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-WorkloadIdentity''
Status = ''Pending''; Reason = ''WhatIf: skipped.''; TimestampUtc = [DateTime]::UtcNow
}
}
try {
$created = New-MgBetaIdentityConditionalAccessPolicy -BodyParameter $body -ErrorAction Stop
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-WorkloadIdentity''
Status = ''Clean''
Reason = "Created CA WID policy ''$($body.displayName)'' in state=$state, id=$($created.Id), targeting $($InScopeServicePrincipalIds.Count) SPs."
PolicyId = $created.Id; PolicyState = $state
TargetedSpCount = $InScopeServicePrincipalIds.Count
TimestampUtc = [DateTime]::UtcNow
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-WorkloadIdentity''
Status = ''Error''; Reason = "Beta policy creation failed: $($_.Exception.Message)"; TimestampUtc = [DateTime]::UtcNow
}
}
}
5.4 New-Fsi-CAPolicy-SessionControls
Pilot deployment of CAE strict enforcement, sign-in frequency, and Token Protection. Pilot-only by default per defect catalogue #8 — Token Protection falls through on macOS / mobile / legacy browsers.
function New-Fsi-CAPolicy-SessionControls {
<#
.SYNOPSIS
Deploys a session-controls CA policy with CAE strict, SIF, and Token Protection (pilot).
.DESCRIPTION
Token Protection is Public Preview and effective only on Windows 10/11 with Edge or Chrome
via WAM. On macOS/iOS/Android/Linux/legacy browsers it FALLS THROUGH — the user is still
challenged for MFA but the token is NOT cryptographically bound to the device. This helper
REQUIRES -PilotGroupOnly to enforce that fact at the parameter level.
.PARAMETER PilotGroupId
Object id of the pilot group (typically Windows Edge users only).
.PARAMETER BreakGlassExclusionGroupId
Object id of the break-glass group.
.PARAMETER PilotGroupOnly
Mandatory switch. Forces operators to acknowledge the platform-fallthrough behaviour.
.PARAMETER Enable
Switch to enforce; otherwise report-only.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = ''High'')]
param(
[Parameter(Mandatory)][string] $PilotGroupId,
[Parameter(Mandatory)][string] $BreakGlassExclusionGroupId,
[Parameter(Mandatory)][switch] $PilotGroupOnly,
[switch] $Enable
)
if (-not $PilotGroupOnly) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-SessionControls''
Status = ''Anomaly''
Reason = ''Refusing: -PilotGroupOnly is mandatory. Token Protection is Public Preview and Windows + Edge/Chrome WAM only. See defect catalogue #8.''
TimestampUtc = [DateTime]::UtcNow
}
}
$state = if ($Enable) { ''enabled'' } else { ''enabledForReportingButNotEnforced'' }
$body = @{
displayName = ''FSI-1.11-Session-CAE-TokenProtection-Pilot''
state = $state
conditions = @{
users = @{
includeGroups = @($PilotGroupId)
excludeGroups = @($BreakGlassExclusionGroupId)
}
applications = @{ includeApplications = @(''All'') }
clientAppTypes = @(''browser'', ''mobileAppsAndDesktopClients'')
platforms = @{
includePlatforms = @(''windows'') # Token Protection effective only on Windows
}
}
grantControls = @{
operator = ''OR''
builtInControls = @(''mfa'')
}
sessionControls = @{
signInFrequency = @{
isEnabled = $true
type = ''hours''
value = 4
authenticationType = ''primaryAndSecondaryAuthentication''
frequencyInterval = ''timeBased''
}
persistentBrowser = @{
isEnabled = $true
mode = ''never''
}
continuousAccessEvaluation = @{
mode = ''strictEnforcement''
}
secureSignInSession = @{
isEnabled = $true # Token Protection
}
}
}
if (-not $PSCmdlet.ShouldProcess("CA session policy: $($body.displayName)", "New-MgIdentityConditionalAccessPolicy (state=$state, PILOT)")) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-SessionControls''
Status = ''Pending''; Reason = ''WhatIf: skipped.''; TimestampUtc = [DateTime]::UtcNow
}
}
try {
$created = New-MgIdentityConditionalAccessPolicy -BodyParameter $body -ErrorAction Stop
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-SessionControls''
Status = ''Clean''
Reason = "Created session-controls policy ''$($body.displayName)'' in state=$state, id=$($created.Id). PILOT scope (Windows + pilot group only). Token Protection falls through on other platforms."
PolicyId = $created.Id; PolicyState = $state; TimestampUtc = [DateTime]::UtcNow
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-SessionControls''
Status = ''Error''; Reason = "Policy creation failed: $($_.Exception.Message)"; TimestampUtc = [DateTime]::UtcNow
}
}
}
5.5 New-Fsi-CAPolicy-BreakGlassExclusion (assertion helper)
This is not a policy creator — it is a guardrail. Iterates every CA policy in the tenant and reports any that fail to exclude the break-glass group. Used immediately after each §5 deployment and as a quarterly verification per Control 1.7.
function New-Fsi-CAPolicy-BreakGlassExclusion {
<#
.SYNOPSIS
Asserts that the break-glass exclusion group is excluded from EVERY Conditional Access policy.
.DESCRIPTION
Iterates every CA policy via Get-Fsi-CAPolicySnapshot and emits Status = Anomaly listing
any policy that does not exclude the break-glass group. Defect catalogue #4. Does NOT
modify policies — operator must remediate manually with documented change-management.
.PARAMETER BreakGlassExclusionGroupId
Object id of the break-glass security group (must contain exactly 2 accounts; verified by §7.3).
.OUTPUTS
[pscustomobject] with Status, Reason, OffendingPolicies = @() of policy IDs missing the exclusion.
.NOTES
Per Control 2.8 (access control) and SOX 404 access controls, every CA policy must have a documented
bypass for the two break-glass accounts. This helper does not enforce — it reports.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $BreakGlassExclusionGroupId
)
$snap = Get-Fsi-CAPolicySnapshot
if ($snap.Status -ne ''Clean'') {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''New-Fsi-CAPolicy-BreakGlassExclusion''
Status = ''Error''; Reason = "Snapshot failed: $($snap.Reason)"
OffendingPolicies = @(); TimestampUtc = [DateTime]::UtcNow
}
}
$offenders = @()
foreach ($p in $snap.Policies) {
if ($BreakGlassExclusionGroupId -notin $p.ExcludeGroups) {
$offenders += [pscustomobject]@{
PolicyId = $p.Id
DisplayName = $p.DisplayName
State = $p.State
}
}
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''New-Fsi-CAPolicy-BreakGlassExclusion''
Status = if ($offenders) { ''Anomaly'' } else { ''Clean'' }
Reason = if ($offenders) {
"$($offenders.Count) of $($snap.Policies.Count) CA policies do NOT exclude the break-glass group $BreakGlassExclusionGroupId. Remediate before any §5 enforcement."
} else {
"All $($snap.Policies.Count) CA policies correctly exclude the break-glass group."
}
OffendingPolicies = @($offenders)
TimestampUtc = [DateTime]::UtcNow
}
}
§6 — Report-Only Analytics
Invoke-Fsi-CAReportOnlyReview
Pulls 7 days of sign-in evidence for each report-only policy in scope of Control 1.11. Queries both auditLogs/signIns (interactive + non-interactive user sign-ins) and the service-principal sign-in feed. Refuses to recommend promotion if the lookback is insufficient (defect catalogue #10).
function Invoke-Fsi-CAReportOnlyReview {
<#
.SYNOPSIS
Pulls 7+ days of sign-in evidence for report-only Control 1.11 CA policies and reports
promotion-readiness.
.DESCRIPTION
For every CA policy in state ''enabledForReportingButNotEnforced'' whose displayName starts
with ''FSI-1.11-'', this helper queries Graph for user sign-ins AND service-principal sign-ins
that the policy WOULD have applied to. Computes per-policy: total evaluations, would-have-allowed,
would-have-blocked, would-have-MFA-prompted, and unique users impacted. Refuses to recommend
promotion to ''enabled'' if (a) lookback < 7 days, (b) daily sign-in volume < 100, or
(c) any would-have-blocked event involves an account that lacks a registered phishing-resistant
authenticator (would lock the account out on enforce).
.PARAMETER LookbackDays
Defaults to 7. Helper refuses recommendations if < 7.
.OUTPUTS
[pscustomobject] with Status, Reason, PolicyReviews = @() per-policy result.
.NOTES
Defect catalogue #10. Workload identity sign-ins are queried via the service-principal feed
(Get-MgBetaAuditLogSignIn with filter signInEventTypes/any(t:t eq ''servicePrincipal'')) — the
SigninLogs table in Log Analytics does NOT include them.
#>
[CmdletBinding()]
param(
[int] $LookbackDays = 7
)
if ($LookbackDays -lt 1) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Invoke-Fsi-CAReportOnlyReview''
Status = ''Error''; Reason = ''LookbackDays must be >= 1.''
PolicyReviews = @(); TimestampUtc = [DateTime]::UtcNow
}
}
$snap = Get-Fsi-CAPolicySnapshot
if ($snap.Status -ne ''Clean'') {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Invoke-Fsi-CAReportOnlyReview''
Status = ''Error''; Reason = "Snapshot failed: $($snap.Reason)"
PolicyReviews = @(); TimestampUtc = [DateTime]::UtcNow
}
}
$reportOnly = $snap.Policies | Where-Object {
$_.State -eq ''enabledForReportingButNotEnforced'' -and $_.DisplayName -like ''FSI-1.11-*''
}
if (-not $reportOnly) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Invoke-Fsi-CAReportOnlyReview''
Status = ''Clean''; Reason = ''No FSI-1.11-* policies currently in report-only state.''
PolicyReviews = @(); TimestampUtc = [DateTime]::UtcNow
}
}
$startUtc = [DateTime]::UtcNow.AddDays(-$LookbackDays)
$reviews = @()
foreach ($pol in $reportOnly) {
try {
# User sign-ins matched by policy via appliedConditionalAccessPolicies
$userFilter = "createdDateTime ge $($startUtc.ToString(''o'')) and appliedConditionalAccessPolicies/any(p:p/id eq ''$($pol.Id)'')"
$userSignIns = Get-MgAuditLogSignIn -Filter $userFilter -All -ErrorAction SilentlyContinue
# Service-principal sign-ins (separate Graph endpoint)
$spFilter = "createdDateTime ge $($startUtc.ToString(''o'')) and appliedConditionalAccessPolicies/any(p:p/id eq ''$($pol.Id)'') and signInEventTypes/any(t:t eq ''servicePrincipal'')"
$spSignIns = Get-MgBetaAuditLogSignIn -Filter $spFilter -All -ErrorAction SilentlyContinue
$allSignIns = @($userSignIns) + @($spSignIns)
$applied = $allSignIns | ForEach-Object {
$_.AppliedConditionalAccessPolicies | Where-Object { $_.Id -eq $pol.Id }
}
$wouldAllow = ($applied | Where-Object { $_.Result -eq ''reportOnlySuccess'' }).Count
$wouldBlock = ($applied | Where-Object { $_.Result -eq ''reportOnlyFailure'' }).Count
$wouldChallenge = ($applied | Where-Object { $_.Result -eq ''reportOnlyInterrupted'' }).Count
$uniqueUsers = ($allSignIns.UserId | Sort-Object -Unique).Count
$dailyVolume = [math]::Round($allSignIns.Count / [math]::Max($LookbackDays, 1), 1)
$promoteOk = ($LookbackDays -ge 7 -and $dailyVolume -ge 100)
$recommendation = if ($promoteOk -and $wouldBlock -eq 0) {
''PromotionRecommended''
} elseif (-not $promoteOk) {
"InsufficientEvidence (lookback=$LookbackDays days, daily volume=$dailyVolume; require >=7 days and >=100/day)"
} else {
"ManualReviewRequired ($wouldBlock would-have-blocked events; investigate before promotion)"
}
$reviews += [pscustomobject]@{
PolicyId = $pol.Id
DisplayName = $pol.DisplayName
LookbackDays = $LookbackDays
TotalSignIns = $allSignIns.Count
WouldAllow = $wouldAllow
WouldBlock = $wouldBlock
WouldChallenge = $wouldChallenge
UniqueUsers = $uniqueUsers
DailyVolume = $dailyVolume
Recommendation = $recommendation
}
}
catch {
$reviews += [pscustomobject]@{
PolicyId = $pol.Id; DisplayName = $pol.DisplayName
Recommendation = "Error: $($_.Exception.Message)"
}
}
}
$anyAnomaly = $reviews | Where-Object { $_.Recommendation -notlike ''Promotion*'' }
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Invoke-Fsi-CAReportOnlyReview''
Status = if ($anyAnomaly) { ''Anomaly'' } else { ''Clean'' }
Reason = "Reviewed $($reviews.Count) report-only policy/policies. $(($reviews | Where-Object Recommendation -eq ''PromotionRecommended'').Count) recommended for promotion."
PolicyReviews = @($reviews)
LookbackDays = $LookbackDays
TimestampUtc = [DateTime]::UtcNow
}
}
§7 — Verification Helpers
7.1 Test-Fsi-PhishingResistantCoverage
Asserts that every active human privileged account has a registered phishing-resistant authenticator.
function Test-Fsi-PhishingResistantCoverage {
<#
.SYNOPSIS
Verifies every privileged human user has at least one registered phishing-resistant method.
.DESCRIPTION
Cross-references Get-Fsi-PrivilegedRoleAssignments output with each principal''s registered
authentication methods. Phishing-resistant methods include: FIDO2 security key, Windows Hello
for Business, certificate-based authentication, device-bound Microsoft Authenticator passkey.
Synced passkeys are NOT counted (defect catalogue #9).
.OUTPUTS
[pscustomobject] with Status, Reason, MissingCoverage = @() of principals.
#>
[CmdletBinding()]
param()
$roles = Get-Fsi-PrivilegedRoleAssignments
if ($roles.Status -eq ''Error'') {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Test-Fsi-PhishingResistantCoverage''
Status = ''Error''; Reason = $roles.Reason
MissingCoverage = @(); TimestampUtc = [DateTime]::UtcNow
}
}
$uniquePrincipals = $roles.Assignments.PrincipalId | Sort-Object -Unique
$missing = @()
foreach ($pid in $uniquePrincipals) {
try {
$methods = Get-MgUserAuthenticationMethod -UserId $pid -ErrorAction Stop
$resistant = $methods | Where-Object {
$_.AdditionalProperties[''@odata.type''] -in @(
''#microsoft.graph.fido2AuthenticationMethod''
''#microsoft.graph.windowsHelloForBusinessAuthenticationMethod''
''#microsoft.graph.x509CertificateAuthenticationMethod''
) -or (
$_.AdditionalProperties[''@odata.type''] -eq ''#microsoft.graph.microsoftAuthenticatorAuthenticationMethod'' -and
$_.AdditionalProperties[''deviceTag''] -eq ''SoftwareTokenActivated'' # device-bound indicator; verify per tenant
)
}
if (-not $resistant) {
$missing += [pscustomobject]@{
PrincipalId = $pid
Roles = ($roles.Assignments | Where-Object PrincipalId -eq $pid).RoleName -join '', ''
}
}
}
catch {
$missing += [pscustomobject]@{
PrincipalId = $pid
Roles = ''Unknown''
Error = $_.Exception.Message
}
}
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Test-Fsi-PhishingResistantCoverage''
Status = if ($missing) { ''Anomaly'' } else { ''Clean'' }
Reason = if ($missing) {
"$($missing.Count) of $($uniquePrincipals.Count) privileged principals lack a registered phishing-resistant authenticator."
} else {
"All $($uniquePrincipals.Count) privileged principals have at least one phishing-resistant authenticator registered."
}
MissingCoverage = @($missing)
TimestampUtc = [DateTime]::UtcNow
}
}
7.2 Test-Fsi-LegacyAuthBlocked
Asserts a tenant CA policy is in place to block legacy authentication clients (POP, IMAP, SMTP AUTH, ActiveSync basic auth, EWS basic auth). Required precursor for any phishing-resistant grant to be effective.
function Test-Fsi-LegacyAuthBlocked {
<#
.SYNOPSIS
Verifies a CA policy blocks legacy authentication client app types.
.DESCRIPTION
Phishing-resistant Authentication Strength does NOT block legacy auth clients on its own.
A separate CA policy must block clientAppTypes ''exchangeActiveSync'' and ''other'' (basic auth).
This helper confirms at least one ENABLED policy with grantControl = block targeting those
client app types exists.
.OUTPUTS
[pscustomobject] with Status, Reason, MatchingPolicies.
#>
[CmdletBinding()]
param()
$snap = Get-Fsi-CAPolicySnapshot
if ($snap.Status -ne ''Clean'') {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Test-Fsi-LegacyAuthBlocked''
Status = ''Error''; Reason = $snap.Reason
MatchingPolicies = @(); TimestampUtc = [DateTime]::UtcNow
}
}
$matches = $snap.Policies | Where-Object {
$_.State -eq ''enabled'' -and
$_.GrantBuiltInControls -contains ''block'' -and
($_.ClientAppTypes -contains ''exchangeActiveSync'' -or $_.ClientAppTypes -contains ''other'')
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Test-Fsi-LegacyAuthBlocked''
Status = if ($matches) { ''Clean'' } else { ''Anomaly'' }
Reason = if ($matches) {
"Found $($matches.Count) enabled CA policy/policies blocking legacy auth client app types."
} else {
''No enabled CA policy blocks legacy auth (clientAppTypes exchangeActiveSync / other). Phishing-resistant grant is bypassable via basic auth.''
}
MatchingPolicies = @($matches | Select-Object Id, DisplayName, ClientAppTypes)
TimestampUtc = [DateTime]::UtcNow
}
}
7.3 Test-Fsi-BreakGlassExclusions
Asserts: (a) the break-glass group exists and contains exactly 2 accounts; (b) every CA policy excludes that group. Combines a count check with New-Fsi-CAPolicy-BreakGlassExclusion.
function Test-Fsi-BreakGlassExclusions {
<#
.SYNOPSIS
Asserts the break-glass group has exactly 2 members and is excluded from every CA policy.
.DESCRIPTION
Per Control 2.8, the break-glass group should contain exactly 2 dedicated accounts (no
primary admin, no shared mailbox). Every CA policy must exclude this group so that an
enforcement misconfiguration cannot lock the entire tenant out.
.PARAMETER BreakGlassGroupId
Object id of the break-glass group.
.OUTPUTS
[pscustomobject] with Status, Reason, MemberCount, OffendingPolicies.
.NOTES
Defect catalogue #4. This helper does NOT verify credential escrow, quarterly testing, or
roster attestation — those are Control 2.8 / SOX 404 evidence collection items.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $BreakGlassGroupId
)
$reasons = @()
$offenders = @()
$memberCount = -1
try {
$members = Get-MgGroupMember -GroupId $BreakGlassGroupId -All -ErrorAction Stop
$memberCount = $members.Count
if ($memberCount -ne 2) {
$reasons += "Break-glass group has $memberCount member(s); required exactly 2."
}
}
catch {
$reasons += "Could not enumerate break-glass group members: $($_.Exception.Message)"
}
$excl = New-Fsi-CAPolicy-BreakGlassExclusion -BreakGlassExclusionGroupId $BreakGlassGroupId
if ($excl.Status -ne ''Clean'') {
$reasons += $excl.Reason
$offenders = $excl.OffendingPolicies
}
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Test-Fsi-BreakGlassExclusions''
Status = if ($reasons) { ''Anomaly'' } else { ''Clean'' }
Reason = if ($reasons) { $reasons -join '' | '' } else { ''Break-glass group has 2 members and is excluded from every CA policy.'' }
MemberCount = $memberCount
OffendingPolicies = @($offenders)
TimestampUtc = [DateTime]::UtcNow
}
}
§8 — Sentinel Wiring Stub
Invoke-Fsi-CAInsightsWorkbookCheck
Verifies the Microsoft Sentinel workspace is ingesting both SigninLogs and AADServicePrincipalSignInLogs — the latter is the only Log Analytics surface for workload identity / agent sign-ins (defect catalogue #6). Cross-references Control 3.9 Microsoft Sentinel Integration.
function Invoke-Fsi-CAInsightsWorkbookCheck {
<#
.SYNOPSIS
Verifies Sentinel workspace ingests both SigninLogs and AADServicePrincipalSignInLogs.
.DESCRIPTION
Workload identity / Entra Agent ID sign-ins do NOT appear in SigninLogs. They appear ONLY
in AADServicePrincipalSignInLogs. Operators who only check SigninLogs will see zero agent
activity even when agents are actively signing in. This helper queries Log Analytics for
each table''s row count over the last 24h and emits Anomaly if either is empty.
.PARAMETER WorkspaceId
Sentinel / Log Analytics workspace ID.
.PARAMETER LookbackHours
Defaults to 24.
.OUTPUTS
[pscustomobject] with Status, Reason, TableCounts.
.NOTES
Requires the Az.OperationalInsights module and Microsoft Sentinel Reader (or Log Analytics
Reader) on the workspace. Companion to Control 3.9.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $WorkspaceId,
[int] $LookbackHours = 24
)
$tables = @(''SigninLogs'', ''AADServicePrincipalSignInLogs'')
$counts = @{}
foreach ($t in $tables) {
$kql = "$t | where TimeGenerated > ago(${LookbackHours}h) | summarize Rows = count()"
try {
$r = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kql -ErrorAction Stop
$counts[$t] = [int]($r.Results[0].Rows)
}
catch {
$counts[$t] = -1 # sentinel for query failure
}
}
$missing = $counts.GetEnumerator() | Where-Object { $_.Value -le 0 }
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Invoke-Fsi-CAInsightsWorkbookCheck''
Status = if ($missing) { ''Anomaly'' } else { ''Clean'' }
Reason = if ($missing) {
"The following sign-in tables had zero rows or failed to query in the last ${LookbackHours}h: $(($missing | ForEach-Object { $_.Key }) -join '', ''). Workload identity sign-ins live in AADServicePrincipalSignInLogs ONLY — not SigninLogs. See Control 3.9."
} else {
"Both sign-in tables ingesting: SigninLogs=$($counts[''SigninLogs'']), AADServicePrincipalSignInLogs=$($counts[''AADServicePrincipalSignInLogs''])."
}
TableCounts = $counts
WorkspaceId = $WorkspaceId
TimestampUtc = [DateTime]::UtcNow
}
}
See Control 3.9 Microsoft Sentinel Integration for workspace provisioning, table retention settings, and CA Insights workbook configuration. See Control 3.6 Orphaned Agent Detection for downstream alerting on workload-identity anomalies.
§9 — Orchestrator
Invoke-Fsi-Control111Setup
End-to-end orchestrator that consumes every helper above. Three modes:
ReadOnly— runs §3 inventory + §6 analytics + §7 verification; emits a signed evidence pack; never mutates.Verify— runs §3 + §7 only; intended for daily / weekly attestation runs; never mutates.Enforce— runs the full §4 → §5 → §7 sequence in dependency order, with-WhatIfautomatically applied unless the operator confirms via the additional-IUnderstandThisMutatesProductionswitch.
function Invoke-Fsi-Control111Setup {
<#
.SYNOPSIS
End-to-end orchestrator for Control 1.11. Modes: ReadOnly | Verify | Enforce.
.DESCRIPTION
Composes every Control 1.11 helper into a single deterministic flow with transcript,
structured evidence emission (per _shared/powershell-baseline.md §5), and SHA-256 manifest.
Refuses to enter Enforce mode without (a) explicit -IUnderstandThisMutatesProduction switch,
(b) PIM-activated Entra Global Admin context, (c) successful 7-day report-only review per §6.
.PARAMETER Mode
ReadOnly | Verify | Enforce.
.PARAMETER EvidenceRoot
Root directory for the timestamped evidence pack. Defaults to ./evidence/1.11.
.PARAMETER MakerGroupId, BreakGlassGroupId, NamedLocationId, PilotGroupId
Required for Enforce mode; ignored otherwise.
.PARAMETER IUnderstandThisMutatesProduction
Mandatory switch for Enforce mode; refuses to mutate without it.
.OUTPUTS
[pscustomobject] with Status, Reason, EvidencePath.
.NOTES
Per Control 2.12 (FINRA 3110 supervision), the orchestrator does NOT replace the supervisor''s
written sign-off. The evidence pack is INPUT to the supervisor''s review, not the supervisor''s
record. Per Control 2.6 (OCC 2011-12 / SR 11-7), any change to the authentication boundary
requires MRM re-validation BEFORE this orchestrator is run in Enforce mode.
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = ''High'')]
param(
[Parameter(Mandatory)][ValidateSet(''ReadOnly'',''Verify'',''Enforce'')][string] $Mode,
[string] $EvidenceRoot = (Join-Path -Path ''.'' -ChildPath ''evidence/1.11''),
[string] $TenantId,
[string] $MakerGroupId,
[string] $BreakGlassGroupId,
[string] $NamedLocationId,
[string] $PilotGroupId,
[string[]] $FidoAaguidAllowList,
[switch] $IUnderstandThisMutatesProduction
)
if ($Mode -eq ''Enforce'' -and -not $IUnderstandThisMutatesProduction) {
return [pscustomobject]@{
ControlId = ''1.11''; HelperName = ''Invoke-Fsi-Control111Setup''
Status = ''Pending''
Reason = ''Refusing Enforce without -IUnderstandThisMutatesProduction. Re-invoke with the switch only after MRM re-validation (Control 2.6) and supervisory approval (Control 2.12).''
TimestampUtc = [DateTime]::UtcNow
}
}
$stamp = [DateTime]::UtcNow.ToString(''yyyyMMddTHHmmssZ'')
$packPath = Join-Path $EvidenceRoot $stamp
New-Item -ItemType Directory -Path $packPath -Force | Out-Null
$transcript = Join-Path $packPath ''transcript.log''
Start-Transcript -Path $transcript -Force | Out-Null
$results = [System.Collections.Generic.List[object]]::new()
try {
# Phase 0: bootstrap
$session = Initialize-Fsi-Session111 -Mode $Mode -TenantId $TenantId
$results.Add($session)
# Phase 1: inventory (always)
$results.Add( (Get-Fsi-CAPolicySnapshot) )
$results.Add( (Get-Fsi-AuthenticationMethodsPolicy) )
$results.Add( (Get-Fsi-PrivilegedRoleAssignments) )
$wi = Get-Fsi-WorkloadIdentityInventory
$results.Add($wi)
# Phase 2: enforcement (Enforce only)
if ($Mode -eq ''Enforce'') {
foreach ($p in @(''MakerGroupId'',''BreakGlassGroupId'',''NamedLocationId'',''PilotGroupId'')) {
if (-not (Get-Variable -Name $p -ValueOnly -ErrorAction SilentlyContinue)) {
throw "Enforce mode requires -$p"
}
}
$auth = New-Fsi-PhishingResistantAuthStrength -FidoAaguidAllowList $FidoAaguidAllowList
$results.Add($auth)
$results.Add( (New-Fsi-CAPolicy-HumanPrivilegedMFA -AuthStrengthId $auth.AuthStrengthId -BreakGlassExclusionGroupId $BreakGlassGroupId) )
$results.Add( (New-Fsi-CAPolicy-MakerCompliantDevice -MakerGroupId $MakerGroupId -BreakGlassExclusionGroupId $BreakGlassGroupId) )
$inScopeIds = @($wi.InScope | Select-Object -ExpandProperty Id)
$results.Add( (New-Fsi-CAPolicy-WorkloadIdentity -InScopeServicePrincipalIds $inScopeIds -NamedLocationId $NamedLocationId) )
$results.Add( (New-Fsi-CAPolicy-SessionControls -PilotGroupId $PilotGroupId -BreakGlassExclusionGroupId $BreakGlassGroupId -PilotGroupOnly) )
$results.Add( (New-Fsi-CAPolicy-BreakGlassExclusion -BreakGlassExclusionGroupId $BreakGlassGroupId) )
}
# Phase 3: report-only review (ReadOnly + Enforce)
if ($Mode -ne ''Verify'') {
$results.Add( (Invoke-Fsi-CAReportOnlyReview -LookbackDays 7) )
}
# Phase 4: verification (always)
$results.Add( (Test-Fsi-PhishingResistantCoverage) )
$results.Add( (Test-Fsi-LegacyAuthBlocked) )
if ($BreakGlassGroupId) {
$results.Add( (Test-Fsi-BreakGlassExclusions -BreakGlassGroupId $BreakGlassGroupId) )
}
# Phase 5: emit evidence pack
$results | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $packPath ''results.json'') -Encoding UTF8
# SHA-256 manifest per _shared/powershell-baseline.md §5
$manifest = Get-ChildItem $packPath -File | ForEach-Object {
[pscustomobject]@{
File = $_.Name
Sha256 = (Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash
SizeBytes = $_.Length
}
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -Path (Join-Path $packPath ''manifest.json'') -Encoding UTF8
$anyAnomaly = $results | Where-Object { $_.Status -in @(''Anomaly'',''Error'') }
$overallStatus = if ($anyAnomaly) { ''Anomaly'' } else { ''Clean'' }
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Invoke-Fsi-Control111Setup''
Status = $overallStatus
Reason = "Mode=$Mode completed. $($results.Count) helpers run. $($anyAnomaly.Count) anomaly/error result(s). Evidence: $packPath"
EvidencePath = $packPath
HelperCount = $results.Count
AnomalyCount = $anyAnomaly.Count
TimestampUtc = [DateTime]::UtcNow
}
}
catch {
[pscustomobject]@{
ControlId = ''1.11''
HelperName = ''Invoke-Fsi-Control111Setup''
Status = ''Error''
Reason = "Orchestrator threw: $($_.Exception.Message)"
EvidencePath = $packPath
TimestampUtc = [DateTime]::UtcNow
}
}
finally {
Stop-Transcript | Out-Null
}
}
§10 — Evidence Pack Contract
The orchestrator emits the following artefacts to $EvidenceRoot/<UTC-timestamp>/:
| File | Contents |
|---|---|
transcript.log |
Full PowerShell transcript of the orchestrator run. |
results.json |
Array of every helper''s [pscustomobject] return, in execution order, depth-8 JSON. |
manifest.json |
SHA-256 hash and byte size of every file in the pack (per _shared/powershell-baseline.md §5). |
Retention. Per SEC Rule 17a-4(b)(4), CA-related sign-in evidence must be retained for at least 6 years in WORM-compliant storage. Per SOX 404, the evidence pack should be tagged for the relevant ICFR control owner. Per FINRA Rule 4511, electronic records require a designated principal''s review documented separately.
What this pack is NOT. The pack is the operator''s technical record of a deterministic helper run. It is not a supervisory record under FINRA Rule 3110 (see Control 2.12) and not a model-risk validation record under OCC 2011-12 / SR 11-7 (see Control 2.6). Those records are separate and are produced by named human reviewers.
§11 — Cross-References
Sister Playbooks for Control 1.11
- Portal Walkthrough — step-by-step Entra portal configuration
- Verification & Testing — test cases and evidence collection procedures
- Troubleshooting — common issues, error codes, and resolutions
Related Controls
| Control | Relationship |
|---|---|
| 2.8 Access Control and Segregation of Duties | Standing Global Admin is a 2.8 violation; the orchestrator''s Enforce mode requires PIM activation. |
| 2.26 Entra Agent ID Identity Governance | Agent ID principals are the workload-identity targets of §5.3 and the human-accountability tie-in for agents. |
| 1.21 Adversarial Input Logging | CA governs identity context only; runtime prompt / output inspection lives in 1.21. |
| 1.23 Step-Up Authentication for Agent Operations | Step-up authentication for sensitive agent operations is a complementary identity-plane control. |
| 1.24 Defender AI Security Posture Management | AI-SPM signals augment Conditional Access risk evaluation for AI-bearing identities. |
| 2.6 Model Risk Management (OCC 2011-12 / SR 11-7) | Material changes to the auth boundary are MRM-relevant; re-validate before Enforce. |
| 2.12 Supervision (FINRA 3110) | Supervisory sign-off for promotion to enabled. The orchestrator does not replace the supervisor. |
| 1.5 Data Loss Prevention and Sensitivity Labels | DLP and CA together provide identity + content defence-in-depth. |
| 2.25 Agent 365 Admin Center Governance Console | Centralised governance surface that complements identity-plane CA controls for agents. |
| 3.6 Orphaned Agent Detection | Downstream alerting on workload identities; consumes the inventory in §3.4. |
| 3.9 Microsoft Sentinel Integration | Provides the workspace and ingestion configuration verified by §8. |
External Resources
- Microsoft Learn: Conditional Access overview
- Microsoft Learn: Conditional Access for Workload Identities
- Microsoft Learn: Authentication strengths
- Microsoft Learn: Microsoft Entra Agent ID (preview)
- Microsoft Learn: Token Protection in Conditional Access (preview)
- Microsoft Learn: Continuous Access Evaluation
- NIST SP 800-63B: Digital Identity Guidelines — Authentication and Lifecycle Management
- FFIEC: Authentication and Access to Financial Institution Services and Systems (Aug 2021)
Updated: April 2026 | Version: v1.4.0 | UI Verification Status: Current