mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(m365): add CIS M365 v7.0.0 entra authentication method, PIM and access review checks (#12155)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com> Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
This commit is contained in:
co-authored by
Daniel Barranquero
Hugo P.Brito
parent
356036fe1f
commit
ce037318cd
+147
@@ -0,0 +1,147 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
AccessReviewDefinition,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
|
||||
|
||||
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_access_review_guest_users_configured.entra_access_review_guest_users_configured"
|
||||
|
||||
|
||||
def _definition(
|
||||
status="InProgress",
|
||||
scope_query="/users?$filter=(userType eq 'Guest')",
|
||||
principal_scope_queries=None,
|
||||
default_decision="Deny",
|
||||
default_decision_enabled=True,
|
||||
auto_apply_enabled=True,
|
||||
mail_notifications_enabled=True,
|
||||
reminders_enabled=True,
|
||||
recurrence_pattern_type="weekly",
|
||||
recurrence_range_type="noEnd",
|
||||
has_primary_reviewers=True,
|
||||
):
|
||||
definition = {
|
||||
"id": "ar1",
|
||||
"display_name": "Guest Review",
|
||||
"status": status,
|
||||
"scope_query": scope_query,
|
||||
"principal_scope_queries": principal_scope_queries or [],
|
||||
"default_decision": default_decision,
|
||||
"auto_apply_enabled": auto_apply_enabled,
|
||||
"mail_notifications_enabled": mail_notifications_enabled,
|
||||
"reminders_enabled": reminders_enabled,
|
||||
"recurrence_pattern_type": recurrence_pattern_type,
|
||||
"recurrence_range_type": recurrence_range_type,
|
||||
"has_primary_reviewers": has_primary_reviewers,
|
||||
}
|
||||
if default_decision_enabled is not None:
|
||||
definition["default_decision_enabled"] = default_decision_enabled
|
||||
return AccessReviewDefinition(**definition)
|
||||
|
||||
|
||||
class Test_entra_access_review_guest_users_configured:
|
||||
def _run(self, definitions):
|
||||
entra_client = mock.MagicMock
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_access_review_guest_users_configured.entra_access_review_guest_users_configured import (
|
||||
entra_access_review_guest_users_configured,
|
||||
)
|
||||
|
||||
entra_client.access_review_definitions = definitions
|
||||
return entra_access_review_guest_users_configured().execute()
|
||||
|
||||
def test_no_definitions(self):
|
||||
result = self._run([])
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_active_failclosed_guest_review(self):
|
||||
result = self._run([_definition()])
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "Access review 'Guest Review' for guest users is active, recurring, "
|
||||
"reviewer-assigned, and fail-closed."
|
||||
)
|
||||
|
||||
def test_not_active(self):
|
||||
result = self._run([_definition(status="Completed")])
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_not_fail_closed(self):
|
||||
# Active guest review but does nothing on non-response -> FAIL.
|
||||
result = self._run(
|
||||
[_definition(default_decision="None", auto_apply_enabled=False)]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
@pytest.mark.parametrize("default_decision_enabled", [False, None])
|
||||
def test_default_decision_not_enabled(self, default_decision_enabled):
|
||||
result = self._run(
|
||||
[_definition(default_decision_enabled=default_decision_enabled)]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_not_guest_scope(self):
|
||||
result = self._run([_definition(scope_query="/roleManagement/directory")])
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_guest_filter_in_principal_scopes(self):
|
||||
# Portal-created reviews keep the guest filter in principalScopes and
|
||||
# leave the top-level scope query empty.
|
||||
result = self._run(
|
||||
[
|
||||
_definition(
|
||||
scope_query="",
|
||||
principal_scope_queries=["/users?$filter=(userType eq 'Guest')"],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_url_encoded_guest_filter(self):
|
||||
result = self._run(
|
||||
[_definition(scope_query="/users?$filter=userType%20eq%20%27Guest%27")]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_mixed_case_guest_filter(self):
|
||||
result = self._run(
|
||||
[_definition(scope_query="/users?$filter=(USERTYPE EQ 'guest')")]
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scope_query",
|
||||
[
|
||||
"/users?$filter=not(userType eq 'Guest')",
|
||||
"/users?$filter=(userType ne 'Guest')",
|
||||
"/users?$filter=displayName eq 'Guest account'",
|
||||
"/groups/guest-review-members",
|
||||
],
|
||||
)
|
||||
def test_incidental_guest_text_does_not_target_guests(self, scope_query):
|
||||
result = self._run([_definition(scope_query=scope_query)])
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"definition_overrides",
|
||||
[
|
||||
{"recurrence_pattern_type": None},
|
||||
{"recurrence_pattern_type": "daily"},
|
||||
{"recurrence_range_type": None},
|
||||
{"recurrence_range_type": "endDate"},
|
||||
{"has_primary_reviewers": False},
|
||||
],
|
||||
)
|
||||
def test_invalid_recurrence_or_missing_reviewers(self, definition_overrides):
|
||||
result = self._run([_definition(**definition_overrides)])
|
||||
assert result[0].status == "FAIL"
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
AccessReviewDefinition,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
|
||||
|
||||
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_access_review_privileged_roles_configured.entra_access_review_privileged_roles_configured"
|
||||
|
||||
|
||||
def _definition(
|
||||
status="InProgress",
|
||||
scope_query="",
|
||||
resource_scope_queries=None,
|
||||
default_decision="None",
|
||||
auto_apply_enabled=True,
|
||||
mail_notifications_enabled=True,
|
||||
reminders_enabled=True,
|
||||
recurrence_pattern_type="absoluteMonthly",
|
||||
recurrence_range_type="noEnd",
|
||||
has_primary_reviewers=True,
|
||||
):
|
||||
return AccessReviewDefinition(
|
||||
id="ar1",
|
||||
display_name="Privileged Roles Review",
|
||||
status=status,
|
||||
scope_query=scope_query,
|
||||
resource_scope_queries=(
|
||||
resource_scope_queries
|
||||
if resource_scope_queries is not None
|
||||
else ["/roleManagement/directory/roleDefinitions/62e90394-..."]
|
||||
),
|
||||
default_decision=default_decision,
|
||||
auto_apply_enabled=auto_apply_enabled,
|
||||
mail_notifications_enabled=mail_notifications_enabled,
|
||||
reminders_enabled=reminders_enabled,
|
||||
recurrence_pattern_type=recurrence_pattern_type,
|
||||
recurrence_range_type=recurrence_range_type,
|
||||
has_primary_reviewers=has_primary_reviewers,
|
||||
)
|
||||
|
||||
|
||||
class Test_entra_access_review_privileged_roles_configured:
|
||||
def _run(self, definitions):
|
||||
entra_client = mock.MagicMock
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_access_review_privileged_roles_configured.entra_access_review_privileged_roles_configured import (
|
||||
entra_access_review_privileged_roles_configured,
|
||||
)
|
||||
|
||||
entra_client.access_review_definitions = definitions
|
||||
return entra_access_review_privileged_roles_configured().execute()
|
||||
|
||||
def test_no_definitions(self):
|
||||
assert self._run([])[0].status == "FAIL"
|
||||
|
||||
def test_active_fail_closed_privileged(self):
|
||||
# Role reference lives in resource scopes (not top-level scope.query).
|
||||
result = self._run([_definition()])
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_deny_default_decision(self):
|
||||
result = self._run([_definition(default_decision="Deny")])
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_guest_review_ignored(self):
|
||||
result = self._run(
|
||||
[
|
||||
_definition(
|
||||
scope_query="/users?$filter=(userType eq 'Guest')",
|
||||
resource_scope_queries=[],
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"definition_overrides",
|
||||
[
|
||||
{"recurrence_pattern_type": None},
|
||||
{"recurrence_pattern_type": "daily"},
|
||||
{"recurrence_range_type": None},
|
||||
{"recurrence_range_type": "numbered"},
|
||||
{"has_primary_reviewers": False},
|
||||
],
|
||||
)
|
||||
def test_invalid_recurrence_or_missing_reviewers(self, definition_overrides):
|
||||
result = self._run([_definition(**definition_overrides)])
|
||||
assert result[0].status == "FAIL"
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
AuthenticationMethodsPolicySettings,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
|
||||
|
||||
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_authentication_method_authenticator_show_context.entra_authentication_method_authenticator_show_context"
|
||||
|
||||
|
||||
class Test_entra_authentication_method_authenticator_show_context:
|
||||
def _run(self, settings):
|
||||
entra_client = mock.MagicMock
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_authentication_method_authenticator_show_context.entra_authentication_method_authenticator_show_context import (
|
||||
entra_authentication_method_authenticator_show_context,
|
||||
)
|
||||
|
||||
entra_client.authentication_methods_policy_settings = settings
|
||||
return entra_authentication_method_authenticator_show_context().execute()
|
||||
|
||||
def test_no_settings(self):
|
||||
assert self._run(None) == []
|
||||
|
||||
def test_both_enabled(self):
|
||||
result = self._run(
|
||||
AuthenticationMethodsPolicySettings(
|
||||
authenticator_state="enabled",
|
||||
authenticator_display_app_information_state="enabled",
|
||||
authenticator_display_location_information_state="enabled",
|
||||
)
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_location_disabled(self):
|
||||
result = self._run(
|
||||
AuthenticationMethodsPolicySettings(
|
||||
authenticator_state="enabled",
|
||||
authenticator_display_app_information_state="enabled",
|
||||
authenticator_display_location_information_state="disabled",
|
||||
)
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_context_enabled_but_authenticator_disabled(self):
|
||||
result = self._run(
|
||||
AuthenticationMethodsPolicySettings(
|
||||
authenticator_state="disabled",
|
||||
authenticator_display_app_information_state="enabled",
|
||||
authenticator_display_location_information_state="enabled",
|
||||
)
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
AuthenticationMethodConfiguration,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
|
||||
|
||||
|
||||
class Test_entra_authentication_method_email_otp_disabled:
|
||||
def test_no_configurations(self):
|
||||
entra_client = mock.MagicMock
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import (
|
||||
entra_authentication_method_email_otp_disabled,
|
||||
)
|
||||
|
||||
entra_client.authentication_method_configurations = {}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_authentication_method_email_otp_disabled()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_email_otp_enabled(self):
|
||||
entra_client = mock.MagicMock
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import (
|
||||
entra_authentication_method_email_otp_disabled,
|
||||
)
|
||||
|
||||
entra_client.authentication_method_configurations = {
|
||||
"Email": AuthenticationMethodConfiguration(
|
||||
id="Email",
|
||||
state="enabled",
|
||||
),
|
||||
}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_authentication_method_email_otp_disabled()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "Email OTP authentication method is enabled in the tenant."
|
||||
)
|
||||
|
||||
def test_email_otp_disabled(self):
|
||||
entra_client = mock.MagicMock
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import (
|
||||
entra_authentication_method_email_otp_disabled,
|
||||
)
|
||||
|
||||
entra_client.authentication_method_configurations = {
|
||||
"Email": AuthenticationMethodConfiguration(
|
||||
id="Email",
|
||||
state="disabled",
|
||||
),
|
||||
}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_authentication_method_email_otp_disabled()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "Email OTP authentication method is disabled in the tenant."
|
||||
)
|
||||
|
||||
def test_email_otp_unknown_state(self):
|
||||
entra_client = mock.MagicMock
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_authentication_method_email_otp_disabled.entra_authentication_method_email_otp_disabled import (
|
||||
entra_authentication_method_email_otp_disabled,
|
||||
)
|
||||
|
||||
entra_client.authentication_method_configurations = {
|
||||
"Email": AuthenticationMethodConfiguration(
|
||||
id="Email",
|
||||
state="unknown",
|
||||
),
|
||||
}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_authentication_method_email_otp_disabled()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "Email OTP authentication method state could not be determined; "
|
||||
"treating as enabled/non-compliant."
|
||||
)
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
PimRoleApprovalSetting,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
|
||||
|
||||
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_pim_global_administrator_approval_required.entra_pim_global_administrator_approval_required"
|
||||
|
||||
|
||||
class Test_entra_pim_global_administrator_approval_required:
|
||||
def _run(self, settings):
|
||||
entra_client = mock.MagicMock()
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_global_administrator_approval_required.entra_pim_global_administrator_approval_required import (
|
||||
entra_pim_global_administrator_approval_required,
|
||||
)
|
||||
|
||||
entra_client.pim_role_approval_settings = settings
|
||||
return entra_pim_global_administrator_approval_required().execute()
|
||||
|
||||
def test_no_setting(self):
|
||||
assert self._run({}) == []
|
||||
|
||||
def test_approval_required(self):
|
||||
result = self._run(
|
||||
{
|
||||
GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting(
|
||||
role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
is_approval_required=True,
|
||||
has_approvers=True,
|
||||
)
|
||||
}
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_no_approvers(self):
|
||||
result = self._run(
|
||||
{
|
||||
GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting(
|
||||
role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
is_approval_required=True,
|
||||
has_approvers=False,
|
||||
)
|
||||
}
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_approval_not_required_with_approvers(self):
|
||||
result = self._run(
|
||||
{
|
||||
GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting(
|
||||
role_definition_id=GLOBAL_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
is_approval_required=False,
|
||||
has_approvers=True,
|
||||
)
|
||||
}
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].status_extended == (
|
||||
"PIM does not require approval to activate the Global Administrator role."
|
||||
)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
PimRoleApprovalSetting,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
|
||||
|
||||
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_pim_privileged_role_administrator_approval_required.entra_pim_privileged_role_administrator_approval_required"
|
||||
|
||||
|
||||
class Test_entra_pim_privileged_role_administrator_approval_required:
|
||||
def _run(self, settings):
|
||||
entra_client = mock.MagicMock
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_privileged_role_administrator_approval_required.entra_pim_privileged_role_administrator_approval_required import (
|
||||
entra_pim_privileged_role_administrator_approval_required,
|
||||
)
|
||||
|
||||
entra_client.pim_role_approval_settings = settings
|
||||
return entra_pim_privileged_role_administrator_approval_required().execute()
|
||||
|
||||
def test_no_setting(self):
|
||||
assert self._run({}) == []
|
||||
|
||||
def test_approval_required(self):
|
||||
result = self._run(
|
||||
{
|
||||
PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting(
|
||||
role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
is_approval_required=True,
|
||||
has_approvers=True,
|
||||
)
|
||||
}
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_no_approvers(self):
|
||||
result = self._run(
|
||||
{
|
||||
PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting(
|
||||
role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
is_approval_required=True,
|
||||
has_approvers=False,
|
||||
)
|
||||
}
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].status_extended == (
|
||||
"PIM requires approval to activate the Privileged Role Administrator "
|
||||
"role but no approvers are configured."
|
||||
)
|
||||
|
||||
def test_not_required(self):
|
||||
result = self._run(
|
||||
{
|
||||
PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID: PimRoleApprovalSetting(
|
||||
role_definition_id=PRIVILEGED_ROLE_ADMINISTRATOR_ROLE_TEMPLATE_ID,
|
||||
is_approval_required=False,
|
||||
has_approvers=True,
|
||||
)
|
||||
}
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
@@ -3,7 +3,7 @@ import importlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -42,6 +42,363 @@ UserAction = entra_service.UserAction
|
||||
UsersConditions = entra_service.UsersConditions
|
||||
|
||||
|
||||
def _get_access_review_definitions(definition):
|
||||
service = Entra.__new__(Entra)
|
||||
request_information = MagicMock()
|
||||
service.client = SimpleNamespace(
|
||||
identity_governance=SimpleNamespace(
|
||||
with_url=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
to_get_request_information=MagicMock(
|
||||
return_value=request_information
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
request_adapter=SimpleNamespace(
|
||||
send_primitive_async=AsyncMock(
|
||||
return_value=json.dumps({"value": [definition]}).encode()
|
||||
)
|
||||
),
|
||||
)
|
||||
return asyncio.run(service._get_access_review_definitions())
|
||||
|
||||
|
||||
class TestAccessReviewDefinitions:
|
||||
@pytest.mark.parametrize(
|
||||
("settings", "expected"),
|
||||
[
|
||||
({"defaultDecisionEnabled": True}, True),
|
||||
({"defaultDecisionEnabled": False}, False),
|
||||
({}, False),
|
||||
],
|
||||
)
|
||||
def test_parses_default_decision_enabled(self, settings, expected):
|
||||
definitions = _get_access_review_definitions(
|
||||
{"id": "review-1", "settings": settings}
|
||||
)
|
||||
|
||||
assert definitions[0].default_decision_enabled is expected
|
||||
|
||||
def test_parses_recurrence_and_top_level_reviewers(self):
|
||||
definitions = _get_access_review_definitions(
|
||||
{
|
||||
"id": "review-1",
|
||||
"reviewers": [{"query": "/users/reviewer-1"}],
|
||||
"settings": {
|
||||
"recurrence": {
|
||||
"pattern": {"type": "weekly"},
|
||||
"range": {"type": "noEnd"},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert definitions[0].recurrence_pattern_type == "weekly"
|
||||
assert definitions[0].recurrence_range_type == "noEnd"
|
||||
assert definitions[0].has_primary_reviewers is True
|
||||
|
||||
def test_uses_reviewers_from_every_configured_stage(self):
|
||||
definitions = _get_access_review_definitions(
|
||||
{
|
||||
"id": "review-1",
|
||||
"reviewers": [],
|
||||
"stageSettings": [
|
||||
{"reviewers": [{"query": "/users/reviewer-1"}]},
|
||||
{"reviewers": [{"query": "/users/reviewer-2"}]},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert definitions[0].has_primary_reviewers is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reviewer_configuration",
|
||||
[
|
||||
{"fallbackReviewers": [{"query": "/users/fallback-1"}]},
|
||||
{
|
||||
"reviewers": [{"query": "/users/top-level-reviewer"}],
|
||||
"stageSettings": [
|
||||
{"reviewers": [{"query": "/users/stage-reviewer"}]},
|
||||
{"reviewers": []},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_fallback_or_incomplete_stage_reviewers_do_not_count(
|
||||
self, reviewer_configuration
|
||||
):
|
||||
definitions = _get_access_review_definitions(
|
||||
{"id": "review-1", **reviewer_configuration}
|
||||
)
|
||||
|
||||
assert definitions[0].has_primary_reviewers is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"terminal_response",
|
||||
[b"", json.dumps({"value": []}).encode()],
|
||||
)
|
||||
def test_preserves_definitions_when_terminal_page_is_empty(self, terminal_response):
|
||||
service = Entra.__new__(Entra)
|
||||
first_page = json.dumps(
|
||||
{
|
||||
"value": [{"id": "review-1", "displayName": "Guest Review"}],
|
||||
"@odata.nextLink": "next-link",
|
||||
}
|
||||
).encode()
|
||||
send_mock = AsyncMock(side_effect=[first_page, terminal_response])
|
||||
with_url_mock = MagicMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(
|
||||
to_get_request_information=MagicMock(return_value="request-1")
|
||||
),
|
||||
SimpleNamespace(
|
||||
to_get_request_information=MagicMock(return_value="request-2")
|
||||
),
|
||||
]
|
||||
)
|
||||
service.client = SimpleNamespace(
|
||||
identity_governance=SimpleNamespace(with_url=with_url_mock),
|
||||
request_adapter=SimpleNamespace(send_primitive_async=send_mock),
|
||||
)
|
||||
|
||||
definitions = asyncio.run(service._get_access_review_definitions())
|
||||
|
||||
assert [definition.id for definition in definitions] == ["review-1"]
|
||||
assert send_mock.await_count == 2
|
||||
assert with_url_mock.call_args_list[-1] == call("next-link")
|
||||
|
||||
def test_error_returns_no_access_review_definitions(self):
|
||||
service = Entra.__new__(Entra)
|
||||
service.client = SimpleNamespace(
|
||||
identity_governance=SimpleNamespace(
|
||||
with_url=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
to_get_request_information=MagicMock(return_value="request-1")
|
||||
)
|
||||
)
|
||||
),
|
||||
request_adapter=SimpleNamespace(
|
||||
send_primitive_async=AsyncMock(side_effect=RuntimeError("Graph error"))
|
||||
),
|
||||
)
|
||||
|
||||
assert asyncio.run(service._get_access_review_definitions()) == []
|
||||
|
||||
|
||||
class TestPimRoleApprovalSettings:
|
||||
def test_paginates_and_parses_role_approval_settings(self):
|
||||
service = Entra.__new__(Entra)
|
||||
first_page = json.dumps(
|
||||
{
|
||||
"value": [
|
||||
{"policy": {"rules": []}},
|
||||
{
|
||||
"roleDefinitionId": "role-unrelated",
|
||||
"policy": {"rules": [{"id": "Unrelated_Rule"}]},
|
||||
},
|
||||
{
|
||||
"roleDefinitionId": "role-approval-required",
|
||||
"policy": {
|
||||
"rules": [
|
||||
{
|
||||
"id": "Approval_EndUser_Assignment",
|
||||
"setting": {
|
||||
"isApprovalRequired": True,
|
||||
"approvalStages": [
|
||||
{"primaryApprovers": []},
|
||||
{
|
||||
"primaryApprovers": [
|
||||
{"id": "approver-1"}
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
"@odata.nextLink": "next-link",
|
||||
}
|
||||
).encode()
|
||||
final_page = json.dumps(
|
||||
{
|
||||
"value": [
|
||||
{
|
||||
"roleDefinitionId": "role-approval-disabled",
|
||||
"policy": {
|
||||
"rules": [
|
||||
{
|
||||
"id": "Approval_EndUser_Assignment",
|
||||
"setting": {
|
||||
"isApprovalRequired": False,
|
||||
"approvalStages": [{"primaryApprovers": []}],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
).encode()
|
||||
send_mock = AsyncMock(side_effect=[first_page, final_page])
|
||||
with_url_mock = MagicMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(
|
||||
to_get_request_information=MagicMock(return_value="request-1")
|
||||
),
|
||||
SimpleNamespace(
|
||||
to_get_request_information=MagicMock(return_value="request-2")
|
||||
),
|
||||
]
|
||||
)
|
||||
service.client = SimpleNamespace(
|
||||
policies=SimpleNamespace(with_url=with_url_mock),
|
||||
request_adapter=SimpleNamespace(send_primitive_async=send_mock),
|
||||
)
|
||||
|
||||
settings = asyncio.run(service._get_pim_role_approval_settings())
|
||||
|
||||
assert set(settings) == {
|
||||
"role-unrelated",
|
||||
"role-approval-required",
|
||||
"role-approval-disabled",
|
||||
}
|
||||
assert settings["role-unrelated"].is_approval_required is False
|
||||
assert settings["role-unrelated"].has_approvers is False
|
||||
assert settings["role-approval-required"].is_approval_required is True
|
||||
assert settings["role-approval-required"].has_approvers is True
|
||||
assert settings["role-approval-disabled"].is_approval_required is False
|
||||
assert settings["role-approval-disabled"].has_approvers is False
|
||||
assert send_mock.await_count == 2
|
||||
assert with_url_mock.call_args_list[-1] == call("next-link")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[b"", json.dumps({"value": []}).encode()],
|
||||
)
|
||||
def test_stops_on_falsy_or_empty_response(self, response):
|
||||
service = Entra.__new__(Entra)
|
||||
service.client = SimpleNamespace(
|
||||
policies=SimpleNamespace(
|
||||
with_url=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
to_get_request_information=MagicMock(return_value="request-1")
|
||||
)
|
||||
)
|
||||
),
|
||||
request_adapter=SimpleNamespace(
|
||||
send_primitive_async=AsyncMock(return_value=response)
|
||||
),
|
||||
)
|
||||
|
||||
assert asyncio.run(service._get_pim_role_approval_settings()) == {}
|
||||
|
||||
def test_error_returns_no_role_approval_settings(self):
|
||||
service = Entra.__new__(Entra)
|
||||
service.client = SimpleNamespace(
|
||||
policies=SimpleNamespace(
|
||||
with_url=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
to_get_request_information=MagicMock(return_value="request-1")
|
||||
)
|
||||
)
|
||||
),
|
||||
request_adapter=SimpleNamespace(
|
||||
send_primitive_async=AsyncMock(side_effect=RuntimeError("Graph error"))
|
||||
),
|
||||
)
|
||||
|
||||
assert asyncio.run(service._get_pim_role_approval_settings()) == {}
|
||||
|
||||
|
||||
class TestAuthenticationMethodsPolicySettings:
|
||||
@staticmethod
|
||||
def _service(response=None, error=None):
|
||||
send_mock = AsyncMock(return_value=response, side_effect=error)
|
||||
service = Entra.__new__(Entra)
|
||||
service.client = SimpleNamespace(
|
||||
policies=SimpleNamespace(
|
||||
authentication_methods_policy=SimpleNamespace(
|
||||
with_url=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
to_get_request_information=MagicMock(
|
||||
return_value="request-1"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
request_adapter=SimpleNamespace(send_primitive_async=send_mock),
|
||||
)
|
||||
return service
|
||||
|
||||
def test_parses_microsoft_authenticator_after_unrelated_configuration(self):
|
||||
response = json.dumps(
|
||||
{
|
||||
"authenticationMethodConfigurations": [
|
||||
{"id": "Fido2", "state": "enabled"},
|
||||
{
|
||||
"id": "MicrosoftAuthenticator",
|
||||
"state": "enabled",
|
||||
"featureSettings": {
|
||||
"displayAppInformationRequiredState": {"state": "enabled"},
|
||||
"displayLocationInformationRequiredState": {
|
||||
"state": "disabled"
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
).encode()
|
||||
|
||||
settings = asyncio.run(
|
||||
self._service(
|
||||
response=response
|
||||
)._get_authentication_methods_policy_settings()
|
||||
)
|
||||
|
||||
assert settings.authenticator_state == "enabled"
|
||||
assert settings.authenticator_display_app_information_state == "enabled"
|
||||
assert settings.authenticator_display_location_information_state == "disabled"
|
||||
|
||||
def test_missing_microsoft_authenticator_returns_unconfigured_model(self):
|
||||
response = json.dumps(
|
||||
{"authenticationMethodConfigurations": [{"id": "Fido2"}]}
|
||||
).encode()
|
||||
|
||||
settings = asyncio.run(
|
||||
self._service(
|
||||
response=response
|
||||
)._get_authentication_methods_policy_settings()
|
||||
)
|
||||
|
||||
assert settings.authenticator_state is None
|
||||
assert settings.authenticator_display_app_information_state is None
|
||||
assert settings.authenticator_display_location_information_state is None
|
||||
|
||||
def test_falsy_response_returns_none(self):
|
||||
assert (
|
||||
asyncio.run(
|
||||
self._service(
|
||||
response=b""
|
||||
)._get_authentication_methods_policy_settings()
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_error_returns_none(self):
|
||||
assert (
|
||||
asyncio.run(
|
||||
self._service(
|
||||
error=RuntimeError("Graph error")
|
||||
)._get_authentication_methods_policy_settings()
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def mock_entra_get_authorization_policy(_):
|
||||
return AuthorizationPolicy(
|
||||
id="id-1",
|
||||
|
||||
Reference in New Issue
Block a user