From 5b293fd509ddd07adddcb063cc67051d5fff77a8 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Tue, 28 Apr 2026 14:22:40 +0100 Subject: [PATCH] refactor(m365): scope p1 license check to P1 features only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape PremiumLicenseInsight to a canonical 5-field schema reused by the upcoming P2 check (#10784): entitled P1/P2/total, plus pre-aggregated p1_licenses_utilized (CA + CA-guest) and p2_licenses_utilized (risk-based-CA + risk-based-CA-guest). The P1 check now compares entitled_total_license_count (P1+P2) against p1_licenses_utilized only — risk-based CA usage is the P2 check's domain. Updates Description, Notes and tests accordingly. --- ...olicy_p1_license_utilization.metadata.json | 8 +- ...al_access_policy_p1_license_utilization.py | 33 +++--- .../m365/services/entra/entra_service.py | 57 ++++++---- ...cess_policy_p1_license_utilization_test.py | 104 +++++++++++++----- .../entra/microsoft365_entra_service_test.py | 17 ++- 5 files changed, 145 insertions(+), 74 deletions(-) diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.metadata.json index e8004353ca..f980f0af06 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.metadata.json +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.metadata.json @@ -1,7 +1,7 @@ { "Provider": "m365", "CheckID": "entra_conditional_access_policy_p1_license_utilization", - "CheckTitle": "Premium (P1/P2) license entitlements ensure sufficient coverage for Conditional Access users", + "CheckTitle": "P1 license entitlements ensure sufficient coverage for Conditional Access users", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", @@ -9,8 +9,8 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Verifies that the total number of entitled Microsoft Entra ID Premium licenses (**P1 plus P2**, since P2 includes P1) is sufficient to cover all users actively utilizing **Conditional Access** (regular plus guest users). Unlicensed Conditional Access usage may violate Microsoft licensing terms and indicates gaps in license governance.", - "Risk": "When more users consume Conditional Access than the organization has premium 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 Conditional Access protection if Microsoft enforces entitlements.", + "Description": "Verifies that the entitled premium license count (P1 + P2, since P2 includes P1) covers all users actively using **P1-level Conditional Access** (regular plus guest). Unlicensed Conditional Access usage may violate Microsoft licensing terms. For risk-based (P2) Conditional Access, see `entra_conditional_access_policy_p2_license_utilization`.", + "Risk": "When more users consume P1-level Conditional Access than the organization has premium 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 Conditional Access protection if Microsoft enforces entitlements.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", @@ -35,5 +35,5 @@ ], "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). The check sums P1 + P2 entitlements against regular plus guest Conditional Access users." + "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 P1 Conditional Access utilization (regular + guest) against the combined P1 + P2 entitled license count, since P2 includes P1." } diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.py index 9cd40f4fd5..26d9626af2 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.py +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization.py @@ -3,21 +3,26 @@ from prowler.providers.m365.services.entra.entra_client import entra_client class entra_conditional_access_policy_p1_license_utilization(Check): - """Check if Premium license entitlements cover Conditional Access utilization. + """Check P1-feature (Conditional Access) license coverage. - Compares the total entitled Microsoft Entra ID Premium licenses (P1 + P2, - since P2 includes P1) against the number of users actively utilising - Conditional Access (regular plus guest users). + Compares the number of users consuming **P1-level Conditional Access** + (regular plus guest, exposed via ``p1FeatureUtilizations`` in + ``reports/azureADPremiumLicenseInsight``) against the entitled premium + license count. P2 entitlements include P1, so coverage is measured + against the combined ``entitled_total_license_count`` (P1 + P2). - - PASS: total premium license entitlements cover all Conditional Access users. - - FAIL: more users utilize Conditional Access than entitled premium licenses. + For risk-based Conditional Access (P2), see + ``entra_conditional_access_policy_p2_license_utilization``. + + - PASS: total premium entitlements cover all P1 Conditional Access users. + - FAIL: P1 Conditional Access utilization exceeds entitled premium 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 premium license utilization check. + """Execute the P1 license utilization check. Returns: A list with a single report describing the licensing coverage. @@ -40,19 +45,19 @@ class entra_conditional_access_policy_p1_license_utilization(Check): "Verify the tenant has at least one Microsoft Entra ID P1 or P2 " "license and that Reports.Read.All permission is granted." ) - elif insight.total_license_count >= insight.conditional_access_users_count: + elif insight.entitled_total_license_count >= insight.p1_licenses_utilized: report.status = "PASS" report.status_extended = ( - f"Premium license entitlements ({insight.total_license_count}) " - f"cover all Conditional Access users " - f"({insight.conditional_access_users_count})." + f"Premium license entitlements ({insight.entitled_total_license_count}) " + f"cover all P1 Conditional Access users " + f"({insight.p1_licenses_utilized})." ) else: report.status = "FAIL" report.status_extended = ( - f"Premium license entitlements ({insight.total_license_count}) " - f"do not cover all Conditional Access users " - f"({insight.conditional_access_users_count})." + f"Premium license entitlements ({insight.entitled_total_license_count}) " + f"do not cover all P1 Conditional Access users " + f"({insight.p1_licenses_utilized})." ) findings.append(report) diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 327971e538..63954beaf8 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -1057,7 +1057,11 @@ OAuthAppInfo data = json.loads(response) p1 = int(data.get("entitledP1LicenseCount", 0) or 0) p2 = int(data.get("entitledP2LicenseCount", 0) or 0) + total_licenses = int( + data.get("entitledTotalLicenseCount", p1 + p2) or (p1 + p2) + ) p1_features = data.get("p1FeatureUtilizations") or {} + p2_features = data.get("p2FeatureUtilizations") or {} ca_users = int( (p1_features.get("conditionalAccess") or {}).get("userCount", 0) or 0 @@ -1068,16 +1072,25 @@ OAuthAppInfo ) or 0 ) - total_licenses = int( - data.get("entitledTotalLicenseCount", p1 + p2) or (p1 + p2) + rb_ca_users = int( + (p2_features.get("riskBasedConditionalAccess") or {}).get( + "userCount", 0 + ) + or 0 + ) + rb_ca_guest_users = int( + ( + p2_features.get("riskBasedConditionalAccessGuestUsers") + or {} + ).get("userCount", 0) + or 0 ) return PremiumLicenseInsight( entitled_p1_license_count=p1, entitled_p2_license_count=p2, - conditional_access_user_count=ca_users, - conditional_access_guest_user_count=ca_guest_users, - total_license_count=total_licenses, - conditional_access_users_count=ca_users + ca_guest_users, + entitled_total_license_count=total_licenses, + p1_licenses_utilized=ca_users + ca_guest_users, + p2_licenses_utilized=rb_ca_users + rb_ca_guest_users, ) except Exception as error: # 403 missingLicense is expected for tenants without P1/P2. @@ -1554,25 +1567,31 @@ class OAuthApp(BaseModel): class PremiumLicenseInsight(BaseModel): - """Model representing Azure AD Premium license utilization insight. + """Mirror of Microsoft Graph beta ``reports/azureADPremiumLicenseInsight``. - Mirrors the four counters returned by Microsoft Graph beta - ``reports/azureADPremiumLicenseInsight`` plus two derived totals - consumed by the licence-utilization check (P2 entitlements include P1, - and guest Conditional Access users still consume premium licences). + Stores entitled license counts (P1, P2, total) and per-feature + utilisation totals already aggregated across regular and guest users: + + - **P1 features** (Conditional Access): ``p1_licenses_utilized`` = + ``conditionalAccess.userCount`` + ``conditionalAccessGuestUsers.userCount``. + - **P2 features** (risk-based Conditional Access): ``p2_licenses_utilized`` = + ``riskBasedConditionalAccess.userCount`` + + ``riskBasedConditionalAccessGuestUsers.userCount``. + + P2 entitlements include P1; the P1 utilisation check therefore compares + against ``entitled_total_license_count`` (P1 + P2), while the P2 check + compares against ``entitled_p2_license_count`` alone. Attributes: entitled_p1_license_count: Tenant-wide entitled Microsoft Entra ID P1 licenses. entitled_p2_license_count: Tenant-wide entitled Microsoft Entra ID P2 licenses. - conditional_access_user_count: Users actively utilising Conditional Access. - conditional_access_guest_user_count: Guest users actively utilising Conditional Access. - total_license_count: Total premium licenses entitled (P1 + P2). - conditional_access_users_count: Total Conditional Access utilization (regular + guest). + entitled_total_license_count: Total premium licenses entitled (P1 + P2). + p1_licenses_utilized: Users consuming P1 features (regular + guest CA). + p2_licenses_utilized: Users consuming P2 features (regular + guest risk-based CA). """ entitled_p1_license_count: int = 0 entitled_p2_license_count: int = 0 - conditional_access_user_count: int = 0 - conditional_access_guest_user_count: int = 0 - total_license_count: int = 0 - conditional_access_users_count: int = 0 + entitled_total_license_count: int = 0 + p1_licenses_utilized: int = 0 + p2_licenses_utilized: int = 0 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization_test.py index 6c03647af6..7597044b3e 100644 --- a/tests/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization_test.py +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_p1_license_utilization/entra_conditional_access_policy_p1_license_utilization_test.py @@ -45,7 +45,7 @@ class Test_entra_conditional_access_policy_p1_license_utilization: assert result[0].location == "global" def test_p1_only_covers_utilization(self): - """PASS when P1 entitlements alone cover all CA users (regular + guest).""" + """PASS when P1 entitlements alone cover all P1 CA users (regular + guest).""" entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -73,10 +73,9 @@ class Test_entra_conditional_access_policy_p1_license_utilization: entra_client.premium_license_insight = PremiumLicenseInsight( entitled_p1_license_count=100, entitled_p2_license_count=0, - conditional_access_user_count=60, - conditional_access_guest_user_count=10, - total_license_count=100, - conditional_access_users_count=70, + entitled_total_license_count=100, + p1_licenses_utilized=70, + p2_licenses_utilized=0, ) check = entra_conditional_access_policy_p1_license_utilization() @@ -86,15 +85,17 @@ class Test_entra_conditional_access_policy_p1_license_utilization: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Premium license entitlements (100) cover all Conditional Access users (70)." + == "Premium license entitlements (100) cover all P1 Conditional Access users (70)." + ) + assert ( + result[0].resource == entra_client.premium_license_insight.dict() ) - 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_plus_p2_covers_utilization(self): - """PASS when combined P1 + P2 entitlements cover all CA users.""" + """PASS when combined P1 + P2 entitlements cover all P1 CA users.""" entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -122,10 +123,9 @@ class Test_entra_conditional_access_policy_p1_license_utilization: entra_client.premium_license_insight = PremiumLicenseInsight( entitled_p1_license_count=50, entitled_p2_license_count=50, - conditional_access_user_count=60, - conditional_access_guest_user_count=20, - total_license_count=100, - conditional_access_users_count=80, + entitled_total_license_count=100, + p1_licenses_utilized=80, + p2_licenses_utilized=20, ) check = entra_conditional_access_policy_p1_license_utilization() @@ -135,9 +135,11 @@ class Test_entra_conditional_access_policy_p1_license_utilization: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Premium license entitlements (100) cover all Conditional Access users (80)." + == "Premium license entitlements (100) cover all P1 Conditional Access users (80)." + ) + assert ( + result[0].resource == entra_client.premium_license_insight.dict() ) - 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" @@ -171,10 +173,9 @@ class Test_entra_conditional_access_policy_p1_license_utilization: entra_client.premium_license_insight = PremiumLicenseInsight( entitled_p1_license_count=0, entitled_p2_license_count=150, - conditional_access_user_count=100, - conditional_access_guest_user_count=20, - total_license_count=150, - conditional_access_users_count=120, + entitled_total_license_count=150, + p1_licenses_utilized=120, + p2_licenses_utilized=40, ) check = entra_conditional_access_policy_p1_license_utilization() @@ -184,15 +185,17 @@ class Test_entra_conditional_access_policy_p1_license_utilization: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Premium license entitlements (150) cover all Conditional Access users (120)." + == "Premium license entitlements (150) cover all P1 Conditional Access users (120)." + ) + assert ( + result[0].resource == entra_client.premium_license_insight.dict() ) - 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_licenses_insufficient_with_guests(self): - """FAIL when guest CA users push utilization above the entitled count.""" + """FAIL when guest CA users push P1 utilization above the entitled count.""" entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -220,10 +223,9 @@ class Test_entra_conditional_access_policy_p1_license_utilization: entra_client.premium_license_insight = PremiumLicenseInsight( entitled_p1_license_count=30, entitled_p2_license_count=0, - conditional_access_user_count=25, - conditional_access_guest_user_count=10, - total_license_count=30, - conditional_access_users_count=35, + entitled_total_license_count=30, + p1_licenses_utilized=35, + p2_licenses_utilized=0, ) check = entra_conditional_access_policy_p1_license_utilization() @@ -233,13 +235,59 @@ class Test_entra_conditional_access_policy_p1_license_utilization: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "Premium license entitlements (30) do not cover all Conditional Access users (35)." + == "Premium license entitlements (30) do not cover all P1 Conditional Access users (35)." + ) + assert ( + result[0].resource == entra_client.premium_license_insight.dict() ) - 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_p2_utilization_does_not_affect_p1_check(self): + """P2 risk-based CA usage is ignored by this check; only P1 CA 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_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization import ( + entra_conditional_access_policy_p1_license_utilization, + ) + from prowler.providers.m365.services.entra.entra_service import ( + PremiumLicenseInsight, + ) + + entra_client.premium_license_insight = PremiumLicenseInsight( + entitled_p1_license_count=10, + entitled_p2_license_count=0, + entitled_total_license_count=10, + p1_licenses_utilized=5, + p2_licenses_utilized=999, + ) + + check = entra_conditional_access_policy_p1_license_utilization() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Premium license entitlements (10) cover all P1 Conditional Access users (5)." + ) + def test_zero_licenses_zero_users(self): """PASS when both license count and utilization are zero.""" entra_client = mock.MagicMock @@ -275,7 +323,7 @@ class Test_entra_conditional_access_policy_p1_license_utilization: assert result[0].status == "PASS" assert ( result[0].status_extended - == "Premium license entitlements (0) cover all Conditional Access users (0)." + == "Premium license entitlements (0) cover all P1 Conditional Access users (0)." ) assert result[0].resource_name == "Premium License Insight" assert result[0].resource_id == "azureADPremiumLicenseInsight" diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index fbd8424f33..f9fd7b30fb 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -637,10 +637,9 @@ class Test_Entra_get_premium_license_insight: assert isinstance(insight, PremiumLicenseInsight) assert insight.entitled_p1_license_count == 100 assert insight.entitled_p2_license_count == 50 - assert insight.total_license_count == 150 - assert insight.conditional_access_user_count == 85 - assert insight.conditional_access_guest_user_count == 12 - assert insight.conditional_access_users_count == 97 + assert insight.entitled_total_license_count == 150 + assert insight.p1_licenses_utilized == 97 + assert insight.p2_licenses_utilized == 35 def test_returns_none_on_403(self): """Returns None when the API raises (e.g. 403 missingLicense).""" @@ -666,11 +665,12 @@ class Test_Entra_get_premium_license_insight: def test_handles_missing_feature_utilizations(self): """Falls back to P1+P2 sum when entitledTotalLicenseCount is missing - and tolerates a null p1FeatureUtilizations object.""" + and tolerates null feature-utilization objects.""" payload = { "entitledP1LicenseCount": 10, "entitledP2LicenseCount": 0, "p1FeatureUtilizations": None, + "p2FeatureUtilizations": None, } adapter = MagicMock() adapter.send_primitive_async = AsyncMock( @@ -681,7 +681,6 @@ class Test_Entra_get_premium_license_insight: insight = asyncio.run(entra._get_premium_license_insight()) assert insight is not None - assert insight.total_license_count == 10 - assert insight.conditional_access_users_count == 0 - assert insight.conditional_access_user_count == 0 - assert insight.conditional_access_guest_user_count == 0 + assert insight.entitled_total_license_count == 10 + assert insight.p1_licenses_utilized == 0 + assert insight.p2_licenses_utilized == 0