Existing check search
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 — namely includeUsers, 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; in exclude* 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, and enabledForReportingButNotEnforced). 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}
- 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 in includeUsers/excludeUsers) are not object identifiers and must be skipped before issuing a Graph lookup.
References
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 in entra_service.py that takes a list of identifiers and a type (user / group / role), issues the corresponding GET /…/{id}?$select=id,displayName calls, 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.All already 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):
- Metadata: follow the M365 metadata schema used by sibling checks under
entra/.
Existing check search
Provider
Microsoft 365
New provider name
No response
Service or product area
entra
Suggested check name
entra_conditional_access_policy_no_deleted_object_referencesContext and goal
conditions.users— namelyincludeUsers,excludeUsers,includeGroups,excludeGroups,includeRoles,excludeRoles— must resolve to an existing Microsoft Entra object.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.Expected behavior
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:GET /users/{id}GET /groups/{id}GET /roleManagement/directory/roleDefinitions/{id}"All","None","GuestsOrExternalUsers"(used inincludeUsers/excludeUsers) are not object identifiers and must be skipped before issuing a Graph lookup.References
conditionalAccessUsers(includeUsers, excludeUsers, includeGroups, excludeGroups, includeRoles, excludeRoles): https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0Suggested severity
Medium
Additional implementation notes
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).Directory.Read.All,Policy.Read.All).Directory.Read.Allalready grants reads against/users/{id},/groups/{id}, and the unified role-management endpoints used here.entra_conditional_access_policy_groups_management_restricted(issue [New Check]: Conditional Access groups must be protected by RMAU or role-assignable groups #11060) — audits whether referenced groups are RMAU/role-assignable, assumes they exist.entra_conditional_access_policy_no_exclusion_gaps(issue [New Check]: Conditional Access excluded objects must be covered by another policy (no exclusion gaps) #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.entra/.