mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
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:
committed by
Hugo P.Brito
parent
5b293fd509
commit
625aca7121
@@ -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"
|
||||
]
|
||||
|
||||
+40
@@ -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."
|
||||
}
|
||||
+64
@@ -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
|
||||
+277
@@ -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"
|
||||
Reference in New Issue
Block a user