Conditional Access policies must not reference deleted users, groups, or roles
Every object identifier referenced by any Conditional Access policy under conditions.users (includeUsers, excludeUsers, includeGroups, excludeGroups, includeRoles, excludeRoles) must resolve to an existing Microsoft Entra object. This check audits all Conditional Access policies regardless of state and reports any whose user, group, or role references no longer resolve in the directory.
Risk
When a user, group, or directory role referenced by a Conditional Access policy stops resolving (account or group deleted, role template removed), the reference becomes orphaned. include* references silently shrink the policy's enforcement scope; exclude* references can cause the policy to evaluate unexpectedly. This is a common root cause of MFA-not-applied incidents.
Run this check with Prowler
Run in Prowler Cloudprowler m365 --checks entra_conditional_access_policy_no_deleted_object_referencesRecommendation
Audit each Conditional Access policy quarterly and remove references to deleted users, groups, or directory roles. Stale references in include collections silently shrink enforcement scope; stale references in exclude collections can cause policies to behave unexpectedly. Treat both as misconfigurations regardless of policy state.
Remediation
Other
Check Code
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
CONDITIONAL_ACCESS_SENTINEL_IDS,
ConditionalAccessPolicyState,
)
class entra_conditional_access_policy_no_deleted_object_references(Check):
"""
Ensure Conditional Access policies do not reference deleted directory objects.
Stale references to deleted users, groups, or directory roles silently change
the runtime behavior of a Conditional Access policy: include* references
shrink enforcement scope, exclude* references can change exemption logic.
Either way, the policy stops behaving the way the operator believes it does.
The directory-object existence check runs once at service init time and is
cached on the entra client. This check reads from that cache and reports any
policy whose users/groups/roles inclusion or exclusion collections name an
identifier that no longer resolves in Microsoft Entra ID.
Identifiers whose Graph lookup failed with a non-404 error (5xx, throttling,
insufficient permissions) are cached separately: they could not be verified
as present or deleted, so the policy is reported as MANUAL rather than being
silently treated as clean.
- PASS: The policy references no deleted users, groups, or roles.
- FAIL: The policy references at least one deleted user, group, or role.
- MANUAL: At least one referenced identifier could not be resolved due to a
transient Graph error, so the policy could not be fully evaluated.
"""
def execute(self) -> list[CheckReportM365]:
findings = []
unresolved = entra_client.unresolved_directory_object_references
errored = entra_client.errored_directory_object_references
for policy_id, policy in entra_client.conditional_access_policies.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy_id,
)
orphans = self._collect_references_in(policy, unresolved)
unverified = self._collect_references_in(policy, errored)
if orphans:
# A confirmed deletion takes precedence over unverified ones.
report.status = "FAIL"
report.status_extended = self._format_failure(
policy.display_name, orphans, unverified, policy.state
)
elif unverified:
# Nothing confirmed deleted, but we could not verify every
# reference — do not claim the policy is clean.
report.status = "MANUAL"
report.status_extended = self._format_manual(
policy.display_name, unverified
)
else:
report.status = "PASS"
report.status_extended = (
f"Conditional Access policy {policy.display_name} references no "
f"deleted directory objects."
)
findings.append(report)
return findings
@staticmethod
def _collect_references_in(policy, id_set):
"""Walk the six identifier collections and return references in ``id_set``.
Args:
policy: The Conditional Access policy to inspect.
id_set: Set of ``(type, id)`` pairs to match references against.
Returns:
list[tuple[str, str, str]]: ``(type, id, side)`` tuples where
``type`` is one of ``user|group|role``, ``id`` is the Graph
identifier, and ``side`` is one of ``include|exclude``.
"""
if not policy.conditions or not policy.conditions.user_conditions:
return []
uc = policy.conditions.user_conditions
collections = (
("user", "include", uc.included_users),
("user", "exclude", uc.excluded_users),
("group", "include", uc.included_groups),
("group", "exclude", uc.excluded_groups),
("role", "include", uc.included_roles),
("role", "exclude", uc.excluded_roles),
)
matches = []
for type_, side, identifiers in collections:
for identifier in identifiers:
if identifier in CONDITIONAL_ACCESS_SENTINEL_IDS:
continue
if (type_, identifier) in id_set:
matches.append((type_, identifier, side))
return matches
@staticmethod
def _group_by_type(references):
"""Group ``(type, id, side)`` references into a deterministic message part."""
by_type = {"user": [], "group": [], "role": []}
for type_, identifier, side in references:
by_type[type_].append(f"{identifier} ({side})")
parts = []
for type_ in ("user", "group", "role"):
if by_type[type_]:
joined = ", ".join(sorted(by_type[type_]))
parts.append(f"{type_}s: {joined}")
return "; ".join(parts)
@classmethod
def _format_failure(cls, display_name, orphans, unverified, state=None):
# Surface report-only mode explicitly: the stale references are not yet
# enforced, but become live the moment the policy is turned on.
report_only = (
" The policy is in report-only mode, so these references are not "
"enforced yet but will take effect once it is enabled."
if state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING
else ""
)
# If some references also could not be resolved, say so rather than
# implying the rest of the policy is fully clean.
unverified_note = (
f" Additionally, {len(unverified)} reference(s) could not be verified "
f"due to transient Microsoft Graph errors."
if unverified
else ""
)
return (
f"Conditional Access policy {display_name} references "
f"{len(orphans)} deleted directory object(s) — "
f"{cls._group_by_type(orphans)}.{report_only}{unverified_note}"
)
@classmethod
def _format_manual(cls, display_name, unverified):
return (
f"Conditional Access policy {display_name} could not be fully evaluated: "
f"{len(unverified)} reference(s) could not be resolved due to transient "
f"Microsoft Graph errors (5xx, throttling, or insufficient permissions) — "
f"{cls._group_by_type(unverified)}. Re-run the scan or review the policy "
f"manually."
)References
- https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0
- https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0
- https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0
- https://learn.microsoft.com/en-us/graph/api/group-get?view=graph-rest-1.0
- https://learn.microsoft.com/en-us/graph/api/unifiedroledefinition-get?view=graph-rest-1.0
- https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups