diff --git a/data_safe_haven/config/config_sections.py b/data_safe_haven/config/config_sections.py index 62bfec0833..e1a41d2c6c 100644 --- a/data_safe_haven/config/config_sections.py +++ b/data_safe_haven/config/config_sections.py @@ -56,6 +56,7 @@ class ConfigSectionSRE(BaseModel, validate_assignment=True): # https://docs.pydantic.dev/latest/concepts/models/#fields-with-non-hashable-default-values admin_email_address: EmailAddress admin_ip_addresses: list[IpAddress] = [] + allow_workspace_internet: bool = False databases: UniqueList[DatabaseSystem] = [] data_provider_ip_addresses: list[IpAddress] | AzureServiceTag = [] remote_desktop: ConfigSubsectionRemoteDesktopOpts diff --git a/data_safe_haven/infrastructure/programs/declarative_sre.py b/data_safe_haven/infrastructure/programs/declarative_sre.py index 2228078c36..02cc59b71c 100644 --- a/data_safe_haven/infrastructure/programs/declarative_sre.py +++ b/data_safe_haven/infrastructure/programs/declarative_sre.py @@ -123,6 +123,7 @@ def __call__(self) -> None: "sre_dns_server", self.stack_name, SREDnsServerProps( + allow_workspace_internet=self.config.sre.allow_workspace_internet, dockerhub_credentials=dockerhub_credentials, location=self.config.azure.location, resource_group_name=resource_group.name, @@ -182,6 +183,7 @@ def __call__(self) -> None: "sre_firewall", self.stack_name, SREFirewallProps( + allow_workspace_internet=self.config.sre.allow_workspace_internet, location=self.config.azure.location, log_analytics_workspace=monitoring.log_analytics, resource_group_name=resource_group.name, diff --git a/data_safe_haven/infrastructure/programs/sre/dns_server.py b/data_safe_haven/infrastructure/programs/sre/dns_server.py index df85e09d83..b50c908f86 100644 --- a/data_safe_haven/infrastructure/programs/sre/dns_server.py +++ b/data_safe_haven/infrastructure/programs/sre/dns_server.py @@ -28,12 +28,15 @@ class SREDnsServerProps: def __init__( self, + *, + allow_workspace_internet: bool, dockerhub_credentials: DockerHubCredentials, location: Input[str], resource_group_name: Input[str], shm_fqdn: Input[str], ) -> None: self.admin_username = "dshadmin" + self.allow_workspace_internet: bool = allow_workspace_internet self.dockerhub_credentials = dockerhub_credentials self.location = location self.resource_group_name = resource_group_name @@ -69,6 +72,9 @@ def __init__( ) # Expand AdGuardHome YAML configuration + mustache_values: dict[str, object] = { + "allow_workspace_internet": props.allow_workspace_internet + } adguard_adguardhome_yaml_contents = Output.all( admin_username=props.admin_username, # Only the first 72 bytes of the generated random string will be used but a @@ -85,8 +91,8 @@ def __init__( ] ), ).apply( - lambda mustache_values: adguard_adguardhome_yaml_reader.file_contents( - mustache_values + lambda mustache_config: adguard_adguardhome_yaml_reader.file_contents( + mustache_config | mustache_values ) ) diff --git a/data_safe_haven/infrastructure/programs/sre/firewall.py b/data_safe_haven/infrastructure/programs/sre/firewall.py index ed831e826a..8408ff89b5 100644 --- a/data_safe_haven/infrastructure/programs/sre/firewall.py +++ b/data_safe_haven/infrastructure/programs/sre/firewall.py @@ -23,6 +23,8 @@ class SREFirewallProps: def __init__( self, + *, + allow_workspace_internet: bool, location: Input[str], log_analytics_workspace: Input[WrappedLogAnalyticsWorkspace], resource_group_name: Input[str], @@ -36,6 +38,7 @@ def __init__( subnet_user_services_software_repositories: Input[network.GetSubnetResult], subnet_workspaces: Input[network.GetSubnetResult], ) -> None: + self.allow_workspace_internet = allow_workspace_internet self.location = location self.log_analytics_workspace = log_analytics_workspace self.resource_group_name = resource_group_name @@ -111,206 +114,238 @@ def __init__( tags=child_tags, ) - # Deploy firewall - firewall = network.AzureFirewall( - f"{self._name}_firewall", - application_rule_collections=[ - network.AzureFirewallApplicationRuleCollectionArgs( - action=network.AzureFirewallRCActionArgs( - type=network.AzureFirewallRCActionType.ALLOW + application_rule_collections: list[ + network.AzureFirewallApplicationRuleCollectionArgs + ] = [ + network.AzureFirewallApplicationRuleCollectionArgs( + action=network.AzureFirewallRCActionArgs( + type=network.AzureFirewallRCActionType.ALLOW + ), + name="apt-proxy-server", + priority=FirewallPriorities.SRE_APT_PROXY_SERVER, + rules=[ + network.AzureFirewallApplicationRuleArgs( + description="Allow external apt repository requests", + name="AllowAptRepositories", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTP), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, + ), + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ), + ], + source_addresses=props.subnet_apt_proxy_server_prefixes, + target_fqdns=PermittedDomains.APT_REPOSITORIES, ), - name="apt-proxy-server", - priority=FirewallPriorities.SRE_APT_PROXY_SERVER, - rules=[ - network.AzureFirewallApplicationRuleArgs( - description="Allow external apt repository requests", - name="AllowAptRepositories", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTP), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, - ), - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ), - ], - source_addresses=props.subnet_apt_proxy_server_prefixes, - target_fqdns=PermittedDomains.APT_REPOSITORIES, - ), - ], + ], + ), + network.AzureFirewallApplicationRuleCollectionArgs( + action=network.AzureFirewallRCActionArgs( + type=network.AzureFirewallRCActionType.ALLOW ), - network.AzureFirewallApplicationRuleCollectionArgs( - action=network.AzureFirewallRCActionArgs( - type=network.AzureFirewallRCActionType.ALLOW + name="clamav-mirror", + priority=FirewallPriorities.SRE_CLAMAV_MIRROR, + rules=[ + network.AzureFirewallApplicationRuleArgs( + description="Allow external ClamAV definition update requests", + name="AllowClamAVDefinitionUpdates", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTP), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, + ), + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ), + ], + source_addresses=props.subnet_clamav_mirror_prefixes, + target_fqdns=PermittedDomains.CLAMAV_UPDATES, ), - name="clamav-mirror", - priority=FirewallPriorities.SRE_CLAMAV_MIRROR, - rules=[ - network.AzureFirewallApplicationRuleArgs( - description="Allow external ClamAV definition update requests", - name="AllowClamAVDefinitionUpdates", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTP), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, - ), - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ), - ], - source_addresses=props.subnet_clamav_mirror_prefixes, - target_fqdns=PermittedDomains.CLAMAV_UPDATES, - ), - ], + ], + ), + network.AzureFirewallApplicationRuleCollectionArgs( + action=network.AzureFirewallRCActionArgs( + type=network.AzureFirewallRCActionType.ALLOW ), - network.AzureFirewallApplicationRuleCollectionArgs( - action=network.AzureFirewallRCActionArgs( - type=network.AzureFirewallRCActionType.ALLOW + name="identity-server", + priority=FirewallPriorities.SRE_IDENTITY_CONTAINERS, + rules=[ + network.AzureFirewallApplicationRuleArgs( + description="Allow Microsoft OAuth login requests", + name="AllowMicrosoftOAuthLogin", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ) + ], + source_addresses=props.subnet_identity_containers_prefixes, + target_fqdns=PermittedDomains.MICROSOFT_IDENTITY, ), - name="identity-server", - priority=FirewallPriorities.SRE_IDENTITY_CONTAINERS, - rules=[ - network.AzureFirewallApplicationRuleArgs( - description="Allow Microsoft OAuth login requests", - name="AllowMicrosoftOAuthLogin", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ) - ], - source_addresses=props.subnet_identity_containers_prefixes, - target_fqdns=PermittedDomains.MICROSOFT_IDENTITY, - ), - ], + ], + ), + network.AzureFirewallApplicationRuleCollectionArgs( + action=network.AzureFirewallRCActionArgs( + type=network.AzureFirewallRCActionType.ALLOW ), - network.AzureFirewallApplicationRuleCollectionArgs( - action=network.AzureFirewallRCActionArgs( - type=network.AzureFirewallRCActionType.ALLOW + name="remote-desktop-gateway", + priority=FirewallPriorities.SRE_GUACAMOLE_CONTAINERS, + rules=[ + network.AzureFirewallApplicationRuleArgs( + description="Allow Microsoft OAuth login requests", + name="AllowMicrosoftOAuthLogin", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ) + ], + source_addresses=props.subnet_guacamole_containers_prefixes, + target_fqdns=PermittedDomains.MICROSOFT_LOGIN, ), - name="remote-desktop-gateway", - priority=FirewallPriorities.SRE_GUACAMOLE_CONTAINERS, - rules=[ - network.AzureFirewallApplicationRuleArgs( - description="Allow Microsoft OAuth login requests", - name="AllowMicrosoftOAuthLogin", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ) - ], - source_addresses=props.subnet_guacamole_containers_prefixes, - target_fqdns=PermittedDomains.MICROSOFT_LOGIN, - ), - ], + ], + ), + network.AzureFirewallApplicationRuleCollectionArgs( + action=network.AzureFirewallRCActionArgs( + type=network.AzureFirewallRCActionType.ALLOW ), - network.AzureFirewallApplicationRuleCollectionArgs( - action=network.AzureFirewallRCActionArgs( - type=network.AzureFirewallRCActionType.ALLOW + name="software-repositories", + priority=FirewallPriorities.SRE_USER_SERVICES_SOFTWARE_REPOSITORIES, + rules=[ + network.AzureFirewallApplicationRuleArgs( + description="Allow external CRAN package requests", + name="AllowCRANPackageDownload", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ) + ], + source_addresses=props.subnet_user_services_software_repositories_prefixes, + target_fqdns=PermittedDomains.SOFTWARE_REPOSITORIES_R, ), - name="software-repositories", - priority=FirewallPriorities.SRE_USER_SERVICES_SOFTWARE_REPOSITORIES, - rules=[ - network.AzureFirewallApplicationRuleArgs( - description="Allow external CRAN package requests", - name="AllowCRANPackageDownload", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ) - ], - source_addresses=props.subnet_user_services_software_repositories_prefixes, - target_fqdns=PermittedDomains.SOFTWARE_REPOSITORIES_R, - ), - network.AzureFirewallApplicationRuleArgs( - description="Allow external PyPI package requests", - name="AllowPyPIPackageDownload", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ) - ], - source_addresses=props.subnet_user_services_software_repositories_prefixes, - target_fqdns=PermittedDomains.SOFTWARE_REPOSITORIES_PYTHON, - ), - ], + network.AzureFirewallApplicationRuleArgs( + description="Allow external PyPI package requests", + name="AllowPyPIPackageDownload", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ) + ], + source_addresses=props.subnet_user_services_software_repositories_prefixes, + target_fqdns=PermittedDomains.SOFTWARE_REPOSITORIES_PYTHON, + ), + ], + ), + network.AzureFirewallApplicationRuleCollectionArgs( + action=network.AzureFirewallRCActionArgs( + type=network.AzureFirewallRCActionType.ALLOW ), - network.AzureFirewallApplicationRuleCollectionArgs( - action=network.AzureFirewallRCActionArgs( - type=network.AzureFirewallRCActionType.ALLOW + name="workspaces", + priority=FirewallPriorities.SRE_WORKSPACES, + rules=[ + network.AzureFirewallApplicationRuleArgs( + description="Allow external Ubuntu keyserver requests", + name="AllowUbuntuKeyserver", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HKP), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, + ), + ], + source_addresses=props.subnet_workspaces_prefixes, + target_fqdns=PermittedDomains.UBUNTU_KEYSERVER, ), - name="workspaces", - priority=FirewallPriorities.SRE_WORKSPACES, - rules=[ - network.AzureFirewallApplicationRuleArgs( - description="Allow external Ubuntu keyserver requests", - name="AllowUbuntuKeyserver", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HKP), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, - ), - ], - source_addresses=props.subnet_workspaces_prefixes, - target_fqdns=PermittedDomains.UBUNTU_KEYSERVER, - ), - network.AzureFirewallApplicationRuleArgs( - description="Allow external Ubuntu Snap Store access", - name="AllowUbuntuSnapcraft", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ), - ], - source_addresses=props.subnet_workspaces_prefixes, - target_fqdns=PermittedDomains.UBUNTU_SNAPCRAFT, - ), - network.AzureFirewallApplicationRuleArgs( - description="Allow external RStudio deb downloads", - name="AllowRStudioDeb", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ), - ], - source_addresses=props.subnet_workspaces_prefixes, - target_fqdns=PermittedDomains.RSTUDIO_DEB, - ), - ], + network.AzureFirewallApplicationRuleArgs( + description="Allow external Ubuntu Snap Store access", + name="AllowUbuntuSnapcraft", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ), + ], + source_addresses=props.subnet_workspaces_prefixes, + target_fqdns=PermittedDomains.UBUNTU_SNAPCRAFT, + ), + network.AzureFirewallApplicationRuleArgs( + description="Allow external RStudio deb downloads", + name="AllowRStudioDeb", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ), + ], + source_addresses=props.subnet_workspaces_prefixes, + target_fqdns=PermittedDomains.RSTUDIO_DEB, + ), + ], + ), + network.AzureFirewallApplicationRuleCollectionArgs( + action=network.AzureFirewallRCActionArgs( + type=network.AzureFirewallRCActionType.DENY ), - network.AzureFirewallApplicationRuleCollectionArgs( + name="workspaces-deny", + priority=FirewallPriorities.SRE_WORKSPACES_DENY, + rules=[ + network.AzureFirewallApplicationRuleArgs( + description="Deny external Ubuntu Snap Store upload and login access", + name="DenyUbuntuSnapcraft", + protocols=[ + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTP), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, + ), + network.AzureFirewallApplicationRuleProtocolArgs( + port=int(Ports.HTTPS), + protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, + ), + ], + source_addresses=props.subnet_workspaces_prefixes, + target_fqdns=ForbiddenDomains.UBUNTU_SNAPCRAFT, + ), + ], + ), + ] + + network_rule_collections: list[ + network.AzureFirewallNetworkRuleCollectionArgs + ] = [] + + if props.allow_workspace_internet: + application_rule_collections = [] + network_rule_collections.append( + network.AzureFirewallNetworkRuleCollectionArgs( action=network.AzureFirewallRCActionArgs( - type=network.AzureFirewallRCActionType.DENY + type=network.AzureFirewallRCActionType.ALLOW ), - name="workspaces-deny", - priority=FirewallPriorities.SRE_WORKSPACES_DENY, + name="workspaces-all-allow", # TODO: Fix other names. + priority=FirewallPriorities.SRE_WORKSPACES, rules=[ - network.AzureFirewallApplicationRuleArgs( - description="Deny external Ubuntu Snap Store upload and login access", - name="DenyUbuntuSnapcraft", - protocols=[ - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTP), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTP, - ), - network.AzureFirewallApplicationRuleProtocolArgs( - port=int(Ports.HTTPS), - protocol_type=network.AzureFirewallApplicationRuleProtocolType.HTTPS, - ), - ], - source_addresses=props.subnet_workspaces_prefixes, - target_fqdns=ForbiddenDomains.UBUNTU_SNAPCRAFT, - ), + network.AzureFirewallNetworkRuleArgs( + description="Enables internet access to workspaces.", + destination_addresses=["*"], + destination_ports=["*"], + name="allow-internet-access", + protocols=[network.AzureFirewallNetworkRuleProtocol.ANY], + source_addresses=[ + "*" + ], # TODO: Check if we can make this more restrictive. + ) ], - ), - ], + ) + ) + + # Deploy firewall + self.firewall = network.AzureFirewall( + f"{self._name}_firewall", + application_rule_collections=application_rule_collections, azure_firewall_name=f"{stack_name}-firewall", ip_configurations=[ network.AzureFirewallIPConfigurationArgs( @@ -325,6 +360,7 @@ def __init__( public_ip_address=network.SubResourceArgs(id=public_ip_management.id), subnet=network.SubResourceArgs(id=props.subnet_firewall_management_id), ), + network_rule_collections=network_rule_collections, resource_group_name=props.resource_group_name, sku=network.AzureFirewallSkuArgs( name=network.AzureFirewallSkuName.AZF_W_V_NET, @@ -360,12 +396,12 @@ def __init__( }, } ], - resource_uri=firewall.id, + resource_uri=self.firewall.id, workspace_id=props.log_analytics_workspace.id, ) # Retrieve the private IP address for the firewall - private_ip_address = firewall.ip_configurations.apply( + private_ip_address = self.firewall.ip_configurations.apply( lambda cfgs: "" if not cfgs else cfgs[0].private_ip_address ) @@ -376,7 +412,7 @@ def __init__( # send via the firewall. # # See https://learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-udr-overview - network.Route( + self.route = network.Route( f"{self._name}_route_via_firewall", address_prefix="0.0.0.0/0", next_hop_ip_address=private_ip_address, @@ -384,5 +420,7 @@ def __init__( resource_group_name=props.resource_group_name, route_name="ViaFirewall", route_table_name=props.route_table_name, - opts=ResourceOptions.merge(child_opts, ResourceOptions(parent=firewall)), + opts=ResourceOptions.merge( + child_opts, ResourceOptions(parent=self.firewall) + ), ) diff --git a/data_safe_haven/resources/dns_server/AdGuardHome.mustache.yaml b/data_safe_haven/resources/dns_server/AdGuardHome.mustache.yaml index eef58d5a0a..9c75067956 100644 --- a/data_safe_haven/resources/dns_server/AdGuardHome.mustache.yaml +++ b/data_safe_haven/resources/dns_server/AdGuardHome.mustache.yaml @@ -11,12 +11,17 @@ dns: querylog: enabled: true filters: +{{#allow_workspace_internet}} +user_rules: [] +{{/allow_workspace_internet}} +{{^allow_workspace_internet}} user_rules: # https://github.com/AdguardTeam/AdGuardHome/wiki/Hosts-Blocklists#adblock-style-syntax - "*.*" {{#filter_allow}} - "@@||{{.}}" {{/filter_allow}} +{{/allow_workspace_internet}} log: verbose: true # Note that because we are only providing a partial config file we need the diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 7d9a0ba873..178d833487 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -155,6 +155,7 @@ def test_constructor_defaults( ) assert sre_config.admin_email_address == "admin@example.com" assert sre_config.admin_ip_addresses == [] + assert not sre_config.allow_workspace_internet assert sre_config.databases == [] assert sre_config.data_provider_ip_addresses == [] assert sre_config.remote_desktop == config_subsection_remote_desktop diff --git a/tests/conftest.py b/tests/conftest.py index 8734d39ba1..494660d74c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -530,6 +530,7 @@ def sre_config_yaml(request): admin_email_address: admin@example.com admin_ip_addresses: - 1.2.3.4/32 + allow_workspace_internet: false data_provider_ip_addresses: [] databases: [] remote_desktop: diff --git a/tests/infrastructure/programs/sre/conftest.py b/tests/infrastructure/programs/sre/conftest.py index efcbe0c921..11e9997c11 100644 --- a/tests/infrastructure/programs/sre/conftest.py +++ b/tests/infrastructure/programs/sre/conftest.py @@ -1,8 +1,27 @@ +import pulumi +import pulumi.runtime from pulumi_azure_native import managedidentity, network, resources from pytest import fixture from data_safe_haven.infrastructure.common import SREIpRanges +# Mock configuration. + + +class DataSafeHavenMocks(pulumi.runtime.Mocks): + def new_resource(self, args: pulumi.runtime.MockResourceArgs): + resources = [args.name + "_id", args.inputs] + return resources + + def call(self, _: pulumi.runtime.MockCallArgs): + return {} + + +pulumi.runtime.set_mocks( + DataSafeHavenMocks(), + preview=False, +) + # # Constants @@ -75,3 +94,67 @@ def subnet_guacamole_containers() -> network.GetSubnetResult: address_prefix=SREIpRanges.guacamole_containers.prefix, id="subnet_guacamole_containers_id", ) + + +@fixture +def subnet_apt_proxy_server() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.apt_proxy_server.prefix, + id="subnet_apt_proxy_server_id", + ) + + +@fixture +def subnet_clamav_mirror() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.clamav_mirror.prefix, + id="subnet_clamav_mirror_id", + ) + + +@fixture +def subnet_firewall() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.firewall.prefix, + id="subnet_firewall_id", + ) + + +@fixture +def subnet_firewall_management() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.firewall_management.prefix, + id="subnet_firewall_management_id", + ) + + +@fixture +def subnet_identity_containers() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.identity_containers.prefix, + id="subnet_identity_containers_id", + ) + + +@fixture +def subnet_user_services_software_repositories() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.user_services_software_repositories.prefix, + id="subnet_user_services_software_repositories_id", + ) + + +@fixture +def subnet_workspaces() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.workspaces.prefix, + id="subnet_workspaces_id", + ) + + +@fixture +def subnet_monitoring() -> network.GetSubnetResult: + return network.GetSubnetResult( + address_prefix=SREIpRanges.monitoring.prefix, + id="subnet_monitoring_id", + ) diff --git a/tests/infrastructure/programs/sre/test_application_gateway.py b/tests/infrastructure/programs/sre/test_application_gateway.py index 7e919a29cf..3583fa6cbc 100644 --- a/tests/infrastructure/programs/sre/test_application_gateway.py +++ b/tests/infrastructure/programs/sre/test_application_gateway.py @@ -67,7 +67,7 @@ def test_props_resource_group_id( self, application_gateway_props: SREApplicationGatewayProps ): application_gateway_props.resource_group_id.apply( - partial(assert_equal, pulumi.UNKNOWN), + partial(assert_equal, "resource_group_id"), run_with_unknowns=True, ) @@ -112,7 +112,7 @@ def test_props_user_assigned_identities( self, application_gateway_props: SREApplicationGatewayProps ): application_gateway_props.user_assigned_identities.apply( - partial(assert_equal, pulumi.UNKNOWN), + partial(assert_equal, {"identity_key_vault_reader_id": {}}), run_with_unknowns=True, ) @@ -282,7 +282,7 @@ def test_application_gateway_frontend_ip_configurations( "name": "appGatewayFrontendIP", "private_ip_allocation_method": "Dynamic", "provisioning_state": None, - "public_ip_address": {"id": None}, + "public_ip_address": {"id": "ag-name_public_ip_id"}, "type": None, } ], @@ -356,8 +356,12 @@ def test_application_gateway_http_listeners( [ { "etag": None, - "frontend_ip_configuration": {"id": None}, - "frontend_port": {"id": None}, + "frontend_ip_configuration": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/frontendIPConfigurations/appGatewayFrontendIP" + }, + "frontend_port": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/frontendPorts/appGatewayFrontendHttp" + }, "host_name": "sre.example.com", "name": "GuacamoleHttpListener", "protocol": "Http", @@ -366,13 +370,19 @@ def test_application_gateway_http_listeners( }, { "etag": None, - "frontend_ip_configuration": {"id": None}, - "frontend_port": {"id": None}, + "frontend_ip_configuration": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/frontendIPConfigurations/appGatewayFrontendIP" + }, + "frontend_port": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/frontendPorts/appGatewayFrontendHttps" + }, "host_name": "sre.example.com", "name": "GuacamoleHttpsListener", "protocol": "Https", "provisioning_state": None, - "ssl_certificate": {"id": None}, + "ssl_certificate": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/sslCertificates/letsencryptcertificate" + }, "type": None, }, ], @@ -387,7 +397,17 @@ def test_application_gateway_identity( application_gateway_component.application_gateway.identity.apply( partial( assert_equal_json, - {"principal_id": None, "tenant_id": None, "type": "UserAssigned"}, + { + "principal_id": None, + "tenant_id": None, + "type": "UserAssigned", + "user_assigned_identities": { + "identity_key_vault_reader_id": { + "client_id": None, + "principal_id": None, + } + }, + }, ), run_with_unknowns=True, ) @@ -489,8 +509,14 @@ def test_application_gateway_redirect_configurations( "include_query_string": True, "name": "GuacamoleHttpToHttpsRedirection", "redirect_type": "Permanent", - "request_routing_rules": [{"id": None}], - "target_listener": {"id": None}, + "request_routing_rules": [ + { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/requestRoutingRules/HttpToHttpsRedirection" + } + ], + "target_listener": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/httpListeners/GuacamoleHttpsListener" + }, "type": None, } ], @@ -508,24 +534,38 @@ def test_application_gateway_request_routing_rules( [ { "etag": None, - "http_listener": {"id": None}, + "http_listener": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/httpListeners/GuacamoleHttpListener" + }, "name": "GuacamoleHttpRouting", "priority": 200, "provisioning_state": None, - "redirect_configuration": {"id": None}, - "rewrite_rule_set": {"id": None}, + "redirect_configuration": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/redirectConfigurations/GuacamoleHttpToHttpsRedirection" + }, + "rewrite_rule_set": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/rewriteRuleSets/ResponseHeaders" + }, "rule_type": "Basic", "type": None, }, { - "backend_address_pool": {"id": None}, - "backend_http_settings": {"id": None}, + "backend_address_pool": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/backendAddressPools/appGatewayBackendGuacamole" + }, + "backend_http_settings": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/backendHttpSettingsCollection/appGatewayBackendHttpSettings" + }, "etag": None, - "http_listener": {"id": None}, + "http_listener": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/httpListeners/GuacamoleHttpsListener" + }, "name": "GuacamoleHttpsRouting", "priority": 100, "provisioning_state": None, - "rewrite_rule_set": {"id": None}, + "rewrite_rule_set": { + "id": "resource_group_id/providers/Microsoft.Network/applicationGateways/stack-example-ag-entrypoint/rewriteRuleSets/ResponseHeaders" + }, "rule_type": "Basic", "type": None, }, diff --git a/tests/infrastructure/programs/sre/test_firewall.py b/tests/infrastructure/programs/sre/test_firewall.py new file mode 100644 index 0000000000..083016ce1b --- /dev/null +++ b/tests/infrastructure/programs/sre/test_firewall.py @@ -0,0 +1,179 @@ +import pulumi +import pulumi.runtime +import pytest +from pulumi_azure_native import network + +from data_safe_haven.functions import replace_separators +from data_safe_haven.infrastructure.programs.sre.firewall import ( + SREFirewallComponent, + SREFirewallProps, +) +from data_safe_haven.infrastructure.programs.sre.monitoring import ( + SREMonitoringComponent, + SREMonitoringProps, +) +from data_safe_haven.types import AzureDnsZoneNames + + +@pytest.fixture +def sre_monitoring_component( + location: str, + resource_group_name: str, + stack_name: str, + subnet_monitoring: network.GetSubnetResult, + tags: dict[str, str], +) -> SREMonitoringComponent: + return SREMonitoringComponent( + "test_sre_monitoring", + stack_name, + SREMonitoringProps( + dns_private_zones={ + dns_zone_name: network.PrivateZone( + replace_separators( + f"test_sre_dns_server_private_zone_{dns_zone_name}", "_" + ), + location="Global", + private_zone_name=f"privatelink.{dns_zone_name}", + resource_group_name=resource_group_name, + tags=tags, + ) + for dns_zone_name in AzureDnsZoneNames.ALL + }, # TODO: Check if this works + location=location, + resource_group_name=resource_group_name, + subnet=subnet_monitoring, + timezone="Europe/London", + ), + tags=tags, + ) + + +@pytest.fixture +def firewall_props_internet_enabled( + location: str, + resource_group_name: str, + stack_name: str, + sre_monitoring_component: SREMonitoringComponent, + subnet_apt_proxy_server: network.GetSubnetResult, + subnet_clamav_mirror: network.GetSubnetResult, + subnet_firewall: network.GetSubnetResult, + subnet_firewall_management: network.GetSubnetResult, + subnet_guacamole_containers: network.GetSubnetResult, + subnet_identity_containers: network.GetSubnetResult, + subnet_user_services_software_repositories: network.GetSubnetResult, + subnet_workspaces: network.GetSubnetResult, +) -> SREFirewallProps: + return SREFirewallProps( + allow_workspace_internet=True, + location=location, + log_analytics_workspace=sre_monitoring_component, + resource_group_name=resource_group_name, + route_table_name=f"{stack_name}-route-table", + subnet_apt_proxy_server=subnet_apt_proxy_server, + subnet_clamav_mirror=subnet_clamav_mirror, + subnet_firewall=subnet_firewall, + subnet_firewall_management=subnet_firewall_management, + subnet_guacamole_containers=subnet_guacamole_containers, + subnet_identity_containers=subnet_identity_containers, + subnet_user_services_software_repositories=subnet_user_services_software_repositories, + subnet_workspaces=subnet_workspaces, + ) + + +@pytest.fixture +def firewall_props_internet_disabled( + location: str, + resource_group_name: str, + stack_name: str, + sre_monitoring_component: SREMonitoringComponent, + subnet_apt_proxy_server: network.GetSubnetResult, + subnet_clamav_mirror: network.GetSubnetResult, + subnet_firewall: network.GetSubnetResult, + subnet_firewall_management: network.GetSubnetResult, + subnet_guacamole_containers: network.GetSubnetResult, + subnet_identity_containers: network.GetSubnetResult, + subnet_user_services_software_repositories: network.GetSubnetResult, + subnet_workspaces: network.GetSubnetResult, +) -> SREFirewallProps: + return SREFirewallProps( + allow_workspace_internet=False, + location=location, + log_analytics_workspace=sre_monitoring_component, + resource_group_name=resource_group_name, + route_table_name=f"{stack_name}-route-table", + subnet_apt_proxy_server=subnet_apt_proxy_server, + subnet_clamav_mirror=subnet_clamav_mirror, + subnet_firewall=subnet_firewall, + subnet_firewall_management=subnet_firewall_management, + subnet_guacamole_containers=subnet_guacamole_containers, + subnet_identity_containers=subnet_identity_containers, + subnet_user_services_software_repositories=subnet_user_services_software_repositories, + subnet_workspaces=subnet_workspaces, + ) + + +class TestSREFirewallComponent: + + @pulumi.runtime.test + def test_component_allow_workspace_internet_enabled( + self, + firewall_props_internet_enabled: SREFirewallProps, + stack_name: str, + tags: dict[str, str], + ): + + firewall_component: SREFirewallComponent = SREFirewallComponent( + name="sre_firewall_with_internet", + stack_name=stack_name, + props=firewall_props_internet_enabled, + tags=tags, + ) + + def assert_on_firewall_rules( + args: list, + ): + application_rule_collections: list[dict] = args[0] + network_rule_collections: list[dict] = args[1] + + # TODO: Be more precise in rule filtering. + allow_internet_collection: list[dict] = [ + rule_collection + for rule_collection in network_rule_collections + if rule_collection["name"] == "workspaces-all-allow" + ] + + assert len(application_rule_collections) == 0 + assert len(allow_internet_collection) == 1 + + pulumi.Output.all( + firewall_component.firewall.application_rule_collections, + firewall_component.firewall.network_rule_collections, + ).apply(assert_on_firewall_rules) + + @pulumi.runtime.test + def test_component_allow_workspace_internet_disabled( + self, + firewall_props_internet_disabled: SREFirewallProps, + stack_name: str, + tags: dict[str, str], + ): + firewall_component: SREFirewallComponent = SREFirewallComponent( + name="sre_firewall_with_internet", + stack_name=stack_name, + props=firewall_props_internet_disabled, + tags=tags, + ) + + def assert_on_firewall_rules( + args: list, + ): + application_rule_collections: list[dict] = args[0] + network_rule_collections: list[dict] = args[1] + + assert len(application_rule_collections) > 0 + assert len(network_rule_collections) == 0 + + pulumi.Output.all( + firewall_component.firewall.application_rule_collections, + firewall_component.firewall.network_rule_collections, + ).apply(assert_on_firewall_rules)