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.
This commit is contained in:
Hugo P.Brito
2026-04-28 14:22:40 +01:00
parent da70c9887d
commit 5b293fd509
5 changed files with 145 additions and 74 deletions
@@ -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."
}
@@ -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)
@@ -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
@@ -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"
@@ -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