feat(m365): add entra_conditional_access_policy_p2_license_utilization

Adds the P2 (risk-based Conditional Access) license utilization check.
Reuses the canonical PremiumLicenseInsight model + parser introduced in
the P1 check (#10783): compares
p2FeatureUtilizations.riskBasedConditionalAccess[GuestUsers].userCount
against entitledP2LicenseCount only — P1 entitlements do not include P2
features.

- Renamed from `entra_conditional_access_policy_license_utilization` so
  P1 and P2 checks are clearly distinguishable.
- Returns MANUAL when license insight is unavailable (mirrors P1 check).
- Tests cover PASS, FAIL (insufficient + guests), P1-only-not-covered,
  isolation from P1 utilization, and zero/zero edge case.
This commit is contained in:
Hugo P.Brito
2026-04-20 09:46:39 +01:00
committed by Hugo P.Brito
parent 5b293fd509
commit 625aca7121
7 changed files with 384 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `entra_conditional_access_policy_p1_license_utilization` check for m365 provider [(#10783)](https://github.com/prowler-cloud/prowler/pull/10783)
- `entra_conditional_access_policy_p2_license_utilization` check for m365 provider [(#10784)](https://github.com/prowler-cloud/prowler/pull/10784)
### 🔄 Changed
@@ -21,6 +21,7 @@
"defender_antiphishing_policy_configured",
"defender_antispam_policy_inbound_no_allowed_domains",
"entra_conditional_access_policy_block_o365_elevated_insider_risk",
"entra_conditional_access_policy_p2_license_utilization",
"entra_identity_protection_sign_in_risk_enabled",
"entra_identity_protection_user_risk_enabled"
]
@@ -212,6 +213,7 @@
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_conditional_access_policy_p1_license_utilization",
"entra_conditional_access_policy_p2_license_utilization",
"entra_policy_guest_users_access_restrictions",
"entra_seamless_sso_disabled"
]
@@ -0,0 +1,40 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_p2_license_utilization",
"CheckTitle": "P2 license entitlements ensure sufficient coverage for risk-based Conditional Access users",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Verifies that the entitled Microsoft Entra ID P2 license count covers all users actively using **risk-based Conditional Access** (regular plus guest). P1 entitlements do not include P2 features. For non-risk-based (P1) Conditional Access, see `entra_conditional_access_policy_p1_license_utilization`.",
"Risk": "When more users consume risk-based Conditional Access than the organization has P2 licenses for, it creates a **licensing compliance gap**. Microsoft may flag the tenant during audits, leading to retroactive charges or service restrictions. Additionally, unlicensed users may lose Identity Protection coverage if Microsoft enforces entitlements.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview",
"https://learn.microsoft.com/en-us/graph/api/resources/azureadpremiumlicenseinsight?view=graph-rest-beta",
"https://learn.microsoft.com/en-us/entra/fundamentals/licensing",
"https://maester.dev/docs/tests/MT.1023"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the **Microsoft Entra admin center** (https://entra.microsoft.com).\n2. Go to **Identity** > **Billing** > **Licenses** > **Licensed features**.\n3. Review the **License utilization** report for P2 features.\n4. Purchase additional P2 licenses, or reduce the scope of risk-based Conditional Access policies to only include users that hold a P2 license.",
"Terraform": ""
},
"Recommendation": {
"Text": "Review the Microsoft Entra ID Premium license utilization report and ensure that P2 license entitlements match or exceed the number of users utilizing risk-based Conditional Access (regular and guest). Purchase additional P2 licenses or adjust policy scope as needed.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_p2_license_utilization"
}
},
"Categories": [
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Uses Microsoft Graph beta reports/azureADPremiumLicenseInsight. Requires Reports.Read.All. Returns MANUAL when the tenant has no Microsoft Entra ID P1 or P2 license (Graph returns 403 missingLicense). Compares risk-based Conditional Access utilization (regular + guest) against the entitled P2 license count only; P1 entitlements do not cover P2 features."
}
@@ -0,0 +1,64 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_conditional_access_policy_p2_license_utilization(Check):
"""Check P2-feature (risk-based Conditional Access) license coverage.
Compares the number of users consuming **risk-based Conditional Access**
(regular plus guest, exposed via ``p2FeatureUtilizations`` in
``reports/azureADPremiumLicenseInsight``) against the entitled
Microsoft Entra ID P2 license count. P1 entitlements do **not** cover
P2 features, so this check uses ``entitled_p2_license_count`` alone.
For non-risk-based Conditional Access (P1), see
``entra_conditional_access_policy_p1_license_utilization``.
- PASS: P2 entitlements cover all risk-based Conditional Access users.
- FAIL: risk-based CA utilization exceeds entitled P2 licenses.
- MANUAL: license insight unavailable, typically because the tenant has no
P1/P2 license (Microsoft Graph returns 403 ``missingLicense``) or the
``Reports.Read.All`` permission was not granted.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the P2 license utilization check.
Returns:
A list with a single report describing the licensing coverage.
"""
findings = []
insight = entra_client.premium_license_insight
report = CheckReportM365(
metadata=self.metadata(),
resource=insight or {},
resource_name="Premium License Insight",
resource_id="azureADPremiumLicenseInsight",
)
if insight is None:
report.status = "MANUAL"
report.status_extended = (
"Could not retrieve Azure AD Premium license insight. "
"Verify the tenant has at least one Microsoft Entra ID P1 or P2 "
"license and that Reports.Read.All permission is granted."
)
elif insight.entitled_p2_license_count >= insight.p2_licenses_utilized:
report.status = "PASS"
report.status_extended = (
f"P2 license entitlements ({insight.entitled_p2_license_count}) "
f"cover all risk-based Conditional Access users "
f"({insight.p2_licenses_utilized})."
)
else:
report.status = "FAIL"
report.status_extended = (
f"P2 license entitlements ({insight.entitled_p2_license_count}) "
f"do not cover all risk-based Conditional Access users "
f"({insight.p2_licenses_utilized})."
)
findings.append(report)
return findings
@@ -0,0 +1,277 @@
from unittest import mock
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization"
class Test_entra_conditional_access_policy_p2_license_utilization:
def test_no_premium_license_insight(self):
"""MANUAL when premium license insight data is unavailable (None)."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import (
entra_conditional_access_policy_p2_license_utilization,
)
entra_client.premium_license_insight = None
check = entra_conditional_access_policy_p2_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Could not retrieve" in result[0].status_extended
assert "P1 or P2" in result[0].status_extended
assert "Reports.Read.All" in result[0].status_extended
assert result[0].resource == {}
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_p2_covers_utilization(self):
"""PASS when P2 entitlements cover all risk-based CA users (regular + guest)."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import (
entra_conditional_access_policy_p2_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
entitled_p1_license_count=0,
entitled_p2_license_count=50,
entitled_total_license_count=50,
p1_licenses_utilized=20,
p2_licenses_utilized=35,
)
check = entra_conditional_access_policy_p2_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "P2 license entitlements (50) cover all risk-based Conditional Access users (35)."
)
assert (
result[0].resource == entra_client.premium_license_insight.dict()
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_p1_only_does_not_cover_p2_utilization(self):
"""FAIL: P1 entitlements do not include P2 features."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import (
entra_conditional_access_policy_p2_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
entitled_p1_license_count=200,
entitled_p2_license_count=0,
entitled_total_license_count=200,
p1_licenses_utilized=150,
p2_licenses_utilized=10,
)
check = entra_conditional_access_policy_p2_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "P2 license entitlements (0) do not cover all risk-based Conditional Access users (10)."
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_licenses_insufficient_with_guests(self):
"""FAIL when guest risk-based CA users push utilization above the entitled count."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import (
entra_conditional_access_policy_p2_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
entitled_p1_license_count=0,
entitled_p2_license_count=30,
entitled_total_license_count=30,
p1_licenses_utilized=0,
p2_licenses_utilized=35,
)
check = entra_conditional_access_policy_p2_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "P2 license entitlements (30) do not cover all risk-based Conditional Access users (35)."
)
assert (
result[0].resource == entra_client.premium_license_insight.dict()
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
def test_p1_utilization_does_not_affect_p2_check(self):
"""P1 CA usage is ignored by this check; only risk-based (P2) is evaluated."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import (
entra_conditional_access_policy_p2_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight(
entitled_p1_license_count=0,
entitled_p2_license_count=10,
entitled_total_license_count=10,
p1_licenses_utilized=999,
p2_licenses_utilized=5,
)
check = entra_conditional_access_policy_p2_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "P2 license entitlements (10) cover all risk-based Conditional Access users (5)."
)
def test_zero_licenses_zero_users(self):
"""PASS when both P2 license count and risk-based utilization are zero."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_p2_license_utilization.entra_conditional_access_policy_p2_license_utilization import (
entra_conditional_access_policy_p2_license_utilization,
)
from prowler.providers.m365.services.entra.entra_service import (
PremiumLicenseInsight,
)
entra_client.premium_license_insight = PremiumLicenseInsight()
check = entra_conditional_access_policy_p2_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "P2 license entitlements (0) cover all risk-based Conditional Access users (0)."
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"