Skip to content

Commit fb98582

Browse files
Merge branch 'dev' of https://github.com/CyberDrain/CIPP into dev
Synced from CyberDrain/CIPP@7f5ae70
1 parent 2e4a635 commit fb98582

2 files changed

Lines changed: 215 additions & 0 deletions

File tree

Config/standards.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,6 +1627,33 @@
16271627
"recommendedBy": ["CIS", "CIPP"],
16281628
"requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"]
16291629
},
1630+
{
1631+
"name": "standards.DisableInactiveUsers",
1632+
"cat": "Entra (AAD) Standards",
1633+
"tag": ["CMMC (IA.L2-3.5.6)", "NIST SP 800-171 (3.5.6)"],
1634+
"helpText": "Blocks login for cloud-only member users that have not signed in for a configurable number of days (minimum 30). Includes accounts that have never signed in when the account is older than the threshold. Hybrid (on-premises synced) users are skipped. Users without sign-in activity data are not disabled.",
1635+
"docsDescription": "Disables enabled Member user accounts after a defined period of inactivity (minimum 30 days), supporting CMMC IA.L2-3.5.6 / NIST SP 800-171 3.5.6. Inactivity is based on signInActivity.lastSuccessfulSignInDateTime. Accounts that have never signed in (signInActivity present but no successful sign-in) are included when createdDateTime is older than the threshold. Users missing signInActivity entirely are skipped so incomplete Graph data cannot cause accidental disables. Hybrid-synced (onPremisesSyncEnabled) users are skipped because Entra disable often will not stick. Recently re-enabled accounts (last 7 days) are also skipped. Values below 30 days are rejected at runtime.",
1636+
"executiveText": "Automatically disables unused employee accounts that have not signed in for a configured number of days, reducing risk from dormant accounts and supporting CMMC / NIST inactive-identifier requirements. Hybrid directory-synced accounts are left alone so on-premises identity remains the source of truth for those users.",
1637+
"addedComponent": [
1638+
{
1639+
"type": "number",
1640+
"name": "standards.DisableInactiveUsers.days",
1641+
"required": true,
1642+
"defaultValue": 180,
1643+
"label": "Days of inactivity (minimum 30)",
1644+
"validators": {
1645+
"min": { "value": 30, "message": "Minimum value is 30" }
1646+
}
1647+
}
1648+
],
1649+
"label": "Disable Member accounts that have not logged on for a number of days",
1650+
"impact": "High Impact",
1651+
"impactColour": "danger",
1652+
"addedDate": "2026-07-22",
1653+
"powershellEquivalent": "Get-MgUser -Property SignInActivity & Update-MgUser -AccountEnabled $false",
1654+
"recommendedBy": ["CIPP", "CMMC"],
1655+
"requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"]
1656+
},
16301657
{
16311658
"name": "standards.OauthConsent",
16321659
"cat": "Entra (AAD) Standards",
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
function Invoke-CIPPStandardDisableInactiveUsers {
2+
<#
3+
.FUNCTIONALITY
4+
Internal
5+
.COMPONENT
6+
(APIName) DisableInactiveUsers
7+
.SYNOPSIS
8+
(Label) Disable Member accounts that have not logged on for a number of days
9+
.DESCRIPTION
10+
(Helptext) Blocks login for cloud-only member users that have not signed in for a configurable number of days (minimum 30). Includes accounts that have never signed in when the account is older than the threshold. Hybrid (on-premises synced) users are skipped. Users without sign-in activity data are not disabled.
11+
(DocsDescription) Disables enabled Member user accounts after a defined period of inactivity (minimum 30 days), supporting CMMC IA.L2-3.5.6 / NIST SP 800-171 3.5.6. Inactivity is based on signInActivity.lastSuccessfulSignInDateTime. Accounts that have never signed in (signInActivity present but no successful sign-in) are included when createdDateTime is older than the threshold. Users missing signInActivity entirely are skipped so incomplete Graph data cannot cause accidental disables. Hybrid-synced (onPremisesSyncEnabled) users are skipped because Entra disable often will not stick. Recently re-enabled accounts (last 7 days) are also skipped. Values below 30 days are rejected at runtime.
12+
.NOTES
13+
CAT
14+
Entra (AAD) Standards
15+
TAG
16+
"CMMC (IA.L2-3.5.6)"
17+
"NIST SP 800-171 (3.5.6)"
18+
EXECUTIVETEXT
19+
Automatically disables unused employee accounts that have not signed in for a configured number of days, reducing risk from dormant accounts and supporting CMMC / NIST inactive-identifier requirements. Hybrid directory-synced accounts are left alone so on-premises identity remains the source of truth for those users.
20+
ADDEDCOMPONENT
21+
{"type":"number","name":"standards.DisableInactiveUsers.days","required":true,"defaultValue":180,"label":"Days of inactivity (minimum 30)","validators":{"min":{"value":30,"message":"Minimum value is 30"}}}
22+
IMPACT
23+
High Impact
24+
ADDEDDATE
25+
2026-07-22
26+
POWERSHELLEQUIVALENT
27+
Get-MgUser -Property SignInActivity & Update-MgUser -AccountEnabled $false
28+
RECOMMENDEDBY
29+
"CIPP"
30+
"CMMC"
31+
REQUIREDCAPABILITIES
32+
"AAD_PREMIUM"
33+
"AAD_PREMIUM_P2"
34+
UPDATECOMMENTBLOCK
35+
Run the Tools\Update-StandardsComments.ps1 script to update this comment block
36+
.LINK
37+
https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards
38+
#>
39+
40+
param($Tenant, $Settings)
41+
$TestResult = Test-CIPPStandardLicense -StandardName 'DisableInactiveUsers' -TenantFilter $Tenant -Preset Entra
42+
43+
if ($TestResult -eq $false) {
44+
foreach ($Template in $Settings.TemplateList) {
45+
Set-CIPPStandardsCompareField -FieldName 'standards.DisableInactiveUsers' -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant
46+
}
47+
return $true
48+
}
49+
50+
$checkDays = if ($Settings.days) { [int]$Settings.days } else { 180 }
51+
if ($checkDays -lt 30) {
52+
Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "DisableInactiveUsers: days ($checkDays) is below the minimum of 30 days. Skipping run to prevent mass user changes." -Sev Error
53+
return
54+
}
55+
$Days = (Get-Date).AddDays(-$checkDays).ToUniversalTime()
56+
$Lookup = $Days.ToString('o')
57+
$AuditLookup = (Get-Date).AddDays(-7).ToUniversalTime().ToString('o')
58+
59+
try {
60+
$GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=createdDateTime le $Lookup and userType eq 'Member' and accountEnabled eq true&`$select=id,userPrincipalName,displayName,signInActivity,mail,userType,accountEnabled,createdDateTime,onPremisesSyncEnabled" -scope 'https://graph.microsoft.com/.default' -tenantid $Tenant
61+
62+
$InactiveUsers = foreach ($user in $GraphRequest) {
63+
if ($user.onPremisesSyncEnabled -eq $true) { continue }
64+
65+
# Missing signInActivity means incomplete Graph data — do not treat as never signed in
66+
if (-not $user.signInActivity) { continue }
67+
68+
if ($user.signInActivity.lastSuccessfulSignInDateTime) {
69+
$lastSignIn = [datetime]$user.signInActivity.lastSuccessfulSignInDateTime
70+
if ($lastSignIn.ToUniversalTime() -le $Days) {
71+
$user | Add-Member -NotePropertyName 'EnrichedLastSignInDateTime' -NotePropertyValue $user.signInActivity.lastSuccessfulSignInDateTime -Force
72+
$user | Add-Member -NotePropertyName 'NeverSignedIn' -NotePropertyValue $false -Force
73+
$user
74+
}
75+
} else {
76+
# signInActivity present but no successful sign-in; createdDateTime already <= $Days via server-side filter
77+
$user | Add-Member -NotePropertyName 'EnrichedLastSignInDateTime' -NotePropertyValue $null -Force
78+
$user | Add-Member -NotePropertyName 'NeverSignedIn' -NotePropertyValue $true -Force
79+
$user
80+
}
81+
}
82+
$GraphRequest = @($InactiveUsers)
83+
} catch {
84+
$ErrorMessage = Get-NormalizedError -Message $_.Exception.Message
85+
Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableInactiveUsers state for $Tenant. Error: $ErrorMessage" -Sev Error
86+
return
87+
}
88+
89+
$AuditResults = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/directoryAudits?`$filter=activityDisplayName eq 'Enable account' and activityDateTime ge $AuditLookup" -scope 'https://graph.microsoft.com/.default' -tenantid $Tenant
90+
$RecentlyReactivatedUsers = @(foreach ($AuditEntry in $AuditResults) { $AuditEntry.targetResources[0].id }) | Select-Object -Unique
91+
92+
$GraphRequest = @($GraphRequest | Where-Object { -not ($RecentlyReactivatedUsers -contains $_.id) })
93+
94+
if ($Settings.remediate -eq $true) {
95+
if ($GraphRequest.Count -gt 0) {
96+
$UpdateDB = $false
97+
$int = 0
98+
$BulkRequests = foreach ($user in $GraphRequest) {
99+
@{
100+
id = $int++
101+
method = 'PATCH'
102+
url = "users/$($user.id)"
103+
body = @{ accountEnabled = $false }
104+
'headers' = @{
105+
'Content-Type' = 'application/json'
106+
}
107+
}
108+
}
109+
110+
try {
111+
$BulkResults = New-GraphBulkRequest -tenantid $Tenant -Requests @($BulkRequests)
112+
113+
for ($i = 0; $i -lt $BulkResults.Count; $i++) {
114+
$result = $BulkResults[$i]
115+
$user = $GraphRequest[$i]
116+
117+
if ($result.status -eq 200 -or $result.status -eq 204) {
118+
$user.accountEnabled = $false
119+
$UpdateDB = $true
120+
$reason = if ($user.NeverSignedIn) {
121+
"never signed in; account created $($user.createdDateTime)"
122+
} else {
123+
"last sign-in: $($user.EnrichedLastSignInDateTime)"
124+
}
125+
Write-LogMessage -API 'Standards' -tenant $Tenant -message "Disabled inactive user $($user.userPrincipalName) ($($user.id)). Reason: $reason" -sev Info
126+
} else {
127+
$errorMsg = if ($result.body.error.message) { $result.body.error.message } else { "Unknown error (Status: $($result.status))" }
128+
Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to disable inactive user $($user.userPrincipalName) ($($user.id)): $errorMsg" -sev Error
129+
}
130+
}
131+
} catch {
132+
$ErrorMessage = Get-CippException -Exception $_
133+
Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to process bulk disable inactive users request: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage
134+
}
135+
136+
if ($UpdateDB) {
137+
try {
138+
Set-CIPPDBCacheUsers -TenantFilter $Tenant
139+
} catch {
140+
Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to refresh user cache after remediation: $($_.Exception.Message)" -sev Warning
141+
}
142+
}
143+
} else {
144+
Write-LogMessage -API 'Standards' -tenant $Tenant -message "No member accounts inactive longer than $checkDays days - all cloud-only member accounts are already compliant." -sev Info
145+
}
146+
}
147+
148+
if ($Settings.alert -eq $true) {
149+
$AlertUsers = @($GraphRequest | Where-Object { $_.accountEnabled })
150+
if ($AlertUsers.Count -gt 0) {
151+
$Filtered = $AlertUsers | Select-Object -Property userPrincipalName, id, displayName, signInActivity, EnrichedLastSignInDateTime, NeverSignedIn, mail, userType, accountEnabled, createdDateTime, onPremisesSyncEnabled
152+
$NeverSignedInCount = @($Filtered | Where-Object { $_.NeverSignedIn }).Count
153+
$StaleCount = $Filtered.Count - $NeverSignedInCount
154+
$AlertMessage = "Inactive member accounts found: $($AlertUsers.Count) total ($StaleCount inactive >$checkDays days, $NeverSignedInCount never signed in and older than $checkDays days)"
155+
Write-StandardsAlert -message $AlertMessage -object $Filtered -tenant $Tenant -standardName 'DisableInactiveUsers' -standardId $Settings.standardId
156+
Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info
157+
} else {
158+
Write-LogMessage -API 'Standards' -tenant $Tenant -message "No inactive member accounts found (threshold: $checkDays days)." -sev Info
159+
}
160+
}
161+
162+
if ($Settings.report -eq $true) {
163+
$Filtered = $GraphRequest | Where-Object { $_.accountEnabled } | Select-Object -Property userPrincipalName, id, displayName, signInActivity, EnrichedLastSignInDateTime, NeverSignedIn, mail, userType, accountEnabled, createdDateTime, onPremisesSyncEnabled
164+
$NeverSignedInUsers = @($Filtered | Where-Object { $_.NeverSignedIn })
165+
$StaleSignIns = @($Filtered | Where-Object { -not $_.NeverSignedIn })
166+
167+
$CurrentValue = [PSCustomObject]@{
168+
UsersDisabledAfterDays = $checkDays
169+
UsersDisabledAccountCount = $Filtered.Count
170+
UsersStaleSignInCount = $StaleSignIns.Count
171+
UsersNeverSignedInCount = $NeverSignedInUsers.Count
172+
UsersDisabledAccountDetails = @($Filtered)
173+
UsersNeverSignedInDetails = $NeverSignedInUsers
174+
}
175+
176+
$ExpectedValue = [PSCustomObject]@{
177+
UsersDisabledAfterDays = $checkDays
178+
UsersDisabledAccountCount = 0
179+
UsersStaleSignInCount = 0
180+
UsersNeverSignedInCount = 0
181+
UsersDisabledAccountDetails = @()
182+
UsersNeverSignedInDetails = @()
183+
}
184+
185+
Set-CIPPStandardsCompareField -FieldName 'standards.DisableInactiveUsers' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant
186+
Add-CIPPBPAField -FieldName 'DisableInactiveUsers' -FieldValue $Filtered -StoreAs json -Tenant $Tenant
187+
}
188+
}

0 commit comments

Comments
 (0)