chore(entra): enhance performance for user_registration_details and user mfa evaluation (#9236)

This commit is contained in:
Hugo Pereira Brito
2026-01-14 14:01:51 +01:00
committed by GitHub
parent 2cde4c939d
commit 1bf49747ad
12 changed files with 171 additions and 126 deletions

View File

@@ -30,6 +30,7 @@ Assign the following Microsoft Graph permissions:
- `Directory.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All` (optional, for multifactor authentication (MFA) checks)
- `AuditLog.Read.All` (optional, for multifactor authentication (MFA) checks)
<Note>
Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permissions. Note that Entra checks related to DirectoryRoles and GetUsers will not run with this permission.
@@ -51,6 +52,7 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss
- `Directory.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All`
- `AuditLog.Read.All`
![Permission Screenshots](/images/providers/domain-permission.png)
@@ -62,7 +64,7 @@ Replace `Directory.Read.All` with `Domain.Read.All` for more restrictive permiss
1. To grant permissions to a Service Principal, execute the following command in a terminal:
```console
az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role 38d9df27-64da-44fd-b7c5-a6fbac20248f=Role
az ad app permission add --id {appId} --api 00000003-0000-0000-c000-000000000000 --api-permissions 7ab1d382-f21e-4acd-a863-ba3e13f7da61=Role 246dd0d5-5bd0-4def-940b-0421030a5b68=Role 38d9df27-64da-44fd-b7c5-a6fbac20248f=Role b0afded3-3588-46d8-8b3d-9842eff778da=Role
```
</Tab>
</Tabs>
@@ -375,7 +377,7 @@ The ProwlerRole is a custom role required for specific security checks. First, c
#### Step 4: (Optional) Assign Microsoft Graph Permissions
For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and optionally `UserAuthenticationMethod.Read.All`.
For Entra ID (Azure AD) checks, the Managed Identity needs Microsoft Graph API permissions: `Directory.Read.All`, `Policy.Read.All`, and optionally `UserAuthenticationMethod.Read.All` and `AuditLog.Read.All`.
<Note>
Assigning Microsoft Graph API permissions to a Managed Identity requires Azure CLI or PowerShell - it cannot be done through the Azure Portal's standard role assignment interface.

View File

@@ -49,6 +49,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update AWS RDS service metadata to new format [(#9551)](https://github.com/prowler-cloud/prowler/pull/9551)
- Update AWS Bedrock service metadata to new format [(#8827)](https://github.com/prowler-cloud/prowler/pull/8827)
- Update AWS IAM service metadata to new format [(#9550)](https://github.com/prowler-cloud/prowler/pull/9550)
- Enhance `user_registration_details` perfomance and user `mfa` evaluation [(#9236)](https://github.com/prowler-cloud/prowler/pull/9236)
- Update AWS Cognito service metadata to new format [(#8853)](https://github.com/prowler-cloud/prowler/pull/8853)
---

View File

@@ -21,7 +21,7 @@ class entra_non_privileged_user_has_mfa(Check):
f"Non-privileged user {user.name} does not have MFA."
)
if len(user.authentication_methods) > 1:
if user.is_mfa_capable:
report.status = "PASS"
report.status_extended = (
f"Non-privileged user {user.name} has MFA."

View File

@@ -21,7 +21,7 @@ class entra_privileged_user_has_mfa(Check):
f"Privileged user {user.name} does not have MFA."
)
if len(user.authentication_methods) > 1:
if user.is_mfa_capable:
report.status = "PASS"
report.status_extended = f"Privileged user {user.name} has MFA."

View File

@@ -66,6 +66,7 @@ class Entra(AzureService):
for tenant, client in self.clients.items():
users.update({tenant: {}})
users_response = await client.users.get()
registration_details = await self._get_user_registration_details(client)
try:
while users_response:
@@ -75,19 +76,9 @@ class Entra(AzureService):
user.id: User(
id=user.id,
name=user.display_name,
authentication_methods=[
AuthMethod(
id=auth_method.id,
type=getattr(
auth_method, "odata_type", None
),
)
for auth_method in (
await client.users.by_user_id(
user.id
).authentication.methods.get()
).value
],
is_mfa_capable=registration_details.get(
user.id, False
),
)
}
)
@@ -116,6 +107,34 @@ class Entra(AzureService):
return users
async def _get_user_registration_details(self, client):
registration_details = {}
try:
registration_builder = (
client.reports.authentication_methods.user_registration_details
)
registration_response = await registration_builder.get()
while registration_response:
for detail in getattr(registration_response, "value", []) or []:
registration_details.update(
{detail.id: getattr(detail, "is_mfa_capable", False)}
)
next_link = getattr(registration_response, "odata_next_link", None)
if not next_link:
break
registration_response = await registration_builder.with_url(
next_link
).get()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return registration_details
async def _get_authorization_policy(self):
logger.info("Entra - Getting authorization policy...")
@@ -391,15 +410,10 @@ class Entra(AzureService):
return conditional_access_policy
class AuthMethod(BaseModel):
id: str
type: str
class User(BaseModel):
id: str
name: str
authentication_methods: List[AuthMethod] = []
is_mfa_capable: bool = False
class DefaultUserRolePermissions(BaseModel):

View File

@@ -43,7 +43,7 @@ class entra_user_with_vm_access_has_mfa(Check):
report.subscription = subscription_name
report.status = "FAIL"
report.status_extended = f"User {user.name} without MFA can access VMs in subscription {subscription_name}"
if len(user.authentication_methods) > 1:
if user.is_mfa_capable:
report.status = "PASS"
report.status_extended = f"User {user.name} can access VMs in subscription {subscription_name} but it has MFA."

View File

@@ -402,18 +402,7 @@ class Entra(M365Service):
for member in members:
user_roles_map.setdefault(member.id, []).append(role_template_id)
try:
registration_details_list = (
await self.client.reports.authentication_methods.user_registration_details.get()
)
registration_details = {
detail.id: detail for detail in registration_details_list.value
}
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
registration_details = {}
registration_details = await self._get_user_registration_details()
while users_response:
for user in getattr(users_response, "value", []) or []:
@@ -424,11 +413,7 @@ class Entra(M365Service):
True if (user.on_premises_sync_enabled) else False
),
directory_roles_ids=user_roles_map.get(user.id, []),
is_mfa_capable=(
registration_details.get(user.id, {}).is_mfa_capable
if registration_details.get(user.id, None) is not None
else False
),
is_mfa_capable=(registration_details.get(user.id, False)),
account_enabled=not self.user_accounts_status.get(
user.id, {}
).get("AccountDisabled", False),
@@ -444,6 +429,38 @@ class Entra(M365Service):
)
return users
async def _get_user_registration_details(self):
registration_details = {}
try:
registration_builder = (
self.client.reports.authentication_methods.user_registration_details
)
registration_response = await registration_builder.get()
while registration_response:
for detail in getattr(registration_response, "value", []) or []:
registration_details.update(
{detail.id: getattr(detail, "is_mfa_capable", False)}
)
next_link = getattr(registration_response, "odata_next_link", None)
if not next_link:
break
registration_response = await registration_builder.with_url(
next_link
).get()
except Exception as error:
if (
error.__class__.__name__ == "ODataError"
and error.__dict__.get("response_status_code", None) == 403
):
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return registration_details
class ConditionalAccessPolicyState(Enum):
ENABLED = "enabled"

View File

@@ -69,7 +69,6 @@ class Test_entra_non_privileged_user_has_mfa:
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -77,7 +76,7 @@ class Test_entra_non_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")],
is_mfa_capable=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
@@ -117,7 +116,6 @@ class Test_entra_non_privileged_user_has_mfa:
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -125,10 +123,7 @@ class Test_entra_non_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[
AuthMethod(id=str(uuid4()), type="foo"),
AuthMethod(id=str(uuid4()), type="bar"),
],
is_mfa_capable=True,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
@@ -165,7 +160,6 @@ class Test_entra_non_privileged_user_has_mfa:
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -173,7 +167,7 @@ class Test_entra_non_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")],
is_mfa_capable=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
@@ -207,7 +201,6 @@ class Test_entra_non_privileged_user_has_mfa:
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -215,10 +208,7 @@ class Test_entra_non_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[
AuthMethod(id=str(uuid4()), type="foo"),
AuthMethod(id=str(uuid4()), type="bar"),
],
is_mfa_capable=True,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}

View File

@@ -69,7 +69,6 @@ class Test_entra_privileged_user_has_mfa:
entra_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -77,7 +76,7 @@ class Test_entra_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")],
is_mfa_capable=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
@@ -109,7 +108,6 @@ class Test_entra_privileged_user_has_mfa:
entra_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -117,10 +115,7 @@ class Test_entra_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[
AuthMethod(id=str(uuid4()), type="foo"),
AuthMethod(id=str(uuid4()), type="bar"),
],
is_mfa_capable=True,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
@@ -152,7 +147,6 @@ class Test_entra_privileged_user_has_mfa:
entra_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -160,7 +154,7 @@ class Test_entra_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[AuthMethod(id=str(uuid4()), type="foo")],
is_mfa_capable=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
@@ -199,7 +193,6 @@ class Test_entra_privileged_user_has_mfa:
entra_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
DirectoryRole,
User,
)
@@ -207,10 +200,7 @@ class Test_entra_privileged_user_has_mfa:
user = User(
id=user_id,
name="foo",
authentication_methods=[
AuthMethod(id=str(uuid4()), type="foo"),
AuthMethod(id=str(uuid4()), type="bar"),
],
is_mfa_capable=True,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}

View File

@@ -145,10 +145,7 @@ class Test_Entra_Service:
assert len(entra_client.users) == 1
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1"
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1"
assert (
len(entra_client.users[DOMAIN]["user-1@tenant1.es"].authentication_methods)
== 0
)
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False
def test_get_authorization_policy(self):
entra_client = Entra(set_mocked_azure_provider())
@@ -251,38 +248,48 @@ def test_azure_entra__get_users_handles_pagination():
)
with_url_mock = MagicMock(return_value=users_with_url_builder)
def by_user_id_side_effect(user_id):
auth_methods_response = SimpleNamespace(
value=[
SimpleNamespace(
id=f"{user_id}-method",
odata_type="#microsoft.graph.passwordAuthenticationMethod",
)
]
)
return SimpleNamespace(
authentication=SimpleNamespace(
methods=SimpleNamespace(
get=AsyncMock(return_value=auth_methods_response)
)
)
)
users_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_one),
with_url=with_url_mock,
by_user_id=MagicMock(side_effect=by_user_id_side_effect),
)
entra_service.clients = {"tenant-1": SimpleNamespace(users=users_builder)}
registration_details_response = SimpleNamespace(
value=[
SimpleNamespace(
id="user-1",
is_mfa_capable=True,
),
SimpleNamespace(
id="user-2",
is_mfa_capable=True,
),
],
odata_next_link=None,
)
registration_details_builder = SimpleNamespace(
get=AsyncMock(return_value=registration_details_response),
with_url=MagicMock(),
)
entra_service.clients = {
"tenant-1": SimpleNamespace(
users=users_builder,
reports=SimpleNamespace(
authentication_methods=SimpleNamespace(
user_registration_details=registration_details_builder
)
),
)
}
users = asyncio.run(entra_service._get_users())
assert len(users["tenant-1"]) == 3
assert users_builder.get.await_count == 1
with_url_mock.assert_called_once_with("next-link")
assert users["tenant-1"]["user-1"].authentication_methods[0].id == "user-1-method"
assert (
users["tenant-1"]["user-3"].authentication_methods[0].type
== "#microsoft.graph.passwordAuthenticationMethod"
)
registration_details_builder.get.assert_awaited()
registration_details_builder.with_url.assert_not_called()
assert users["tenant-1"]["user-1"].is_mfa_capable is True
assert users["tenant-1"]["user-2"].is_mfa_capable is True
assert users["tenant-1"]["user-3"].is_mfa_capable is False

View File

@@ -61,10 +61,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
User,
)
from prowler.providers.azure.services.entra.entra_service import User
from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import (
entra_user_with_vm_access_has_mfa,
)
@@ -90,12 +87,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
f"test@{DOMAIN}": User(
id=user_id,
name="test",
authentication_methods=[
AuthMethod(id=str(uuid4()), type="Password"),
AuthMethod(
id=str(uuid4()), type="MicrosoftAuthenticator"
),
],
is_mfa_capable=True,
)
}
}
@@ -138,10 +130,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
User,
)
from prowler.providers.azure.services.entra.entra_service import User
from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import (
entra_user_with_vm_access_has_mfa,
)
@@ -167,9 +156,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
f"test@{DOMAIN}": User(
id=user_id,
name="test",
authentication_methods=[
AuthMethod(id=str(uuid4()), type="Password"),
],
is_mfa_capable=False,
)
}
}
@@ -264,10 +251,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_service import (
AuthMethod,
User,
)
from prowler.providers.azure.services.entra.entra_service import User
from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import (
entra_user_with_vm_access_has_mfa,
)
@@ -293,12 +277,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
f"test@{DOMAIN}": User(
id=user_id,
name="test",
authentication_methods=[
AuthMethod(id=str(uuid4()), type="Password"),
AuthMethod(
id=str(uuid4()), type="MicrosoftAuthenticator"
),
],
is_mfa_capable=True,
)
}
}

View File

@@ -401,10 +401,14 @@ class Test_Entra_Service:
value=[
SimpleNamespace(id="user-1", is_mfa_capable=True),
SimpleNamespace(id="user-6", is_mfa_capable=True),
]
],
odata_next_link=None,
)
registration_details_builder = SimpleNamespace(
get=AsyncMock(return_value=registration_details_response)
get=AsyncMock(return_value=registration_details_response),
with_url=MagicMock(
return_value=SimpleNamespace(get=AsyncMock(return_value=None))
),
)
reports_builder = SimpleNamespace(
authentication_methods=SimpleNamespace(
@@ -429,3 +433,44 @@ class Test_Entra_Service:
assert users["user-6"].account_enabled is False
assert users["user-1"].is_mfa_capable is True
assert users["user-2"].is_mfa_capable is False
def test__get_user_registration_details_handles_pagination(self):
entra_service = Entra.__new__(Entra)
registration_response_page_one = SimpleNamespace(
value=[
SimpleNamespace(id="user-1", is_mfa_capable=True),
],
odata_next_link="next-link",
)
registration_response_page_two = SimpleNamespace(
value=[
SimpleNamespace(id="user-2", is_mfa_capable=False),
],
odata_next_link=None,
)
registration_builder_next = SimpleNamespace(
get=AsyncMock(return_value=registration_response_page_two)
)
registration_builder = SimpleNamespace(
get=AsyncMock(return_value=registration_response_page_one),
with_url=MagicMock(return_value=registration_builder_next),
)
entra_service.client = SimpleNamespace(
reports=SimpleNamespace(
authentication_methods=SimpleNamespace(
user_registration_details=registration_builder
)
)
)
registration_details = asyncio.run(
entra_service._get_user_registration_details()
)
assert registration_details == {"user-1": True, "user-2": False}
registration_builder.get.assert_awaited()
registration_builder.with_url.assert_called_once_with("next-link")
registration_builder_next.get.assert_awaited()