Compare commits

...

5 Commits

Author SHA1 Message Date
Hugo P.Brito 625aca7121 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.
2026-04-28 14:55:46 +01:00
Hugo P.Brito 5b293fd509 refactor(m365): scope p1 license check to P1 features only
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.
2026-04-28 14:22:40 +01:00
Hugo P.Brito da70c9887d docs(m365): fix broken license utilization URL and adjust title
- Replace 404 concept-license-utilization link with the working
  Microsoft Entra licensing fundamentals page
- Add official Graph API resource doc URL for azureADPremiumLicenseInsight
- Update CheckTitle to reflect that the check counts both P1 and P2
2026-04-28 14:12:30 +01:00
Hugo P.Brito 9531983324 fix(m365): correct premium license insight parsing and semantics
- Parse correct Microsoft Graph keys (entitledP1/P2LicenseCount,
  p1FeatureUtilizations.conditionalAccess[GuestUsers].userCount)
- Sum P1 + P2 entitlements and count regular plus guest CA users
- Return MANUAL when license insight is unavailable instead of FAIL
- Downgrade missingLicense log to warning with explanatory message
- Add parser unit tests that mock the raw Graph response
2026-04-28 11:58:11 +01:00
Hugo P.Brito ec52c7b3fd feat(m365): add entra_conditional_access_policy_p1_license_utilization security check
Add new security check entra_conditional_access_policy_p1_license_utilization for m365 provider.
Includes check implementation, metadata, and unit tests.
2026-04-20 09:28:53 +01:00
13 changed files with 1042 additions and 13 deletions
+5
View File
@@ -4,6 +4,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.24.1] (Prowler UNRELEASED)
### 🚀 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
- bumped `msgraph-sdk` from 1.23.0 to 1.55.0 and `azure-mgmt-resource` from 23.3.0 to 24.0.0, removing `marshmallow` as is a transitively dev dependency [(#10733)](https://github.com/prowler-cloud/prowler/pull/10733)
+18 -13
View File
@@ -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"
]
@@ -206,11 +207,13 @@
"admincenter_users_admins_reduced_license_footprint",
"entra_admin_portals_access_restriction",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_conditional_access_policy_block_o365_elevated_insider_risk",
"entra_conditional_access_policy_block_unknown_device_platforms",
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
"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"
]
@@ -248,15 +251,16 @@
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_break_glass_account_fido2_security_key_registered",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_default_app_management_policy_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_conditional_access_policy_device_registration_mfa_required",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_break_glass_account_fido2_security_key_registered",
"entra_conditional_access_policy_block_unknown_device_platforms",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_device_registration_mfa_required",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_conditional_access_policy_p1_license_utilization",
"entra_default_app_management_policy_enabled",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_legacy_authentication_blocked",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
@@ -718,16 +722,17 @@
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_conditional_access_policy_device_registration_mfa_required",
"entra_conditional_access_policy_mfa_enforced_for_guest_users",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_break_glass_account_fido2_security_key_registered",
"entra_conditional_access_policy_approved_client_app_required_for_mobile",
"entra_conditional_access_policy_block_unknown_device_platforms",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
"entra_conditional_access_policy_device_code_flow_blocked",
"entra_conditional_access_policy_device_registration_mfa_required",
"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_identity_protection_sign_in_risk_enabled",
"entra_intune_enrollment_sign_in_frequency_every_time",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_users_mfa_enabled"
@@ -0,0 +1,39 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_p1_license_utilization",
"CheckTitle": "P1 license entitlements ensure sufficient coverage for Conditional Access users",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"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",
"https://learn.microsoft.com/en-us/graph/api/resources/azureadpremiumlicenseinsight?view=graph-rest-beta",
"https://learn.microsoft.com/en-us/entra/fundamentals/licensing"
],
"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 P1 and P2 features.\n4. Purchase additional P1 or P2 licenses, or remove Conditional Access policy assignments for unlicensed users to bring entitlements in line with utilization.",
"Terraform": ""
},
"Recommendation": {
"Text": "Review the Microsoft Entra ID Premium license utilization report and ensure that combined P1 and P2 license entitlements match or exceed the number of users utilizing Conditional Access (regular and guest). Purchase additional licenses or adjust policy scope as needed.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_p1_license_utilization"
}
},
"Categories": [
"identity-access",
"e3"
],
"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 P1 Conditional Access utilization (regular + guest) against the combined P1 + P2 entitled license count, since P2 includes P1."
}
@@ -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_p1_license_utilization(Check):
"""Check P1-feature (Conditional Access) license coverage.
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).
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 P1 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_total_license_count >= insight.p1_licenses_utilized:
report.status = "PASS"
report.status_extended = (
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.entitled_total_license_count}) "
f"do not cover all P1 Conditional Access users "
f"({insight.p1_licenses_utilized})."
)
findings.append(report)
return findings
@@ -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
@@ -5,6 +5,8 @@ from enum import Enum
from typing import Dict, List, Optional
from uuid import UUID
from kiota_abstractions.method import Method
from kiota_abstractions.request_information import RequestInformation
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
@@ -36,6 +38,7 @@ class Entra(M365Service):
user_accounts_status (dict): Dictionary of user account statuses.
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
authentication_method_configurations (dict): Dictionary of authentication method configurations.
premium_license_insight (PremiumLicenseInsight): Azure AD Premium license utilization insight.
"""
def __init__(self, provider: M365Provider):
@@ -83,6 +86,7 @@ class Entra(M365Service):
self._get_oauth_apps(),
self._get_directory_sync_settings(),
self._get_authentication_method_configurations(),
self._get_premium_license_insight(),
)
)
@@ -98,6 +102,7 @@ class Entra(M365Service):
self.authentication_method_configurations: Dict[
str, AuthenticationMethodConfiguration
] = attributes[9]
self.premium_license_insight: Optional[PremiumLicenseInsight] = attributes[10]
self.user_accounts_status = {}
if created_loop:
@@ -1019,6 +1024,84 @@ OAuthAppInfo
return oauth_apps
async def _get_premium_license_insight(self) -> Optional["PremiumLicenseInsight"]:
"""Retrieve Azure AD Premium license insight from the Microsoft Graph beta API.
Fetches the premium license utilization report which exposes entitled
P1/P2 license counts and per-feature utilization (Conditional Access
regular and guest users).
Tenants without any P1/P2 license receive HTTP 403 with the
``missingLicense`` error code; this is surfaced as ``None`` so the
consuming check can distinguish "not applicable" from a real
coverage gap.
Returns:
Optional[PremiumLicenseInsight]: The premium license insight data,
or None if the API call failed or data is unavailable.
"""
logger.info("Entra - Getting premium license insight...")
try:
request_info = RequestInformation()
request_info.http_method = Method.GET
request_info.url_template = (
"{+baseurl}/reports/azureADPremiumLicenseInsight"
)
request_info.path_parameters = {
"baseurl": "https://graph.microsoft.com/beta"
}
response = await self.client.request_adapter.send_primitive_async(
request_info, "bytes", {}
)
if response:
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
)
ca_guest_users = int(
(p1_features.get("conditionalAccessGuestUsers") or {}).get(
"userCount", 0
)
or 0
)
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,
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.
logger.warning(
f"Entra - Could not retrieve Azure AD Premium license insight. "
f"Requires Reports.Read.All and a tenant with at least one "
f"Microsoft Entra ID P1 or P2 license. "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
async def _get_authentication_method_configurations(self):
"""Retrieve authentication method configurations from Microsoft Entra.
@@ -1481,3 +1564,34 @@ class OAuthApp(BaseModel):
is_admin_consented: bool = False
last_used_time: Optional[str] = None
app_origin: str = ""
class PremiumLicenseInsight(BaseModel):
"""Mirror of Microsoft Graph beta ``reports/azureADPremiumLicenseInsight``.
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.
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
entitled_total_license_count: int = 0
p1_licenses_utilized: int = 0
p2_licenses_utilized: int = 0
@@ -0,0 +1,330 @@
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_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization"
class Test_entra_conditional_access_policy_p1_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_p1_license_utilization.entra_conditional_access_policy_p1_license_utilization import (
entra_conditional_access_policy_p1_license_utilization,
)
entra_client.premium_license_insight = None
check = entra_conditional_access_policy_p1_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_p1_only_covers_utilization(self):
"""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
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=100,
entitled_p2_license_count=0,
entitled_total_license_count=100,
p1_licenses_utilized=70,
p2_licenses_utilized=0,
)
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 (100) cover all P1 Conditional Access users (70)."
)
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 P1 CA users."""
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=50,
entitled_p2_license_count=50,
entitled_total_license_count=100,
p1_licenses_utilized=80,
p2_licenses_utilized=20,
)
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 (100) cover all P1 Conditional Access users (80)."
)
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_only_covers_utilization(self):
"""PASS when only P2 licenses are entitled (P2 includes P1)."""
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=0,
entitled_p2_license_count=150,
entitled_total_license_count=150,
p1_licenses_utilized=120,
p2_licenses_utilized=40,
)
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 (150) cover all P1 Conditional Access users (120)."
)
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 P1 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_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=30,
entitled_p2_license_count=0,
entitled_total_license_count=30,
p1_licenses_utilized=35,
p2_licenses_utilized=0,
)
check = entra_conditional_access_policy_p1_license_utilization()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "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_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
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()
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 (0) cover all P1 Conditional Access users (0)."
)
assert result[0].resource_name == "Premium License Insight"
assert result[0].resource_id == "azureADPremiumLicenseInsight"
assert result[0].location == "global"
@@ -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"
@@ -1,4 +1,5 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@@ -24,6 +25,7 @@ from prowler.providers.m365.services.entra.entra_service import (
InvitationsFrom,
Organization,
PersistentBrowser,
PremiumLicenseInsight,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
@@ -593,3 +595,92 @@ class Test_Entra_Service:
registration_builder.get.assert_awaited()
registration_builder.with_url.assert_called_once_with("next-link")
registration_builder_next.get.assert_awaited()
class Test_Entra_get_premium_license_insight:
"""Validate the raw JSON parsing of the Microsoft Graph beta endpoint
`reports/azureADPremiumLicenseInsight`. These tests bypass full Entra
initialisation and exercise the parser directly via a stand-alone
instance with a mocked `request_adapter`.
"""
@staticmethod
def _build_entra_with_adapter(adapter):
entra = Entra.__new__(Entra)
entra.client = MagicMock(request_adapter=adapter)
return entra
def test_parses_realistic_response(self):
"""Parses the canonical Microsoft Graph response and exposes derived totals."""
payload = {
"id": "00000000-0000-0000-0000-000000000000",
"entitledP1LicenseCount": 100,
"entitledP2LicenseCount": 50,
"entitledTotalLicenseCount": 150,
"p1FeatureUtilizations": {
"conditionalAccess": {"userCount": 85},
"conditionalAccessGuestUsers": {"userCount": 12},
},
"p2FeatureUtilizations": {
"riskBasedConditionalAccess": {"userCount": 30},
"riskBasedConditionalAccessGuestUsers": {"userCount": 5},
},
}
adapter = MagicMock()
adapter.send_primitive_async = AsyncMock(
return_value=json.dumps(payload).encode("utf-8")
)
entra = self._build_entra_with_adapter(adapter)
insight = asyncio.run(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.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)."""
adapter = MagicMock()
adapter.send_primitive_async = AsyncMock(
side_effect=Exception("403 Forbidden: missingLicense")
)
entra = self._build_entra_with_adapter(adapter)
insight = asyncio.run(entra._get_premium_license_insight())
assert insight is None
def test_returns_none_on_empty_response(self):
"""Returns None when the API returns empty bytes."""
adapter = MagicMock()
adapter.send_primitive_async = AsyncMock(return_value=b"")
entra = self._build_entra_with_adapter(adapter)
insight = asyncio.run(entra._get_premium_license_insight())
assert insight is None
def test_handles_missing_feature_utilizations(self):
"""Falls back to P1+P2 sum when entitledTotalLicenseCount is missing
and tolerates null feature-utilization objects."""
payload = {
"entitledP1LicenseCount": 10,
"entitledP2LicenseCount": 0,
"p1FeatureUtilizations": None,
"p2FeatureUtilizations": None,
}
adapter = MagicMock()
adapter.send_primitive_async = AsyncMock(
return_value=json.dumps(payload).encode("utf-8")
)
entra = self._build_entra_with_adapter(adapter)
insight = asyncio.run(entra._get_premium_license_insight())
assert insight is not None
assert insight.entitled_total_license_count == 10
assert insight.p1_licenses_utilized == 0
assert insight.p2_licenses_utilized == 0