mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-14 15:34:19 +00:00
feat(m365): add entra_cross_tenant_access_default_outbound_restricted check (#12991)
Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
This commit is contained in:
8 files changed
+797
No files matched your search
@@ -0,0 +1 @@
|
||||
`entra_cross_tenant_access_default_outbound_restricted` check for M365 provider, verifying that default cross-tenant access does not let all users reach all external applications through B2B collaboration or B2B direct connect
|
||||
@@ -176,6 +176,7 @@
|
||||
"Checks": [
|
||||
"entra_app_registration_redirect_uris_secure",
|
||||
"entra_conditional_access_policy_app_enforced_restrictions",
|
||||
"entra_cross_tenant_access_default_outbound_restricted",
|
||||
"exchange_transport_config_smtp_auth_disabled",
|
||||
"exchange_transport_rules_mail_forwarding_disabled",
|
||||
"exchange_transport_rules_whitelist_disabled",
|
||||
@@ -208,6 +209,7 @@
|
||||
"entra_conditional_access_policy_block_unknown_device_platforms",
|
||||
"entra_conditional_access_policy_directory_sync_account_excluded",
|
||||
"entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced",
|
||||
"entra_cross_tenant_access_default_outbound_restricted",
|
||||
"entra_dynamic_group_member_of_rule_not_used",
|
||||
"entra_policy_guest_users_access_restrictions",
|
||||
"entra_seamless_sso_disabled"
|
||||
|
||||
Whitespace-only changes.
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "entra_cross_tenant_access_default_outbound_restricted",
|
||||
"CheckTitle": "Default cross-tenant access policy restricts outbound B2B collaboration and direct connect",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "The default cross-tenant access policy should **not** allow every user and group to access every application of any external tenant through B2B collaboration outbound or B2B direct connect outbound.\n\nOutbound access should be scoped to specific groups and applications, or blocked by default and opened per partner organization.",
|
||||
"Risk": "With unrestricted outbound defaults, any user can join any external organization, accept B2B invitations, or use Teams shared channels via direct connect without approval. This widens **data exfiltration paths**, lets attackers lure users into attacker-controlled tenants, and removes control over which external organizations the tenant collaborates with.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/entra/external-id/cross-tenant-access-overview",
|
||||
"https://learn.microsoft.com/entra/external-id/cross-tenant-access-settings-b2b-collaboration"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default' -Body '{\"b2bCollaborationOutbound\": {\"usersAndGroups\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllUsers\", \"targetType\": \"user\"}]}, \"applications\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllApplications\", \"targetType\": \"application\"}]}}, \"b2bDirectConnectOutbound\": {\"usersAndGroups\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllUsers\", \"targetType\": \"user\"}]}, \"applications\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllApplications\", \"targetType\": \"application\"}]}}}'",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **External Identities** > **Cross-tenant access settings**\n3. Select the **Default settings** tab\n4. Under **Outbound access settings**, click **Edit outbound defaults**\n5. On the **B2B collaboration** tab, restrict **Users and groups** and **Applications** to specific selections or set access to **Block**\n6. On the **B2B direct connect** tab, restrict **Users and groups** and **Applications** to specific selections or set access to **Block**\n7. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Restrict default outbound cross-tenant access to specific groups and applications, or block it by default and allow collaboration per partner organization using partner-specific policies.",
|
||||
"Url": "https://hub.prowler.com/check/entra_cross_tenant_access_default_outbound_restricted"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries",
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"entra_policy_guest_invitations_restricted_to_allowed_domains"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
CrossTenantB2BSetting,
|
||||
CrossTenantTargetConfiguration,
|
||||
)
|
||||
|
||||
# Targets that cover every user or every application. AllMicrosoftApps is
|
||||
# treated as broad too: allowing it opens and blocking it closes access to
|
||||
# every Microsoft cloud application.
|
||||
ALL_USERS_TARGETS = {"AllUsers"}
|
||||
ALL_APPLICATIONS_TARGETS = {"AllApplications", "AllMicrosoftApps"}
|
||||
|
||||
|
||||
class entra_cross_tenant_access_default_outbound_restricted(Check):
|
||||
"""Ensure default cross-tenant access outbound settings are restricted.
|
||||
|
||||
The default cross-tenant access policy must not allow every user and group
|
||||
to access every application of any external tenant through B2B collaboration
|
||||
outbound or B2B direct connect outbound.
|
||||
|
||||
- PASS: Neither b2bCollaborationOutbound nor b2bDirectConnectOutbound is
|
||||
fully open (allowed for AllUsers AND AllApplications).
|
||||
- FAIL: At least one of the outbound settings allows all users and all
|
||||
applications.
|
||||
- MANUAL: The default policy or one of its outbound settings could not be
|
||||
read or is incomplete, and no setting is fully open.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the cross-tenant access default outbound restriction check.
|
||||
|
||||
Evaluates the tenant's default cross-tenant access policy to determine
|
||||
whether B2B collaboration outbound and/or B2B direct connect outbound
|
||||
are unrestricted (allowed for all users and all applications).
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
policy = entra_client.cross_tenant_access_default
|
||||
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=policy if policy else {},
|
||||
resource_name="Cross-Tenant Access Default Policy",
|
||||
resource_id="crossTenantAccessPolicyConfigurationDefault",
|
||||
)
|
||||
|
||||
if policy is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
"Cannot evaluate the default cross-tenant access outbound settings: "
|
||||
"the policy could not be read. "
|
||||
"Verify that the Policy.Read.All permission is granted to the scanning application."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
collaboration_open = self._is_fully_open(policy.b2b_collaboration_outbound)
|
||||
direct_connect_open = self._is_fully_open(policy.b2b_direct_connect_outbound)
|
||||
|
||||
unrestricted_settings = []
|
||||
if collaboration_open:
|
||||
unrestricted_settings.append("B2B collaboration outbound")
|
||||
if direct_connect_open:
|
||||
unrestricted_settings.append("B2B direct connect outbound")
|
||||
|
||||
if unrestricted_settings:
|
||||
report.status = "FAIL"
|
||||
joined = " and ".join(unrestricted_settings)
|
||||
report.status_extended = (
|
||||
f"Default cross-tenant access policy leaves {joined} broadly open: "
|
||||
f"access is not limited to specific users or groups and to specific "
|
||||
f"applications in external tenants."
|
||||
)
|
||||
elif collaboration_open is None or direct_connect_open is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
"Cannot evaluate the default cross-tenant access outbound settings: "
|
||||
"the users and groups or applications configuration of an outbound "
|
||||
"setting is incomplete."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"Default cross-tenant access policy restricts outbound access "
|
||||
"for both B2B collaboration and B2B direct connect."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
@staticmethod
|
||||
def _is_fully_open(setting: Optional[CrossTenantB2BSetting]) -> Optional[bool]:
|
||||
"""Determine whether a B2B outbound setting is broadly open.
|
||||
|
||||
In the default policy a ``blocked`` list is an exception list: blocking
|
||||
specific users, groups or applications leaves everything else allowed.
|
||||
A dimension is therefore restricted only when it is ``allowed`` for
|
||||
specific targets or ``blocked`` for everyone. The setting is open when
|
||||
neither users and groups nor applications are restricted.
|
||||
|
||||
Args:
|
||||
setting: The B2B setting to evaluate, or None if absent.
|
||||
|
||||
Returns:
|
||||
True if the setting is broadly open, False if users or applications
|
||||
are restricted, or None if its configuration is missing or incomplete.
|
||||
"""
|
||||
if setting is None:
|
||||
return None
|
||||
|
||||
configurations = (setting.users_and_groups, setting.applications)
|
||||
if any(
|
||||
config is None
|
||||
or config.access_type not in ("allowed", "blocked")
|
||||
or not config.targets
|
||||
for config in configurations
|
||||
):
|
||||
return None
|
||||
|
||||
users_open = (
|
||||
entra_cross_tenant_access_default_outbound_restricted._dimension_open(
|
||||
setting.users_and_groups,
|
||||
allowed_all_targets=ALL_USERS_TARGETS,
|
||||
blocked_all_targets=ALL_USERS_TARGETS,
|
||||
)
|
||||
)
|
||||
apps_open = (
|
||||
entra_cross_tenant_access_default_outbound_restricted._dimension_open(
|
||||
setting.applications,
|
||||
allowed_all_targets=ALL_APPLICATIONS_TARGETS,
|
||||
blocked_all_targets=ALL_APPLICATIONS_TARGETS,
|
||||
)
|
||||
)
|
||||
return users_open and apps_open
|
||||
|
||||
@staticmethod
|
||||
def _dimension_open(
|
||||
config: CrossTenantTargetConfiguration,
|
||||
allowed_all_targets: Set[str],
|
||||
blocked_all_targets: Set[str],
|
||||
) -> bool:
|
||||
"""Tell whether one dimension (users or applications) is left open.
|
||||
|
||||
Args:
|
||||
config: The users-and-groups or applications configuration.
|
||||
allowed_all_targets: Targets that open the dimension when allowed.
|
||||
blocked_all_targets: Targets that close the dimension when blocked.
|
||||
|
||||
Returns:
|
||||
True if everyone (or every application) can still be reached.
|
||||
"""
|
||||
targets = {target.target for target in config.targets}
|
||||
if config.access_type == "allowed":
|
||||
return bool(targets & allowed_all_targets)
|
||||
# blocked: only blocking everyone restricts the dimension.
|
||||
return not targets & blocked_all_targets
|
||||
@@ -49,6 +49,7 @@ class Entra(M365Service):
|
||||
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
|
||||
authentication_method_configurations (dict): Dictionary of authentication method configurations.
|
||||
service_principals (dict): Dictionary of service principals with credentials and role assignments.
|
||||
cross_tenant_access_default (CrossTenantAccessDefault): The default cross-tenant access policy configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: M365Provider):
|
||||
@@ -118,6 +119,7 @@ class Entra(M365Service):
|
||||
self._get_authentication_methods_policy_settings(),
|
||||
self._get_pim_role_approval_settings(),
|
||||
self._get_access_review_definitions(),
|
||||
self._get_cross_tenant_access_default(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -154,6 +156,9 @@ class Entra(M365Service):
|
||||
19
|
||||
]
|
||||
self.access_review_definitions: List[AccessReviewDefinition] = attributes[20]
|
||||
self.cross_tenant_access_default: Optional[CrossTenantAccessDefault] = (
|
||||
attributes[21]
|
||||
)
|
||||
self.user_accounts_status = {}
|
||||
|
||||
# Resolve directory-object identifiers referenced by Conditional Access
|
||||
@@ -1587,6 +1592,86 @@ OAuthAppInfo
|
||||
)
|
||||
return b2b_policy
|
||||
|
||||
async def _get_cross_tenant_access_default(self):
|
||||
"""Retrieve the default cross-tenant access policy configuration.
|
||||
|
||||
Fetches the tenant's default cross-tenant access policy from
|
||||
``policies/crossTenantAccessPolicy/default`` to determine whether
|
||||
B2B collaboration outbound and B2B direct connect outbound settings
|
||||
are unrestricted (allowed for all users and all applications).
|
||||
|
||||
Returns:
|
||||
Optional[CrossTenantAccessDefault]: The parsed default cross-tenant
|
||||
access policy, or None on error.
|
||||
"""
|
||||
logger.info("Entra - Getting cross-tenant access default policy...")
|
||||
cross_tenant_default = None
|
||||
try:
|
||||
policy = await self.client.policies.cross_tenant_access_policy.default.get()
|
||||
if policy:
|
||||
cross_tenant_default = CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=self._parse_b2b_setting(
|
||||
policy.b2b_collaboration_outbound
|
||||
),
|
||||
b2b_direct_connect_outbound=self._parse_b2b_setting(
|
||||
policy.b2b_direct_connect_outbound
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return cross_tenant_default
|
||||
|
||||
@staticmethod
|
||||
def _parse_b2b_setting(setting) -> Optional["CrossTenantB2BSetting"]:
|
||||
"""Parse a crossTenantAccessPolicyB2BSetting into a model.
|
||||
|
||||
Extracts the ``usersAndGroups`` and ``applications`` target configuration
|
||||
from a Graph SDK B2B setting object and converts them into a
|
||||
``CrossTenantB2BSetting`` model.
|
||||
|
||||
Args:
|
||||
setting: A Graph SDK ``CrossTenantAccessPolicyB2BSetting`` object,
|
||||
or None if the setting is absent.
|
||||
|
||||
Returns:
|
||||
Optional[CrossTenantB2BSetting]: The parsed B2B setting, or None
|
||||
if the input is None.
|
||||
"""
|
||||
if setting is None:
|
||||
return None
|
||||
|
||||
def parse_target_config(config) -> Optional["CrossTenantTargetConfiguration"]:
|
||||
"""Parse a target configuration (usersAndGroups or applications)."""
|
||||
if config is None:
|
||||
return None
|
||||
access_type = None
|
||||
if config.access_type is not None:
|
||||
access_type = config.access_type.value
|
||||
targets = []
|
||||
if config.targets:
|
||||
for t in config.targets:
|
||||
target_value = getattr(t, "target", None)
|
||||
target_type = getattr(t, "target_type", None)
|
||||
if target_type is not None:
|
||||
target_type = target_type.value
|
||||
targets.append(
|
||||
CrossTenantTarget(
|
||||
target=target_value,
|
||||
target_type=target_type,
|
||||
)
|
||||
)
|
||||
return CrossTenantTargetConfiguration(
|
||||
access_type=access_type,
|
||||
targets=targets,
|
||||
)
|
||||
|
||||
return CrossTenantB2BSetting(
|
||||
users_and_groups=parse_target_config(setting.users_and_groups),
|
||||
applications=parse_target_config(setting.applications),
|
||||
)
|
||||
|
||||
async def _get_named_locations(self):
|
||||
"""Retrieve Conditional Access named locations from Microsoft Entra.
|
||||
|
||||
@@ -3008,3 +3093,56 @@ class AppRegistration(BaseModel):
|
||||
web_redirect_uris: List[str] = []
|
||||
spa_redirect_uris: List[str] = []
|
||||
public_client_redirect_uris: List[str] = []
|
||||
|
||||
|
||||
class CrossTenantTarget(BaseModel):
|
||||
"""A single target entry in a cross-tenant access target configuration.
|
||||
|
||||
Attributes:
|
||||
target: The target identifier (e.g. ``AllUsers``, ``AllApplications``,
|
||||
or a specific group/application ID).
|
||||
target_type: The type of the target (e.g. ``user``, ``application``).
|
||||
"""
|
||||
|
||||
target: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
|
||||
|
||||
class CrossTenantTargetConfiguration(BaseModel):
|
||||
"""Target configuration for users/groups or applications in a B2B setting.
|
||||
|
||||
Attributes:
|
||||
access_type: The access type (``allowed``, ``blocked``, or
|
||||
``unknownFutureValue``). None if not set.
|
||||
targets: List of targets for this configuration.
|
||||
"""
|
||||
|
||||
access_type: Optional[str] = None
|
||||
targets: List[CrossTenantTarget] = []
|
||||
|
||||
|
||||
class CrossTenantB2BSetting(BaseModel):
|
||||
"""A B2B setting (collaboration or direct connect) within the cross-tenant policy.
|
||||
|
||||
Attributes:
|
||||
users_and_groups: Target configuration for users and groups.
|
||||
applications: Target configuration for applications.
|
||||
"""
|
||||
|
||||
users_and_groups: Optional[CrossTenantTargetConfiguration] = None
|
||||
applications: Optional[CrossTenantTargetConfiguration] = None
|
||||
|
||||
|
||||
class CrossTenantAccessDefault(BaseModel):
|
||||
"""Default cross-tenant access policy configuration.
|
||||
|
||||
Represents the tenant's default cross-tenant access policy, specifically
|
||||
the B2B collaboration outbound and B2B direct connect outbound settings.
|
||||
|
||||
Attributes:
|
||||
b2b_collaboration_outbound: The outbound B2B collaboration setting.
|
||||
b2b_direct_connect_outbound: The outbound B2B direct connect setting.
|
||||
"""
|
||||
|
||||
b2b_collaboration_outbound: Optional[CrossTenantB2BSetting] = None
|
||||
b2b_direct_connect_outbound: Optional[CrossTenantB2BSetting] = None
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
CrossTenantAccessDefault,
|
||||
CrossTenantB2BSetting,
|
||||
CrossTenantTarget,
|
||||
CrossTenantTargetConfiguration,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
|
||||
|
||||
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_cross_tenant_access_default_outbound_restricted.entra_cross_tenant_access_default_outbound_restricted"
|
||||
|
||||
|
||||
def _all_users_all_apps_allowed():
|
||||
"""Return a B2B setting that allows all users and all applications."""
|
||||
return CrossTenantB2BSetting(
|
||||
users_and_groups=CrossTenantTargetConfiguration(
|
||||
access_type="allowed",
|
||||
targets=[CrossTenantTarget(target="AllUsers", target_type="user")],
|
||||
),
|
||||
applications=CrossTenantTargetConfiguration(
|
||||
access_type="allowed",
|
||||
targets=[
|
||||
CrossTenantTarget(target="AllApplications", target_type="application")
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _blocked_setting():
|
||||
"""Return a B2B setting that blocks all users and all applications."""
|
||||
return CrossTenantB2BSetting(
|
||||
users_and_groups=CrossTenantTargetConfiguration(
|
||||
access_type="blocked",
|
||||
targets=[CrossTenantTarget(target="AllUsers", target_type="user")],
|
||||
),
|
||||
applications=CrossTenantTargetConfiguration(
|
||||
access_type="blocked",
|
||||
targets=[
|
||||
CrossTenantTarget(target="AllApplications", target_type="application")
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _specific_groups_allowed():
|
||||
"""Return a B2B setting that allows only specific groups and all apps."""
|
||||
return CrossTenantB2BSetting(
|
||||
users_and_groups=CrossTenantTargetConfiguration(
|
||||
access_type="allowed",
|
||||
targets=[
|
||||
CrossTenantTarget(
|
||||
target="00000000-0000-0000-0000-000000000001",
|
||||
target_type="user",
|
||||
)
|
||||
],
|
||||
),
|
||||
applications=CrossTenantTargetConfiguration(
|
||||
access_type="allowed",
|
||||
targets=[
|
||||
CrossTenantTarget(target="AllApplications", target_type="application")
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _setting(users_access, users_targets, apps_access, apps_targets):
|
||||
return CrossTenantB2BSetting(
|
||||
users_and_groups=CrossTenantTargetConfiguration(
|
||||
access_type=users_access,
|
||||
targets=[
|
||||
CrossTenantTarget(target=t, target_type="user") for t in users_targets
|
||||
],
|
||||
),
|
||||
applications=CrossTenantTargetConfiguration(
|
||||
access_type=apps_access,
|
||||
targets=[
|
||||
CrossTenantTarget(target=t, target_type="application")
|
||||
for t in apps_targets
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Test_entra_cross_tenant_access_default_outbound_restricted:
|
||||
def _run(self, policy):
|
||||
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_cross_tenant_access_default_outbound_restricted.entra_cross_tenant_access_default_outbound_restricted import (
|
||||
entra_cross_tenant_access_default_outbound_restricted,
|
||||
)
|
||||
|
||||
entra_client.cross_tenant_access_default = policy
|
||||
return entra_cross_tenant_access_default_outbound_restricted().execute()
|
||||
|
||||
def test_no_policy(self):
|
||||
"""MANUAL when the policy could not be retrieved."""
|
||||
result = self._run(None)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "could not be read" in result[0].status_extended
|
||||
|
||||
def test_both_blocked_pass(self):
|
||||
"""PASS when both outbound settings are blocked."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_blocked_setting(),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "restricts outbound access" in result[0].status_extended
|
||||
|
||||
def test_collaboration_outbound_unrestricted_fail(self):
|
||||
"""FAIL when B2B collaboration outbound allows all users and all apps."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_all_users_all_apps_allowed(),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "B2B collaboration outbound" in result[0].status_extended
|
||||
assert "B2B direct connect outbound" not in result[0].status_extended
|
||||
|
||||
def test_direct_connect_outbound_unrestricted_fail(self):
|
||||
"""FAIL when B2B direct connect outbound allows all users and all apps."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_blocked_setting(),
|
||||
b2b_direct_connect_outbound=_all_users_all_apps_allowed(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "B2B direct connect outbound" in result[0].status_extended
|
||||
assert "B2B collaboration outbound" not in result[0].status_extended
|
||||
|
||||
def test_both_outbound_unrestricted_fail(self):
|
||||
"""FAIL when both outbound settings allow all users and all apps."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_all_users_all_apps_allowed(),
|
||||
b2b_direct_connect_outbound=_all_users_all_apps_allowed(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "B2B collaboration outbound" in result[0].status_extended
|
||||
assert "B2B direct connect outbound" in result[0].status_extended
|
||||
|
||||
def test_specific_groups_allowed_pass(self):
|
||||
"""PASS when users are scoped to specific groups (not AllUsers)."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_specific_groups_allowed(),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_none_b2b_settings_manual(self):
|
||||
"""MANUAL when the outbound settings could not be read."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=None,
|
||||
b2b_direct_connect_outbound=None,
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_incomplete_setting_with_blocked_setting_manual(self):
|
||||
"""MANUAL when no setting is open but one has no users and groups data."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=CrossTenantB2BSetting(
|
||||
users_and_groups=None,
|
||||
applications=_blocked_setting().applications,
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "incomplete" in result[0].status_extended
|
||||
|
||||
def test_setting_without_targets_manual(self):
|
||||
"""MANUAL when an access configuration has no targets."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=CrossTenantB2BSetting(
|
||||
users_and_groups=CrossTenantTargetConfiguration(
|
||||
access_type="allowed", targets=[]
|
||||
),
|
||||
applications=_blocked_setting().applications,
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
|
||||
def test_open_setting_with_incomplete_setting_fail(self):
|
||||
"""FAIL takes precedence when one setting is open and the other incomplete."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_all_users_all_apps_allowed(),
|
||||
b2b_direct_connect_outbound=CrossTenantB2BSetting(
|
||||
users_and_groups=None, applications=None
|
||||
),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "B2B collaboration outbound" in result[0].status_extended
|
||||
|
||||
def test_open_setting_with_missing_setting_fail(self):
|
||||
"""FAIL takes precedence when one setting is open and the other missing."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_all_users_all_apps_allowed(),
|
||||
b2b_direct_connect_outbound=None,
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "B2B collaboration outbound" in result[0].status_extended
|
||||
|
||||
def test_users_allowed_all_but_apps_blocked_pass(self):
|
||||
"""PASS when users are allowed for all but applications are blocked."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=CrossTenantB2BSetting(
|
||||
users_and_groups=CrossTenantTargetConfiguration(
|
||||
access_type="allowed",
|
||||
targets=[
|
||||
CrossTenantTarget(target="AllUsers", target_type="user")
|
||||
],
|
||||
),
|
||||
applications=CrossTenantTargetConfiguration(
|
||||
access_type="blocked",
|
||||
targets=[
|
||||
CrossTenantTarget(
|
||||
target="AllApplications",
|
||||
target_type="application",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_apps_allowed_all_but_users_blocked_pass(self):
|
||||
"""PASS when applications are allowed for all but users are blocked."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=CrossTenantB2BSetting(
|
||||
users_and_groups=CrossTenantTargetConfiguration(
|
||||
access_type="blocked",
|
||||
targets=[
|
||||
CrossTenantTarget(target="AllUsers", target_type="user")
|
||||
],
|
||||
),
|
||||
applications=CrossTenantTargetConfiguration(
|
||||
access_type="allowed",
|
||||
targets=[
|
||||
CrossTenantTarget(
|
||||
target="AllApplications",
|
||||
target_type="application",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_resource_metadata(self):
|
||||
"""Verify resource_name and resource_id are set correctly."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_blocked_setting(),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].resource_name == "Cross-Tenant Access Default Policy"
|
||||
assert result[0].resource_id == "crossTenantAccessPolicyConfigurationDefault"
|
||||
|
||||
def test_blocking_one_group_with_all_apps_allowed_fails(self):
|
||||
"""Blocking a single group leaves every other user able to reach every app."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_setting(
|
||||
"blocked", ["group-1"], "allowed", ["AllApplications"]
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
assert "B2B collaboration outbound" in result[0].status_extended
|
||||
|
||||
def test_blocking_one_app_with_all_users_allowed_fails(self):
|
||||
"""Blocking a single application leaves every other application reachable."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_setting(
|
||||
"allowed", ["AllUsers"], "blocked", ["app-1"]
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_all_microsoft_apps_counts_as_open(self):
|
||||
"""AllMicrosoftApps reaches every Microsoft cloud application."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_setting(
|
||||
"allowed", ["AllUsers"], "allowed", ["AllMicrosoftApps"]
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_blocking_all_microsoft_apps_restricts_applications(self):
|
||||
"""Blocking AllMicrosoftApps closes the applications dimension."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_setting(
|
||||
"blocked", ["group-1"], "blocked", ["AllMicrosoftApps"]
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_users_limited_to_group_with_partial_app_block_passes(self):
|
||||
"""Users allowed only for a specific group restrict the setting."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_setting(
|
||||
"allowed", ["group-1"], "blocked", ["app-1"]
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
|
||||
def test_all_users_blocked_with_all_apps_allowed_passes(self):
|
||||
"""Blocking every user restricts the setting whatever the applications."""
|
||||
result = self._run(
|
||||
CrossTenantAccessDefault(
|
||||
b2b_collaboration_outbound=_setting(
|
||||
"blocked", ["AllUsers"], "allowed", ["AllApplications"]
|
||||
),
|
||||
b2b_direct_connect_outbound=_blocked_setting(),
|
||||
)
|
||||
)
|
||||
assert result[0].status == "PASS"
|
||||
@@ -2161,3 +2161,82 @@ class TestGetGroups:
|
||||
assert entra_service.groups_error == (
|
||||
"Unable to retrieve groups from Microsoft Graph (PermissionError)"
|
||||
)
|
||||
|
||||
|
||||
class TestCrossTenantAccessDefault:
|
||||
"""The Graph SDK default policy is mapped to ``CrossTenantAccessDefault``."""
|
||||
|
||||
def test_maps_outbound_settings_from_the_sdk_model(self):
|
||||
from msgraph.generated.models.cross_tenant_access_policy_b2_b_setting import (
|
||||
CrossTenantAccessPolicyB2BSetting,
|
||||
)
|
||||
from msgraph.generated.models.cross_tenant_access_policy_configuration_default import (
|
||||
CrossTenantAccessPolicyConfigurationDefault,
|
||||
)
|
||||
from msgraph.generated.models.cross_tenant_access_policy_target import (
|
||||
CrossTenantAccessPolicyTarget,
|
||||
)
|
||||
from msgraph.generated.models.cross_tenant_access_policy_target_configuration import (
|
||||
CrossTenantAccessPolicyTargetConfiguration,
|
||||
)
|
||||
from msgraph.generated.models.cross_tenant_access_policy_target_configuration_access_type import (
|
||||
CrossTenantAccessPolicyTargetConfigurationAccessType as AccessType,
|
||||
)
|
||||
from msgraph.generated.models.cross_tenant_access_policy_target_type import (
|
||||
CrossTenantAccessPolicyTargetType as TargetType,
|
||||
)
|
||||
|
||||
def config(access, target, target_type):
|
||||
return CrossTenantAccessPolicyTargetConfiguration(
|
||||
access_type=access,
|
||||
targets=[
|
||||
CrossTenantAccessPolicyTarget(
|
||||
target=target, target_type=target_type
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
graph_policy = CrossTenantAccessPolicyConfigurationDefault(
|
||||
b2b_collaboration_outbound=CrossTenantAccessPolicyB2BSetting(
|
||||
users_and_groups=config(
|
||||
AccessType.Blocked, "group-1", TargetType.Group
|
||||
),
|
||||
applications=config(
|
||||
AccessType.Allowed, "AllApplications", TargetType.Application
|
||||
),
|
||||
),
|
||||
b2b_direct_connect_outbound=None,
|
||||
)
|
||||
service = Entra.__new__(Entra)
|
||||
service.client = SimpleNamespace(
|
||||
policies=SimpleNamespace(
|
||||
cross_tenant_access_policy=SimpleNamespace(
|
||||
default=SimpleNamespace(get=AsyncMock(return_value=graph_policy))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
policy = asyncio.run(service._get_cross_tenant_access_default())
|
||||
|
||||
users = policy.b2b_collaboration_outbound.users_and_groups
|
||||
apps = policy.b2b_collaboration_outbound.applications
|
||||
assert users.access_type == "blocked"
|
||||
assert users.targets[0].target == "group-1"
|
||||
assert users.targets[0].target_type == "group"
|
||||
assert apps.access_type == "allowed"
|
||||
assert apps.targets[0].target == "AllApplications"
|
||||
assert policy.b2b_direct_connect_outbound is None
|
||||
|
||||
def test_read_error_returns_none(self):
|
||||
service = Entra.__new__(Entra)
|
||||
service.client = SimpleNamespace(
|
||||
policies=SimpleNamespace(
|
||||
cross_tenant_access_policy=SimpleNamespace(
|
||||
default=SimpleNamespace(
|
||||
get=AsyncMock(side_effect=RuntimeError("403"))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert asyncio.run(service._get_cross_tenant_access_default()) is None
|
||||
Reference in new issue
Block a user