Entra/EntraUserGroupChecks.ps1

# -------------------------------------------------------------------
# Entra ID -- User, Group, App & Organization Checks
# Extracted from Get-EntraSecurityConfig.ps1 (#256)
# Runs in shared scope: $settings, $checkIdCounter, Add-Setting,
# $context, $authPolicy, $orgSettings
# -------------------------------------------------------------------
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
param()

# ------------------------------------------------------------------
# 3-5. Authorization Policy (user consent, app registration, groups)
# ------------------------------------------------------------------
if ($authPolicy) {
    # 3. User Consent for Applications
    try {
        $consentPolicy = $authPolicy['defaultUserRolePermissions']['permissionGrantPoliciesAssigned']

        $consentValue = if ($consentPolicy -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy') {
            'Allow user consent (legacy)'
        }
        elseif ($consentPolicy -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-low') {
            'Allow user consent for low-impact apps'
        }
        elseif ($consentPolicy.Count -eq 0 -or $null -eq $consentPolicy) {
            'Do not allow user consent'
        }
        else {
            ($consentPolicy -join '; ')
        }

        $consentStatus = if ($consentPolicy.Count -eq 0 -or $null -eq $consentPolicy) { 'Pass' } else { 'Fail' }

        $settingParams = @{
            Category         = 'Application Consent'
            Setting          = 'User Consent for Applications'
            CurrentValue     = $consentValue
            RecommendedValue = 'Do not allow user consent'
            Status           = $consentStatus
            CheckId          = 'ENTRA-CONSENT-001'
            Remediation      = 'Run: Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{PermissionGrantPoliciesAssigned = @()}. Entra admin center > Enterprise applications > Consent and permissions.'
        }
        Add-Setting @settingParams
    }
    catch {
        Write-Warning "Could not check user consent policy: $_"
    }

    # 4. Users Can Register Applications
    try {
        $canRegister = $authPolicy['defaultUserRolePermissions']['allowedToCreateApps']

        $settingParams = @{
            Category         = 'Application Consent'
            Setting          = 'Users Can Register Applications'
            CurrentValue     = "$canRegister"
            RecommendedValue = 'False'
            Status           = $(if (-not $canRegister) { 'Pass' } else { 'Fail' })
            CheckId          = 'ENTRA-APPREG-001'
            Remediation      = 'Run: Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{AllowedToCreateApps = $false}. Entra admin center > Users > User settings.'
        }
        Add-Setting @settingParams
    }
    catch {
        Write-Warning "Could not check app registration policy: $_"
    }

    # 5. Users Can Create Security Groups
    try {
        $canCreateGroups = $authPolicy['defaultUserRolePermissions']['allowedToCreateSecurityGroups']
        $settingParams = @{
            Category         = 'Directory Settings'
            Setting          = 'Users Can Create Security Groups'
            CurrentValue     = "$canCreateGroups"
            RecommendedValue = 'False'
            Status           = $(if (-not $canCreateGroups) { 'Pass' } else { 'Warning' })
            CheckId          = 'ENTRA-GROUP-001'
            Remediation      = 'Run: Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{AllowedToCreateSecurityGroups = $false}. Entra admin center > Groups > General.'
        }
        Add-Setting @settingParams
    }
    catch {
        Write-Warning "Could not check group creation policy: $_"
    }

    # 5b. Restrict Non-Admin Tenant Creation (CIS 5.1.2.3)
    try {
        $canCreateTenants = $authPolicy['defaultUserRolePermissions']['allowedToCreateTenants']
        $settingParams = @{
            Category         = 'Directory Settings'
            Setting          = 'Non-Admin Tenant Creation Restricted'
            CurrentValue     = "$canCreateTenants"
            RecommendedValue = 'False'
            Status           = $(if (-not $canCreateTenants) { 'Pass' } else { 'Warning' })
            CheckId          = 'ENTRA-TENANT-001'
            Remediation      = 'Run: Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{AllowedToCreateTenants = $false}. Entra admin center > Users > User settings.'
        }
        Add-Setting @settingParams
    }
    catch {
        Write-Warning "Could not check tenant creation policy: $_"
    }
}

# ------------------------------------------------------------------
# 6. Admin Consent Workflow
# ------------------------------------------------------------------
try {
    Write-Verbose "Checking admin consent workflow..."
    $graphParams = @{
        Method      = 'GET'
        Uri         = '/v1.0/policies/adminConsentRequestPolicy'
        ErrorAction = 'Stop'
    }
    $adminConsentSettings = Invoke-MgGraphRequest @graphParams
    $isAdminConsentEnabled = $adminConsentSettings['isEnabled']

    $settingParams = @{
        Category         = 'Application Consent'
        Setting          = 'Admin Consent Workflow Enabled'
        CurrentValue     = "$isAdminConsentEnabled"
        RecommendedValue = 'True'
        Status           = $(if ($isAdminConsentEnabled) { 'Pass' } else { 'Warning' })
        CheckId          = 'ENTRA-CONSENT-002'
        Remediation      = 'Run: Update-MgPolicyAdminConsentRequestPolicy -IsEnabled $true. Entra admin center > Enterprise applications > Admin consent requests.'
    }
    Add-Setting @settingParams
}
catch {
    Write-Warning "Could not check admin consent workflow: $_"
}

# ------------------------------------------------------------------
# 10. External Collaboration Settings (reuses $authPolicy from section 3-5)
# ------------------------------------------------------------------
if ($authPolicy) {
    try {
        $guestInviteSettings = $authPolicy['allowInvitesFrom']
        $guestAccessRestriction = $authPolicy['guestUserRoleId']

        $inviteDisplay = switch ($guestInviteSettings) {
            'none' { 'No one can invite' }
            'adminsAndGuestInviters' { 'Admins and guest inviters only' }
            'adminsGuestInvitersAndAllMembers' { 'All members can invite' }
            'everyone' { 'Everyone including guests' }
            default { $guestInviteSettings }
        }

        $inviteStatus = switch ($guestInviteSettings) {
            'none' { 'Pass' }
            'adminsAndGuestInviters' { 'Pass' }
            'adminsGuestInvitersAndAllMembers' { 'Review' }
            'everyone' { 'Warning' }
            default { 'Review' }
        }

        $settingParams = @{
            Category         = 'External Collaboration'
            Setting          = 'Guest Invitation Policy'
            CurrentValue     = $inviteDisplay
            RecommendedValue = 'Admins and guest inviters only'
            Status           = $inviteStatus
            CheckId          = 'ENTRA-GUEST-002'
            Remediation      = 'Run: Update-MgPolicyAuthorizationPolicy -AllowInvitesFrom ''adminsAndGuestInviters''. Entra admin center > External Identities > External collaboration settings.'
        }
        Add-Setting @settingParams

        # Guest user role
        $roleDisplay = switch ($guestAccessRestriction) {
            'a0b1b346-4d3e-4e8b-98f8-753987be4970' { 'Same as member users' }
            '10dae51f-b6af-4016-8d66-8c2a99b929b3' { 'Limited access (default)' }
            '2af84b1e-32c8-42b7-82bc-daa82404023b' { 'Restricted access' }
            default { $guestAccessRestriction }
        }

        $settingParams = @{
            Category         = 'External Collaboration'
            Setting          = 'Guest User Access Restriction'
            CurrentValue     = $roleDisplay
            RecommendedValue = 'Restricted access'
            Status           = $(if ($guestAccessRestriction -eq '2af84b1e-32c8-42b7-82bc-daa82404023b') { 'Pass' } else { 'Warning' })
            CheckId          = 'ENTRA-GUEST-001'
            Remediation      = 'Run: Update-MgPolicyAuthorizationPolicy -GuestUserRoleId ''2af84b1e-32c8-42b7-82bc-daa82404023b''. Entra admin center > External Identities > External collaboration settings.'
        }
        Add-Setting @settingParams
    }
    catch {
        Write-Warning "Could not check external collaboration: $_"
    }
}

# ------------------------------------------------------------------
# 12. Guest User Summary
# ------------------------------------------------------------------
try {
    Write-Verbose "Counting guest users..."
    $graphParams = @{
        Method      = 'GET'
        Uri         = "/v1.0/users/`$count?`$filter=userType eq 'Guest'"
        Headers     = @{ 'ConsistencyLevel' = 'eventual' }
        ErrorAction = 'Stop'
    }
    $guestCount = Invoke-MgGraphRequest @graphParams
    $settingParams = @{
        Category         = 'External Collaboration'
        Setting          = 'Guest User Count'
        CurrentValue     = "$guestCount"
        RecommendedValue = 'Review periodically'
        Status           = 'Info'
        CheckId          = 'ENTRA-GUEST-003'
        Remediation      = 'Informational — review and remove stale guest accounts periodically. Entra admin center > Users > Guest users.'
    }
    Add-Setting @settingParams
}
catch {
    Write-Warning "Could not count guest users: $_"
}

# ------------------------------------------------------------------
# 14. LinkedIn Account Connections (CIS 5.1.2.6)
# ------------------------------------------------------------------
try {
    Write-Verbose "Checking LinkedIn account connections..."
    $tenantId = $context.TenantId
    $graphParams = @{
        Method      = 'GET'
        Uri         = "/beta/organization/$tenantId"
        ErrorAction = 'Stop'
    }
    $orgSettings = Invoke-MgGraphRequest @graphParams

    $linkedInEnabled = $true  # Default assumption
    if ($orgSettings -and $orgSettings['linkedInConfiguration']) {
        $linkedInEnabled = -not $orgSettings['linkedInConfiguration']['isDisabled']
    }

    $settingParams = @{
        Category         = 'Directory Settings'
        Setting          = 'LinkedIn Account Connections'
        CurrentValue     = $(if ($linkedInEnabled) { 'Enabled' } else { 'Disabled' })
        RecommendedValue = 'Disabled'
        Status           = $(if (-not $linkedInEnabled) { 'Pass' } else { 'Fail' })
        CheckId          = 'ENTRA-LINKEDIN-001'
        Remediation      = 'Entra admin center > Users > User settings > LinkedIn account connections > No. Prevents data leakage between LinkedIn and organizational directory.'
    }
    Add-Setting @settingParams
}
catch {
    Write-Warning "Could not check LinkedIn account connections: $_"
}

# ------------------------------------------------------------------
# 15. Per-user MFA Disabled (CIS 5.1.2.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "Checking per-user MFA state..."
    $graphParams = @{
        Method      = 'GET'
        Uri         = '/beta/reports/authenticationMethods/userRegistrationDetails?$select=userPrincipalName,isMfaRegistered,isMfaCapable&$top=1'
        ErrorAction = 'Stop'
    }
    Invoke-MgGraphRequest @graphParams | Out-Null
    # Graph doesn't directly expose legacy per-user MFA state (MSOnline concept).
    # We confirm API access works, then emit Review since we can't verify enforcement mode.
    $settingParams = @{
        Category         = 'Authentication Methods'
        Setting          = 'Per-user MFA (Legacy)'
        CurrentValue     = 'Review -- verify no per-user MFA states are set to Enforced or Enabled'
        RecommendedValue = 'All per-user MFA disabled (use CA policies)'
        Status           = 'Review'
        CheckId          = 'ENTRA-PERUSER-001'
        Remediation      = 'Entra admin center > Users > Per-user MFA > Ensure all users show Disabled. Use Conditional Access policies for MFA enforcement instead of per-user MFA.'
    }
    Add-Setting @settingParams
}
catch {
    Write-Warning "Could not check per-user MFA: $_"
    $settingParams = @{
        Category         = 'Authentication Methods'
        Setting          = 'Per-user MFA (Legacy)'
        CurrentValue     = 'Could not query -- verify manually'
        RecommendedValue = 'All per-user MFA disabled (use CA policies)'
        Status           = 'Review'
        CheckId          = 'ENTRA-PERUSER-001'
        Remediation      = 'Entra admin center > Users > Per-user MFA > Ensure all users show Disabled. Use Conditional Access policies for MFA enforcement instead.'
    }
    Add-Setting @settingParams
}

# ------------------------------------------------------------------
# 16. Third-party Integrated Apps Blocked (CIS 5.1.2.2)
# ------------------------------------------------------------------
if ($authPolicy) {
    try {
        Write-Verbose "Checking third-party integrated apps..."
        $allowedToCreateApps = $authPolicy['defaultUserRolePermissions']['allowedToCreateApps']
        # CIS 5.1.2.2 checks that third-party integrated apps are not allowed
        # This is closely related to ENTRA-APPREG-001 but specifically targets integrated apps
        $settingParams = @{
            Category         = 'Application Consent'
            Setting          = 'Third-party Integrated Apps Restricted'
            CurrentValue     = $(if (-not $allowedToCreateApps) { 'Restricted' } else { 'Allowed' })
            RecommendedValue = 'Restricted'
            Status           = $(if (-not $allowedToCreateApps) { 'Pass' } else { 'Fail' })
            CheckId          = 'ENTRA-APPS-001'
            Remediation      = 'Entra admin center > Users > User settings > Users can register applications > No. Also review Enterprise applications > User settings > Users can consent to apps.'
        }
        Add-Setting @settingParams
    }
    catch {
        Write-Warning "Could not check third-party app restrictions: $_"
    }
}

# ------------------------------------------------------------------
# 17. Guest Invitation Domain Restrictions (CIS 5.1.6.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "Checking guest invitation domain restrictions..."
    $graphParams = @{
        Method      = 'GET'
        Uri         = '/v1.0/policies/crossTenantAccessPolicy/default'
        ErrorAction = 'Stop'
    }
    $crossTenantPolicy = Invoke-MgGraphRequest @graphParams

    $b2bCollabInbound = $crossTenantPolicy['b2bCollaborationInbound']
    $isRestricted = $false
    if ($b2bCollabInbound -and $b2bCollabInbound['applications']) {
        $accessType = $b2bCollabInbound['applications']['accessType']
        $isRestricted = ($accessType -eq 'blocked' -or $accessType -eq 'allowed')
    }

    # Also check authorizationPolicy allowInvitesFrom
    $invitesFrom = if ($authPolicy) { $authPolicy['allowInvitesFrom'] } else { 'unknown' }
    $domainRestricted = ($invitesFrom -ne 'everyone') -and $isRestricted

    $settingParams = @{
        Category         = 'External Collaboration'
        Setting          = 'Guest Invitation Domain Restrictions'
        CurrentValue     = $(if ($domainRestricted) { "Restricted (invites: $invitesFrom)" } else { "Open (invites: $invitesFrom)" })
        RecommendedValue = 'Restricted to allowed domains only'
        Status           = $(if ($invitesFrom -eq 'none' -or $domainRestricted) { 'Pass' } elseif ($invitesFrom -ne 'everyone') { 'Review' } else { 'Fail' })
        CheckId          = 'ENTRA-GUEST-004'
        Remediation      = 'Entra admin center > External Identities > External collaboration settings > Collaboration restrictions > Allow invitations only to the specified domains.'
    }
    Add-Setting @settingParams
}
catch {
    Write-Warning "Could not check guest invitation restrictions: $_"
}

# ------------------------------------------------------------------
# 18. Dynamic Group for Guest Users (CIS 5.1.3.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "Checking for dynamic guest group..."
    $graphParams = @{
        Method      = 'GET'
        Uri         = "/v1.0/groups?`$filter=groupTypes/any(g:g eq 'DynamicMembership')&`$select=displayName,membershipRule&`$top=999"
        ErrorAction = 'Stop'
    }
    $dynamicGroups = Invoke-MgGraphRequest @graphParams
    $dynamicGroupList = if ($dynamicGroups -and $dynamicGroups['value']) { @($dynamicGroups['value']) } else { @() }
    $guestGroups = @($dynamicGroupList | Where-Object {
        $_['membershipRule'] -and $_['membershipRule'] -match 'user\.userType\s+(-eq|-contains)\s+.?Guest'
    })

    if ($guestGroups.Count -gt 0) {
        $names = ($guestGroups | ForEach-Object { $_['displayName'] }) -join '; '
        $settingParams = @{
            Category         = 'External Collaboration'
            Setting          = 'Dynamic Group for Guest Users'
            CurrentValue     = "Yes ($($guestGroups.Count) group: $names)"
            RecommendedValue = 'At least 1 dynamic group for guests'
            Status           = 'Pass'
            CheckId          = 'ENTRA-GROUP-002'
            Remediation      = 'No action needed.'
        }
        Add-Setting @settingParams
    }
    else {
        $settingParams = @{
            Category         = 'External Collaboration'
            Setting          = 'Dynamic Group for Guest Users'
            CurrentValue     = 'No dynamic guest group found'
            RecommendedValue = 'At least 1 dynamic group for guests'
            Status           = 'Fail'
            CheckId          = 'ENTRA-GROUP-002'
            Remediation      = 'Entra admin center > Groups > New group > Membership type = Dynamic User > Rule: (user.userType -eq "Guest"). This enables targeted policies for guest users.'
        }
        Add-Setting @settingParams
    }
}
catch {
    Write-Warning "Could not check dynamic guest groups: $_"
}

# ------------------------------------------------------------------
# 25. Public Groups Have Owners (CIS 1.2.1)
# ------------------------------------------------------------------
try {
    Write-Verbose "Checking public M365 groups for owner assignment..."
    # Fetch M365 groups and filter for Public visibility client-side.
    # Server-side $filter on 'visibility' requires Directory.Read.All and
    # can fail in tenants with restricted directory permissions.
    $graphParams = @{
        Method      = 'GET'
        Uri         = "/v1.0/groups?`$filter=groupTypes/any(g:g eq 'Unified')&`$select=displayName,id,visibility&`$top=999"
        ErrorAction = 'Stop'
    }
    $unifiedGroups = Invoke-MgGraphRequest @graphParams

    $publicGroupList = if ($unifiedGroups -and $unifiedGroups['value']) {
        @($unifiedGroups['value'] | Where-Object { $_['visibility'] -eq 'Public' })
    } else { @() }
    $noOwnerGroups = @()
    foreach ($group in $publicGroupList) {
        $graphParams = @{
            Method      = 'GET'
            Uri         = "/v1.0/groups/$($group['id'])/owners?`$select=id"
            ErrorAction = 'SilentlyContinue'
        }
        $owners = Invoke-MgGraphRequest @graphParams
        if (-not $owners['value'] -or $owners['value'].Count -eq 0) {
            $noOwnerGroups += $group['displayName']
        }
    }

    if ($noOwnerGroups.Count -eq 0) {
        $settingParams = @{
            Category         = 'Group Management'
            Setting          = 'Public Groups Have Owners'
            CurrentValue     = "$($publicGroupList.Count) public groups, all have owners"
            RecommendedValue = 'All public groups have assigned owners'
            Status           = 'Pass'
            CheckId          = 'ENTRA-GROUP-003'
            Remediation      = 'No action needed.'
        }
        Add-Setting @settingParams
    }
    else {
        $groupList = ($noOwnerGroups | Select-Object -First 5) -join ', '
        $suffix = if ($noOwnerGroups.Count -gt 5) { " (+$($noOwnerGroups.Count - 5) more)" } else { '' }
        $settingParams = @{
            Category         = 'Group Management'
            Setting          = 'Public Groups Have Owners'
            CurrentValue     = "$($noOwnerGroups.Count) groups without owners: $groupList$suffix"
            RecommendedValue = 'All public groups have assigned owners'
            Status           = 'Fail'
            CheckId          = 'ENTRA-GROUP-003'
            Remediation      = 'Assign owners to ownerless public M365 groups. Entra admin center > Groups > All groups > select group > Owners > Add owners.'
        }
        Add-Setting @settingParams
    }
}
catch {
    Write-Warning "Could not check public group owners: $_"
}

# ------------------------------------------------------------------
# 26. User Owned Apps Restricted (CIS 1.3.4)
# ------------------------------------------------------------------
try {
    Write-Verbose "Checking user consent for apps..."
    $graphParams = @{
        Method      = 'GET'
        Uri         = '/v1.0/policies/authorizationPolicy'
        ErrorAction = 'Stop'
    }
    $consentPolicy = Invoke-MgGraphRequest @graphParams

    $consentSetting = $consentPolicy['defaultUserRolePermissions']['permissionGrantPoliciesAssigned']
    $isRestricted = ($null -eq $consentSetting) -or ($consentSetting.Count -eq 0) -or
                    ($consentSetting -notcontains 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy')

    $settingParams = @{
        Category         = 'Organization Settings'
        Setting          = 'Org-Level App Consent Restriction'
        CurrentValue     = $(if ($isRestricted) { 'Restricted' } else { "Allowed: $($consentSetting -join ', ')" })
        RecommendedValue = 'Do not allow user consent'
        Status           = $(if ($isRestricted) { 'Pass' } else { 'Fail' })
        CheckId          = 'ENTRA-ORGSETTING-001'
        Remediation      = 'Entra admin center > Enterprise applications > Consent and permissions > User consent settings > Do not allow user consent.'
    }
    Add-Setting @settingParams
}
catch {
    Write-Warning "Could not check user app consent: $_"
}

# ------------------------------------------------------------------
# 28-30. Organization Settings (Review-only CIS 1.3.5, 1.3.7, 1.3.9)
# ------------------------------------------------------------------
$settingParams = @{
    Category         = 'Organization Settings'
    Setting          = 'Forms Internal Phishing Protection'
    CurrentValue     = 'Cannot be checked via API'
    RecommendedValue = 'Enabled'
    Status           = 'Review'
    CheckId          = 'ENTRA-ORGSETTING-002'
    Remediation      = 'M365 admin center > Settings > Org settings > Microsoft Forms > ensure internal phishing protection is enabled.'
}
Add-Setting @settingParams

$settingParams = @{
    Category         = 'Organization Settings'
    Setting          = 'Third-Party Storage in M365 Web Apps'
    CurrentValue     = 'Cannot be checked via API'
    RecommendedValue = 'Restricted (all third-party storage disabled)'
    Status           = 'Review'
    CheckId          = 'ENTRA-ORGSETTING-003'
    Remediation      = 'M365 admin center > Settings > Org settings > Microsoft 365 on the web > uncheck all third-party storage services.'
}
Add-Setting @settingParams

$settingParams = @{
    Category         = 'Organization Settings'
    Setting          = 'Shared Bookings Pages Restricted'
    CurrentValue     = 'Cannot be checked via API'
    RecommendedValue = 'Restricted to selected users'
    Status           = 'Review'
    CheckId          = 'ENTRA-ORGSETTING-004'
    Remediation      = 'M365 admin center > Settings > Org settings > Bookings > restrict shared booking pages to selected staff members.'
}
Add-Setting @settingParams