Back to Checks
m365

Conditional Access policies must not reference deleted users, groups, or roles

entra_conditional_access_policy_no_deleted_object_references
Service: entraSeverity: medium
OfficialBuilt-inVerified
Categories:identity-accesse3
Resource Type:NotDefined

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.

Prowler logoProwlerprowler-provider-m365

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 Cloud
Run
prowler m365 --checks entra_conditional_access_policy_no_deleted_object_references

Recommendation

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

1Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)
2Navigate to Protection > Conditional Access > Policies
3Open each policy reported by this check
4Under Assignments > Users, remove every user, group, or role identifier reported as deleted
5Save the policy and re-run the audit
6For ongoing hygiene, audit Conditional Access policies after any bulk user/group/role cleanup

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."
        )