prowler-cloud/prowler
View on GitHub[New Check]: Conditional Access policies must not reference deleted users, groups, or roles
Open
#11063 opened on May 6, 2026
feature-requestgood first issuenew-checkprovider/m365
Description
Existing check search
- I have searched existing issues, Prowler Hub, and the public roadmap, and this check does not already exist.
Provider
Microsoft 365
New provider name
No response
Service or product area
entra
Suggested check name
entra_conditional_access_policy_no_deleted_object_references
Context and goal
- Security condition to validate: Every object identifier referenced by any Conditional Access policy under
conditions.users— namelyincludeUsers,excludeUsers,includeGroups,excludeGroups,includeRoles,excludeRoles— must resolve to an existing Microsoft Entra object. - Why it matters: When a user, group, or directory role referenced by a Conditional Access policy stops resolving in the directory (account deleted, group deleted, role template removed), the reference becomes orphaned. In
include*collections this silently shrinks the policy's enforcement scope; inexclude*collections it can cause the policy to evaluate unexpectedly. Either way, the policy stops behaving the way the operator believes it does, which is one of the more common root causes of "we thought MFA was required but it wasn't" incidents. - Resource involved: Microsoft Entra Conditional Access policies and the users, security/Microsoft 365 groups, and directory role templates they reference.
Expected behavior
- Resource or scope to evaluate: All Conditional Access policies in the tenant, regardless of
state(enabled,disabled, andenabledForReportingButNotEnforced). Disabled policies still represent operator intent and a stale reference there is a misconfiguration that will go live the moment the policy is re-enabled. Build the deduplicated set of identifiers (per type) across all six collections —includeUsers,excludeUsers,includeGroups,excludeGroups,includeRoles,excludeRoles— for every policy, then resolve each identifier via Microsoft Graph using the type-appropriate endpoint:- Users →
GET /users/{id} - Groups →
GET /groups/{id} - Roles →
GET /roleManagement/directory/roleDefinitions/{id}
- Users →
- PASS when: every referenced identifier resolves successfully (HTTP 200) on the v1.0 Graph endpoint corresponding to its type. Also PASS when no policy references any user, group, or role.
- FAIL when: at least one referenced identifier returns HTTP 404 from its resolution endpoint. The finding should report each missing identifier together with its type (User / Group / Role), the policies that reference it, and the include vs exclude side.
- MANUAL when: not applicable.
- Exclusions / edge cases:
- Treat any non-404 Graph error (5xx, throttling, transient network failure) as a check error, not a FAIL — do not flag an object as deleted on transient failures.
- Cache resolved identifiers across the run so an object referenced by N policies costs one Graph call, not N. Cache per type.
- Disabled policies still count: stale references in disabled policies are a misconfiguration that becomes live the moment the policy is enabled.
- Sentinel values such as
"All","None","GuestsOrExternalUsers"(used inincludeUsers/excludeUsers) are not object identifiers and must be skipped before issuing a Graph lookup.
References
- Microsoft Graph v1.0 — Conditional Access policy resource: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0
- Microsoft Graph v1.0 —
conditionalAccessUsers(includeUsers, excludeUsers, includeGroups, excludeGroups, includeRoles, excludeRoles): https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0 - Microsoft Graph v1.0 — Get user (returns 404 if not found): https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0
- Microsoft Graph v1.0 — Get group (returns 404 if not found): https://learn.microsoft.com/en-us/graph/api/group-get?view=graph-rest-1.0
- Microsoft Graph v1.0 — Get unifiedRoleDefinition (returns 404 if not found): https://learn.microsoft.com/en-us/graph/api/unifiedroledefinition-get?view=graph-rest-1.0
- Conditional Access — Users, groups, and workload identities: https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups
Suggested severity
Medium
Additional implementation notes
- Existing patterns to follow: Reuse the Conditional Access iteration pattern from
entra_conditional_access_policy_mfa_enforced_for_guest_users. Add a small resolver helper inentra_service.pythat takes a list of identifiers and a type (user/group/role), issues the correspondingGET /…/{id}?$select=id,displayNamecalls, and returns the set of identifiers that 404. Cache results within the run, keyed by(type, id). - Permissions / scopes: No additional permissions beyond Prowler's M365 baseline (
Directory.Read.All,Policy.Read.All).Directory.Read.Allalready grants reads against/users/{id},/groups/{id}, and the unified role-management endpoints used here. - PowerShell is NOT needed; this check uses Microsoft Graph v1.0 only.
- Related checks (do NOT duplicate, complementary):
entra_conditional_access_policy_groups_management_restricted(issue #11060) — audits whether referenced groups are RMAU/role-assignable, assumes they exist.entra_conditional_access_policy_no_exclusion_gaps(issue #11062) — audits whether excluded objects are covered by another policy. Could share the resolver helper to detect deleted IDs as a separate failure category instead of mixing them in.
- Metadata: follow the M365 metadata schema used by sibling checks under
entra/.