mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
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:
+4
-4
@@ -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."
|
||||
}
|
||||
|
||||
+19
-14
@@ -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
|
||||
|
||||
+76
-28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user