Repository: microsoft/AzureTRE

Issue:
Change firewall bundle to read rules from resource properties
In order for the firewall to be updated by the API / other resources as part of pipelines, it must read the 'configurable' properties (parts of the firewall we want to allow a user/process to update) from the resource properties in cosmos. 


Discussion:

Resolving patch:
diff --git a/api_app/_version.py b/api_app/_version.py
--- a/api_app/_version.py
+++ b/api_app/_version.py
@@ -1 +1 @@
-__version__ = "0.2.23"
+__version__ = "0.2.24"
diff --git a/api_app/models/domain/resource_template.py b/api_app/models/domain/resource_template.py
--- a/api_app/models/domain/resource_template.py
+++ b/api_app/models/domain/resource_template.py
@@ -23,6 +23,7 @@ class Property(AzureTREModel):
     pattern: Optional[str] = Field(None, title="Pattern")
     updateable: Optional[bool] = Field(None, title="Indicates that the field can be updated")
     readOnly: Optional[bool] = Field(None, title="Indicates the field is read-only")
+    items: Optional[dict] = None  # items can contain sub-properties
 
 
 class CustomAction(AzureTREModel):
diff --git a/resource_processor/resources/commands.py b/resource_processor/resources/commands.py
--- a/resource_processor/resources/commands.py
+++ b/resource_processor/resources/commands.py
@@ -1,6 +1,8 @@
 import asyncio
 import json
 import logging
+import base64
+
 from resources.helpers import get_installation_id
 from shared.logging import shell_output_logger
 
@@ -50,6 +52,13 @@ async def build_porter_command(config, logger, msg_body, custom_action=False):
 
             # only append if we have a value, porter will complain anyway about missing parameters
             if parameter_value is not None:
+                if isinstance(parameter_value, dict) or isinstance(parameter_value, list):
+                    # base64 encode complex types to pass in safely
+                    val = json.dumps(parameter_value)
+                    val_bytes = val.encode("ascii")
+                    val_base64_bytes = base64.b64encode(val_bytes)
+                    parameter_value = val_base64_bytes.decode("ascii")
+
                 porter_parameters = porter_parameters + f" --param {parameter_name}=\"{parameter_value}\""
 
     installation_id = get_installation_id(msg_body)


Test patch:
diff --git a/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py b/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py
--- a/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py
+++ b/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py
@@ -89,6 +89,45 @@ def sample_resource_template() -> ResourceTemplate:
                             actions=[]).dict(exclude_none=True)
 
 
+def sample_nested_template() -> ResourceTemplate:
+    return ResourceTemplate(
+        id="123",
+        name="template1",
+        description="description",
+        version="0.1.0",
+        resourceType=ResourceType.Workspace,
+        current=True,
+        required=[],
+        properties={
+            'rules': {
+                'type': 'array',
+                'items': {
+                    'type': 'object',
+                    'required': [],
+                    'properties': {
+                        'protocol': {
+                            'type': 'object',
+                            'required': ['port'],
+                            'items': {
+                                'type': 'object',
+                                'properties': {
+                                    'port': {
+                                        'type': 'string'
+                                    },
+                                    'method': {
+                                        'type': 'string'
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        },
+        customActions=[]
+    ).dict(exclude_none=True)
+
+
 @patch("db.repositories.resources.ResourceRepository._get_enriched_template")
 @patch("db.repositories.resources.ResourceRepository._validate_resource_parameters", return_value=None)
 def test_validate_input_against_template_returns_template_version_if_template_is_valid(_, enriched_template_mock, resource_repo, workspace_input):
@@ -141,6 +180,42 @@ def test_validate_input_against_template_raises_value_error_if_payload_is_invali
         resource_repo.validate_input_against_template("template1", workspace_input, ResourceType.Workspace)
 
 
+@patch("db.repositories.resources.ResourceRepository._get_enriched_template")
+def test_validate_input_against_nested_template_missing_nested_prop(enriched_template_mock, resource_repo):
+    enriched_template_mock.return_value = sample_nested_template()
+    # missing port
+    nested_input = WorkspaceInCreate(templateName="template1")
+    nested_input.properties['rules'] = [
+        {
+            'protocol': {
+                'method': 'post'
+            }
+        }
+    ]
+
+    with pytest.raises(ValidationError):
+        resource_repo.validate_input_against_template("template1", nested_input, ResourceType.Workspace)
+
+
+@patch("db.repositories.resources.ResourceRepository._get_enriched_template")
+def test_validate_input_against_nested_template_valid(enriched_template_mock, resource_repo):
+    enriched_template_mock.return_value = sample_nested_template()
+
+    # has required props, nested
+    nested_input = WorkspaceInCreate(templateName="template1")
+    nested_input.properties['rules'] = [
+        {
+            'protocol': {
+                'method': 'post',
+                'port': '1234'
+            }
+        }
+    ]
+
+    resp_template = resource_repo.validate_input_against_template("template1", nested_input, ResourceType.Workspace)
+    assert resp_template is not None
+
+
 @patch("db.repositories.resources.ResourceTemplateRepository.get_current_template")
 def test_get_enriched_template_returns_the_enriched_template(get_current_mock, resource_repo):
     workspace_template = ResourceTemplate(id="abc", name="template1", description="", version="", resourceType=ResourceType.Workspace, current=True, required=[], properties={}, customActions=[])
diff --git a/e2e_tests/test_shared_services.py b/e2e_tests/test_shared_services.py
--- a/e2e_tests/test_shared_services.py
+++ b/e2e_tests/test_shared_services.py
@@ -1,7 +1,11 @@
 import pytest
 import logging
 
-from helpers import disable_and_delete_resource, post_resource, get_shared_service_id_by_name
+from helpers import (
+    disable_and_delete_resource,
+    post_resource,
+    get_shared_service_id_by_name,
+)
 from resources import strings
 
 
@@ -15,14 +19,74 @@ async def test_patch_firewall(admin_token, verify):
     patch_payload = {
         "properties": {
             "display_name": "TEST",
+            "rule_collections": [
+                {
+                    "name": "e2e-rule-collection-1",
+                    "action": "Allow",
+                    "rules": [
+                        {
+                            "name": "e2e test rule 1",
+                            "description": "desc here",
+                            "protocols": [{"port": "5555", "type": "Http"}],
+                            "target_fqdns": [
+                                "one.two.three.microsoft.com",
+                                "two.three.microsoft.com"
+                            ],
+                            "source_addresses": ["172.196.0.0"]
+                        }
+                    ]
+                },
+                {
+                    "name": "e2e-rule-collection-2",
+                    "action": "Allow",
+                    "rules": [
+                        {
+                            "name": "e2e test rule 1",
+                            "description": "desc here",
+                            "protocols": [{"port": "5556", "type": "Http"}],
+                            "target_fqdns": [
+                                "one.two.microsoft.com",
+                                "two.microsoft.com"
+                            ],
+                            "source_addresses": ["172.196.0.1"]
+                        }
+                    ]
+                },
+                {
+                    "name": "e2e-rule-collection-3",
+                    "action": "Allow",
+                    "priority": 501,
+                    "rules": [
+                        {
+                            "name": "e2e test rule 1",
+                            "description": "desc here",
+                            "protocols": [{"port": "5557", "type": "Http"}],
+                            "target_fqdns": [
+                                "one.two.three.microsoft.com.uk"
+                            ],
+                            "source_addresses": ["172.196.0.2"]
+                        }
+                    ]
+                }
+            ],
         },
         "templateName": template_name,
     }
 
-    shared_service_firewall = await get_shared_service_id_by_name(template_name, verify, admin_token)
+    shared_service_firewall = await get_shared_service_id_by_name(
+        template_name, verify, admin_token
+    )
     shared_service_path = f'/shared-services/{shared_service_firewall["id"]}'
 
-    await post_resource(patch_payload, f'/api{shared_service_path}', 'shared_service', admin_token, None, verify, method="PATCH")
+    await post_resource(
+        patch_payload,
+        f"/api{shared_service_path}",
+        "shared_service",
+        admin_token,
+        None,
+        verify,
+        method="PATCH",
+    )
 
 
 shared_service_templates_to_create = [
@@ -36,20 +100,35 @@ async def test_patch_firewall(admin_token, verify):
 @pytest.mark.parametrize("template_name", shared_service_templates_to_create)
 async def test_create_shared_service(template_name, admin_token, verify) -> None:
     # Check that the shared service hasn't already been created
-    shared_service = await get_shared_service_id_by_name(template_name, verify, admin_token)
+    shared_service = await get_shared_service_id_by_name(
+        template_name, verify, admin_token
+    )
     if shared_service:
         id = shared_service["id"]
-        LOGGER.info(f"Shared service {template_name} already exists (id {id}), deleting it first...")
-        await disable_and_delete_resource(f'/api/shared-services/{id}', 'shared_service', admin_token, None, verify)
+        LOGGER.info(
+            f"Shared service {template_name} already exists (id {id}), deleting it first..."
+        )
+        await disable_and_delete_resource(
+            f"/api/shared-services/{id}", "shared_service", admin_token, None, verify
+        )
 
     post_payload = {
         "templateName": template_name,
         "properties": {
             "display_name": f"Shared service {template_name}",
-            "description": f"{template_name} deployed via e2e tests"
-        }
+            "description": f"{template_name} deployed via e2e tests",
+        },
     }
 
-    shared_service_path, _ = await post_resource(post_payload, '/api/shared-services', 'shared_service', admin_token, None, verify)
+    shared_service_path, _ = await post_resource(
+        post_payload,
+        "/api/shared-services",
+        "shared_service",
+        admin_token,
+        None,
+        verify,
+    )
 
-    await disable_and_delete_resource(f'/api{shared_service_path}', 'shared_service', admin_token, None, verify)
+    await disable_and_delete_resource(
+        f"/api{shared_service_path}", "shared_service", admin_token, None, verify
+    )