diff --git a/CHANGELOG.md b/CHANGELOG.md index 111b4513b3..930e8c1446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * The updated `config_schema.json` will cause a validation error if you have the developer_settings configured with no items uncommented below it. To resolve this comment out developer_settings in your config.yaml (using #) if you do not have any developer settings configured. ([#4715](https://github.com/microsoft/AzureTRE/pull/4715)) ENHANCEMENTS: +* Add automatic certificate renewal capability to certificate shared service * Create CODEOWNERS file with repository maintainers * Change Guacamole VM OS disk defaults to Standard SSD ([#4621](https://github.com/microsoft/AzureTRE/issues/4621)) * Add additional Databricks, Microsoft & Python domains to allowed-dns.json ([#4636](https://github.com/microsoft/AzureTRE/pull/4636)) @@ -31,6 +32,9 @@ BUG FIXES: * Updated config_schema.json to include missing values. ([#4712](https://github.com/microsoft/AzureTRE/issues/4712))([#4714](https://github.com/microsoft/AzureTRE/issues/4714)) * Remove workspace upgrade step from databricks template ([#4726](https://github.com/microsoft/AzureTRE/pull/4726)) +COMPONENTS: +* Certificate shared service `tre-shared-service-certs` to version 0.8.0 + ## 0.25.0 (July 18, 2025) **IMPORTANT**: * If you update core deployment prior to this release an upstream issue with Azure RM terraform provider means that diff --git a/api_app/tests_ma/test_api/test_routes/test_shared_services.py b/api_app/tests_ma/test_api/test_routes/test_shared_services.py index 67e2048c31..7ce3c76320 100644 --- a/api_app/tests_ma/test_api/test_routes/test_shared_services.py +++ b/api_app/tests_ma/test_api/test_routes/test_shared_services.py @@ -33,6 +33,22 @@ def shared_service_input(): } +@pytest.fixture +def certs_service_input(): + return { + "templateName": "tre-shared-service-certs", + "properties": { + "display_name": "Certificate Service", + "description": "SSL certificate service with auto-renewal", + "domain_prefix": "test", + "cert_name": "test-cert", + "enable_auto_renewal": True, + "renewal_threshold_days": 30, + "renewal_schedule_cron": "0 2 * * 0" + } + } + + def sample_shared_service(shared_service_id=SHARED_SERVICE_ID): return SharedService( id=shared_service_id, diff --git a/api_app/tests_ma/test_services/test_cert_auto_renewal.py b/api_app/tests_ma/test_services/test_cert_auto_renewal.py new file mode 100644 index 0000000000..70207dc6ad --- /dev/null +++ b/api_app/tests_ma/test_services/test_cert_auto_renewal.py @@ -0,0 +1,203 @@ +import pytest +import json +from unittest.mock import patch, MagicMock + +from jsonschema import validate, ValidationError +from services.schema_service import enrich_template + + +class TestCertAutoRenewal: + """Test certificate auto-renewal functionality.""" + + @pytest.fixture + def cert_template_schema(self): + """Sample certificate template schema with auto-renewal parameters.""" + return { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/microsoft/AzureTRE/templates/shared_services/certs/template_schema.json", + "type": "object", + "title": "Certificate Service", + "description": "Provides SSL Certs for a specified internal domain", + "required": [ + "domain_prefix", + "cert_name" + ], + "properties": { + "display_name": { + "type": "string", + "title": "Name for the workspace service", + "description": "The name of the workspace service to be displayed to users", + "default": "Certificate Service", + "updateable": True + }, + "domain_prefix": { + "type": "string", + "title": "Domain prefix", + "description": "The FQDN prefix to generate a certificate for" + }, + "cert_name": { + "type": "string", + "title": "Cert name", + "description": "What to call the certificate exported to KeyVault" + }, + "enable_auto_renewal": { + "type": "boolean", + "title": "Enable Auto-renewal", + "description": "Enable automatic renewal of the certificate before expiry", + "default": False, + "updateable": True + }, + "renewal_threshold_days": { + "type": "integer", + "title": "Renewal threshold (days)", + "description": "Number of days before expiry to trigger renewal", + "default": 30, + "minimum": 1, + "maximum": 60, + "updateable": True + }, + "renewal_schedule_cron": { + "type": "string", + "title": "Renewal schedule (cron)", + "description": "Cron expression for checking certificate expiry", + "default": "0 2 * * 0", + "updateable": True + } + } + } + + def test_auto_renewal_schema_validation_success(self, cert_template_schema): + """Test that valid auto-renewal parameters pass schema validation.""" + valid_payload = { + "domain_prefix": "nexus", + "cert_name": "nexus-ssl", + "enable_auto_renewal": True, + "renewal_threshold_days": 30, + "renewal_schedule_cron": "0 2 * * 0" + } + + # Should not raise ValidationError + validate(instance=valid_payload, schema=cert_template_schema) + + def test_auto_renewal_schema_validation_with_defaults(self, cert_template_schema): + """Test that minimal payload with defaults works.""" + minimal_payload = { + "domain_prefix": "test", + "cert_name": "test-cert" + } + + # Should not raise ValidationError + validate(instance=minimal_payload, schema=cert_template_schema) + + def test_auto_renewal_threshold_validation(self, cert_template_schema): + """Test that renewal threshold validation works.""" + # Test invalid threshold - too low + with pytest.raises(ValidationError): + invalid_payload = { + "domain_prefix": "test", + "cert_name": "test-cert", + "renewal_threshold_days": 0 + } + validate(instance=invalid_payload, schema=cert_template_schema) + + # Test invalid threshold - too high + with pytest.raises(ValidationError): + invalid_payload = { + "domain_prefix": "test", + "cert_name": "test-cert", + "renewal_threshold_days": 100 + } + validate(instance=invalid_payload, schema=cert_template_schema) + + # Test valid thresholds + for valid_threshold in [1, 15, 30, 45, 60]: + valid_payload = { + "domain_prefix": "test", + "cert_name": "test-cert", + "renewal_threshold_days": valid_threshold + } + validate(instance=valid_payload, schema=cert_template_schema) + + def test_auto_renewal_updateable_fields(self, cert_template_schema): + """Test that auto-renewal fields are marked as updateable.""" + properties = cert_template_schema["properties"] + + updateable_fields = [ + "enable_auto_renewal", + "renewal_threshold_days", + "renewal_schedule_cron" + ] + + for field in updateable_fields: + assert properties[field].get("updateable", False) is True, \ + f"Field {field} should be updateable" + + def test_auto_renewal_default_values(self, cert_template_schema): + """Test that auto-renewal fields have correct default values.""" + properties = cert_template_schema["properties"] + + expected_defaults = { + "enable_auto_renewal": False, + "renewal_threshold_days": 30, + "renewal_schedule_cron": "0 2 * * 0" + } + + for field, expected_default in expected_defaults.items(): + actual_default = properties[field].get("default") + assert actual_default == expected_default, \ + f"Field {field} should have default value {expected_default}, got {actual_default}" + + def test_missing_required_fields(self, cert_template_schema): + """Test that missing required fields fail validation.""" + # Missing domain_prefix + with pytest.raises(ValidationError): + validate(instance={"cert_name": "test"}, schema=cert_template_schema) + + # Missing cert_name + with pytest.raises(ValidationError): + validate(instance={"domain_prefix": "test"}, schema=cert_template_schema) + + @pytest.mark.parametrize("auto_renewal_enabled,threshold,cron", [ + (True, 7, "0 1 * * *"), # Daily at 1 AM + (True, 14, "0 2 * * 1"), # Weekly on Monday at 2 AM + (True, 45, "0 3 1 * *"), # Monthly on 1st at 3 AM + (False, 30, "0 2 * * 0"), # Disabled with defaults + ]) + def test_auto_renewal_parameter_combinations(self, cert_template_schema, auto_renewal_enabled, threshold, cron): + """Test various combinations of auto-renewal parameters.""" + payload = { + "domain_prefix": "test", + "cert_name": "test-cert", + "enable_auto_renewal": auto_renewal_enabled, + "renewal_threshold_days": threshold, + "renewal_schedule_cron": cron + } + + # Should not raise ValidationError + validate(instance=payload, schema=cert_template_schema) + + def test_type_validation(self, cert_template_schema): + """Test that incorrect types fail validation.""" + # Wrong type for enable_auto_renewal + with pytest.raises(ValidationError): + validate(instance={ + "domain_prefix": "test", + "cert_name": "test-cert", + "enable_auto_renewal": "true" # Should be boolean + }, schema=cert_template_schema) + + # Wrong type for renewal_threshold_days + with pytest.raises(ValidationError): + validate(instance={ + "domain_prefix": "test", + "cert_name": "test-cert", + "renewal_threshold_days": "30" # Should be integer + }, schema=cert_template_schema) + + # Wrong type for renewal_schedule_cron + with pytest.raises(ValidationError): + validate(instance={ + "domain_prefix": "test", + "cert_name": "test-cert", + "renewal_schedule_cron": 123 # Should be string + }, schema=cert_template_schema) \ No newline at end of file diff --git a/docs/tre-templates/shared-services/certs-auto-renewal.md b/docs/tre-templates/shared-services/certs-auto-renewal.md new file mode 100644 index 0000000000..9ce851050f --- /dev/null +++ b/docs/tre-templates/shared-services/certs-auto-renewal.md @@ -0,0 +1,165 @@ +# Certificate Service Auto-Renewal + +The Certificate Service provides automatic certificate renewal capabilities to ensure your TRE certificates remain valid without manual intervention. + +## Overview + +Starting with certificate service version 0.8.0, the service can automatically monitor certificate expiry dates and trigger renewal when certificates are approaching expiration. This feature is particularly useful for: + +- Main TRE web and API certificates +- Nexus service certificates +- Any other certificates managed by the certificate service + +## How It Works + +The auto-renewal feature uses Azure Logic Apps to: + +1. **Monitor**: Periodically check certificate expiry dates in Key Vault +2. **Evaluate**: Compare expiry dates against the configured renewal threshold +3. **Renew**: Automatically trigger certificate renewal via the TRE API when needed +4. **Log**: Record all renewal activities for monitoring and auditing + +## Configuration + +When deploying the certificate service, you can enable auto-renewal with the following parameters: + +### Enable Auto-renewal +- **Type**: Boolean +- **Default**: `false` +- **Description**: Enable automatic renewal of the certificate before expiry +- **Updateable**: Yes + +### Renewal Threshold (days) +- **Type**: Integer +- **Default**: `30` +- **Range**: 1-60 days +- **Description**: Number of days before expiry to trigger renewal +- **Updateable**: Yes + +### Renewal Schedule (cron) +- **Type**: String +- **Default**: `"0 2 * * 0"` (Weekly on Sunday at 2 AM) +- **Description**: Cron expression for checking certificate expiry +- **Updateable**: Yes + +## Deployment Example + +When deploying the certificate service via the TRE UI or API, enable auto-renewal like this: + +```json +{ + "templateName": "tre-shared-service-certs", + "properties": { + "display_name": "Certificate Service with Auto-renewal", + "description": "SSL certificate service with automatic renewal", + "domain_prefix": "nexus", + "cert_name": "nexus-ssl", + "enable_auto_renewal": true, + "renewal_threshold_days": 30, + "renewal_schedule_cron": "0 2 * * 0" + } +} +``` + +## Monitoring + +The auto-renewal system provides several monitoring capabilities: + +### Logic App Logs +- View execution history in the Azure portal +- Monitor success/failure of renewal checks +- Access detailed logs for troubleshooting + +### Certificate Outputs +The service outputs additional information when auto-renewal is enabled: + +- `auto_renewal_enabled`: Whether auto-renewal is active +- `auto_renewal_logic_app_name`: Name of the Logic App handling renewal +- `renewal_threshold_days`: Current renewal threshold setting + +### Alerting +You can set up Azure Monitor alerts on the Logic App to notify administrators of: +- Failed certificate checks +- Failed renewal attempts +- Successful certificate renewals + +## Security Considerations + +The auto-renewal feature uses managed identities and follows security best practices: + +### Permissions +The Logic App is granted minimal required permissions: +- **Key Vault Certificates Officer**: To read certificate expiry dates +- **Contributor**: To trigger TRE API operations (scoped to resource group) + +### Network Access +- Logic App communicates with Key Vault and TRE API over HTTPS +- Uses Azure's internal network where possible +- No external dependencies beyond Let's Encrypt (same as manual renewal) + +## Troubleshooting + +### Common Issues + +1. **Logic App not triggering renewals** + - Check the Logic App execution history in Azure portal + - Verify the cron schedule is correct + - Ensure the Logic App has proper permissions + +2. **Certificate not found errors** + - Verify the certificate name matches exactly + - Check that the certificate exists in Key Vault + - Confirm the Logic App has Key Vault access + +3. **API authentication failures** + - Ensure the Logic App managed identity has appropriate TRE permissions + - Verify the TRE API endpoint is accessible + - Check for API rate limiting or other restrictions + +### Manual Intervention + +If auto-renewal fails, you can always fall back to manual renewal: + +1. Via TRE API: `POST /api/shared-services/{service-id}/invoke-action?action=renew` +2. Via TRE UI: Navigate to the certificate service and trigger the "Renew" action + +## Upgrading Existing Certificates + +To enable auto-renewal on existing certificate services: + +1. Upgrade the certificate service to version 0.8.0 or later +2. Update the service properties to enable auto-renewal: + ```json + { + "enable_auto_renewal": true, + "renewal_threshold_days": 30, + "renewal_schedule_cron": "0 2 * * 0" + } + ``` + +!!! note + Upgrading to enable auto-renewal will deploy a new Logic App but won't affect existing certificates or cause downtime. + +## Best Practices + +1. **Threshold Selection**: Set renewal threshold to at least 7 days to allow time for troubleshooting if renewal fails +2. **Schedule Frequency**: Weekly checks are usually sufficient; daily checks may be needed for high-turnover environments +3. **Monitoring**: Set up alerts for Logic App failures to catch issues early +4. **Testing**: Test auto-renewal in development environments before enabling in production +5. **Documentation**: Keep track of which certificates have auto-renewal enabled + +## Limitations + +- Auto-renewal uses the same Let's Encrypt rate limits as manual renewal +- Requires the certificate service to be deployed and healthy +- Logic App execution depends on Azure Logic Apps service availability +- Cannot renew certificates that are already expired (manual intervention required) + +## Support + +For issues with auto-renewal: + +1. Check the Logic App execution history and logs +2. Review this documentation and troubleshooting section +3. Contact your TRE administrator or Azure support team +4. As a fallback, use manual certificate renewal procedures \ No newline at end of file diff --git a/docs/tre-templates/shared-services/nexus.md b/docs/tre-templates/shared-services/nexus.md index 51c134ebe4..06b1cd0e93 100644 --- a/docs/tre-templates/shared-services/nexus.md +++ b/docs/tre-templates/shared-services/nexus.md @@ -113,8 +113,30 @@ If you still have an existing Nexus installation based on App Service (from the The Nexus service checks Key Vault regularly for the latest certificate matching the name you passed on deploy (`nexus-ssl` by default). +### Manual Renewal + When approaching expiry, you can either provide an updated certificate into the TRE core KeyVault (with the name you specified when installing Nexus) if you brought your own, or if you used the certs shared service to generate one, just call the `renew` custom action on that service. This will generate a new certificate and persist it to the Key Vault, replacing the expired one. +### Auto-renewal + +Starting with certs service version 0.8.0, you can enable automatic certificate renewal when deploying the certificate service. When enabled, the service will automatically check certificate expiry and trigger renewal before the certificate expires. + +To enable auto-renewal for Nexus certificates: + +1. When deploying the certs shared service, set the following properties: + - **Enable Auto-renewal**: `true` + - **Renewal threshold (days)**: Number of days before expiry to trigger renewal (default: 30) + - **Renewal schedule (cron)**: How often to check for expiry (default: weekly on Sunday at 2 AM) + +2. The system will automatically: + - Check certificate expiry on the configured schedule + - Trigger renewal when the certificate is within the threshold period + - Update the certificate in Key Vault + - Log all renewal activities for monitoring + +!!! note + Auto-renewal uses Azure Logic Apps and requires appropriate permissions to access Key Vault and the TRE API. The Logic App is deployed automatically when auto-renewal is enabled. + ## Updating to v3.0.0 The newest version of Nexus is a significant update for the service. As a result, a new installation of Nexus will be necessary. diff --git a/e2e_tests/test_shared_services.py b/e2e_tests/test_shared_services.py index 227985156d..8a9b212b18 100644 --- a/e2e_tests/test_shared_services.py +++ b/e2e_tests/test_shared_services.py @@ -153,6 +153,9 @@ async def test_create_certs_nexus_shared_service(verify) -> None: "description": f"{strings.CERTS_SHARED_SERVICE} deployed via e2e tests", "domain_prefix": cert_domain, "cert_name": cert_name, + "enable_auto_renewal": True, + "renewal_threshold_days": 30, + "renewal_schedule_cron": "0 2 * * 0", }, } diff --git a/templates/shared_services/certs/porter.yaml b/templates/shared_services/certs/porter.yaml index caf27cd535..84ef19c52f 100755 --- a/templates/shared_services/certs/porter.yaml +++ b/templates/shared_services/certs/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-certs -version: 0.7.7 +version: 0.8.0 description: "An Azure TRE shared service to generate certificates for a specified internal domain using Letsencrypt" registry: azuretre dockerfile: Dockerfile.tmpl @@ -57,6 +57,18 @@ parameters: - name: key_store_id type: string default: "" + - name: enable_auto_renewal + type: boolean + default: false + description: "Enable automatic renewal of the certificate before expiry" + - name: renewal_threshold_days + type: integer + default: 30 + description: "Number of days before expiry to trigger renewal" + - name: renewal_schedule_cron + type: string + default: "0 2 * * 0" + description: "Cron expression for checking certificate expiry (default: weekly on Sunday at 2 AM)" mixins: - exec @@ -76,6 +88,9 @@ install: enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } arm_environment: ${ bundle.parameters.arm_environment } + enable_auto_renewal: ${ bundle.parameters.enable_auto_renewal } + renewal_threshold_days: ${ bundle.parameters.renewal_threshold_days } + renewal_schedule_cron: ${ bundle.parameters.renewal_schedule_cron } backendConfig: use_azuread_auth: "true" use_oidc: "true" @@ -90,6 +105,9 @@ install: - name: resource_group_name - name: keyvault_name - name: password_name + - name: auto_renewal_enabled + - name: auto_renewal_logic_app_name + - name: renewal_threshold_days - az: description: "Set Azure Cloud Environment" arguments: @@ -135,11 +153,26 @@ install: name: ${ bundle.outputs.application_gateway_name } upgrade: - - exec: + - terraform: description: "Upgrade shared service" - command: echo - arguments: - - "This shared service does not implement upgrade action" + vars: + tre_id: ${ bundle.parameters.tre_id } + domain_prefix: ${ bundle.parameters.domain_prefix } + cert_name: ${ bundle.parameters.cert_name } + tre_resource_id: ${ bundle.parameters.id } + enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } + key_store_id: ${ bundle.parameters.key_store_id } + arm_environment: ${ bundle.parameters.arm_environment } + enable_auto_renewal: ${ bundle.parameters.enable_auto_renewal } + renewal_threshold_days: ${ bundle.parameters.renewal_threshold_days } + renewal_schedule_cron: ${ bundle.parameters.renewal_schedule_cron } + backendConfig: + use_azuread_auth: "true" + use_oidc: "true" + resource_group_name: ${ bundle.parameters.tfstate_resource_group_name } + storage_account_name: ${ bundle.parameters.tfstate_storage_account_name } + container_name: ${ bundle.parameters.tfstate_container_name } + key: ${ bundle.parameters.tre_id }-shared-service-certs uninstall: - terraform: @@ -152,6 +185,9 @@ uninstall: enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } arm_environment: ${ bundle.parameters.arm_environment } + enable_auto_renewal: ${ bundle.parameters.enable_auto_renewal } + renewal_threshold_days: ${ bundle.parameters.renewal_threshold_days } + renewal_schedule_cron: ${ bundle.parameters.renewal_schedule_cron } backendConfig: use_azuread_auth: "true" use_oidc: "true" @@ -179,6 +215,9 @@ renew: - name: resource_group_name - name: keyvault_name - name: password_name + - name: auto_renewal_enabled + - name: auto_renewal_logic_app_name + - name: renewal_threshold_days - az: description: "Set Azure Cloud Environment" arguments: diff --git a/templates/shared_services/certs/template_schema.json b/templates/shared_services/certs/template_schema.json index 4b3f69a5b8..17580190a9 100644 --- a/templates/shared_services/certs/template_schema.json +++ b/templates/shared_services/certs/template_schema.json @@ -34,6 +34,32 @@ "type": "string", "title": "Cert name", "description": "What to call the certificate that's exported to KeyVault (alphanumeric and '-' only)" + }, + "enable_auto_renewal": { + "$id": "#/properties/enable_auto_renewal", + "type": "boolean", + "title": "Enable Auto-renewal", + "description": "Enable automatic renewal of the certificate before expiry", + "default": false, + "updateable": true + }, + "renewal_threshold_days": { + "$id": "#/properties/renewal_threshold_days", + "type": "integer", + "title": "Renewal threshold (days)", + "description": "Number of days before expiry to trigger renewal", + "default": 30, + "minimum": 1, + "maximum": 60, + "updateable": true + }, + "renewal_schedule_cron": { + "$id": "#/properties/renewal_schedule_cron", + "type": "string", + "title": "Renewal schedule (cron)", + "description": "Cron expression for checking certificate expiry (default: weekly on Sunday at 2 AM)", + "default": "0 2 * * 0", + "updateable": true } }, "pipeline": { diff --git a/templates/shared_services/certs/terraform/auto_renewal.tf b/templates/shared_services/certs/terraform/auto_renewal.tf new file mode 100644 index 0000000000..42278070d8 --- /dev/null +++ b/templates/shared_services/certs/terraform/auto_renewal.tf @@ -0,0 +1,133 @@ +resource "azurerm_logic_app_workflow" "cert_renewal" { + count = var.enable_auto_renewal ? 1 : 0 + name = "logicapp-cert-renewal-${local.service_resource_name_suffix}" + location = local.location + resource_group_name = local.resource_group_name + tags = local.tre_shared_service_tags + + workflow_schema = "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#" + workflow_version = "1.0.0.0" + + parameters = { + "keyvault_name" = { + "defaultValue" = data.azurerm_key_vault.core.name + "type" = "String" + } + "cert_name" = { + "defaultValue" = var.cert_name + "type" = "String" + } + "renewal_threshold_days" = { + "defaultValue" = var.renewal_threshold_days + "type" = "Int" + } + "tre_api_base_url" = { + "defaultValue" = local.tre_api_base_url + "type" = "String" + } + "shared_service_id" = { + "defaultValue" = var.tre_resource_id + "type" = "String" + } + } + + # Basic workflow definition - will be replaced by ARM template deployment + workflow_definition = jsonencode({ + "$schema" = "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#" + contentVersion = "1.0.0.0" + parameters = { + "keyvault_name" = { + "defaultValue" = data.azurerm_key_vault.core.name + "type" = "String" + } + "cert_name" = { + "defaultValue" = var.cert_name + "type" = "String" + } + "renewal_threshold_days" = { + "defaultValue" = var.renewal_threshold_days + "type" = "Int" + } + "tre_api_base_url" = { + "defaultValue" = local.tre_api_base_url + "type" = "String" + } + "shared_service_id" = { + "defaultValue" = var.tre_resource_id + "type" = "String" + } + } + triggers = { + "Scheduled_Certificate_Check" = { + "recurrence" = { + "frequency" = "Week" + "interval" = 1 + "schedule" = { + "hours" = [2] + "minutes" = [0] + "weekDays" = ["Sunday"] + } + } + "type" = "Recurrence" + } + } + actions = { + "Initialize_variable" = { + "runAfter" = {} + "type" = "InitializeVariable" + "inputs" = { + "variables" = [ + { + "name" = "certificateExpiryDate" + "type" = "string" + } + ] + } + } + } + outputs = {} + }) + + identity { + type = "SystemAssigned" + } +} + +resource "azurerm_role_assignment" "cert_renewal_keyvault" { + count = var.enable_auto_renewal ? 1 : 0 + scope = data.azurerm_key_vault.core.id + role_definition_name = "Key Vault Certificates Officer" + principal_id = azurerm_logic_app_workflow.cert_renewal[0].identity[0].principal_id +} + +resource "azurerm_role_assignment" "cert_renewal_api" { + count = var.enable_auto_renewal ? 1 : 0 + scope = data.azurerm_resource_group.rg.id + role_definition_name = "Contributor" + principal_id = azurerm_logic_app_workflow.cert_renewal[0].identity[0].principal_id +} + +# Deploy the complete Logic App workflow using ARM template +resource "azurerm_resource_group_template_deployment" "cert_renewal_workflow" { + count = var.enable_auto_renewal ? 1 : 0 + name = "cert-renewal-workflow-deployment-${local.service_resource_name_suffix}" + resource_group_name = local.resource_group_name + deployment_mode = "Incremental" + + template_content = templatefile("${path.module}/logic_app_workflow.json", { + workflow_name = azurerm_logic_app_workflow.cert_renewal[0].name + location = local.location + keyvault_name = data.azurerm_key_vault.core.name + cert_name = var.cert_name + renewal_threshold_days = var.renewal_threshold_days + tre_api_base_url = local.tre_api_base_url + shared_service_id = var.tre_resource_id + cron_expression = var.renewal_schedule_cron + }) + + depends_on = [ + azurerm_logic_app_workflow.cert_renewal, + azurerm_role_assignment.cert_renewal_keyvault, + azurerm_role_assignment.cert_renewal_api + ] +} \ No newline at end of file diff --git a/templates/shared_services/certs/terraform/data.tf b/templates/shared_services/certs/terraform/data.tf index 5fb1b04059..e6c7ab0608 100644 --- a/templates/shared_services/certs/terraform/data.tf +++ b/templates/shared_services/certs/terraform/data.tf @@ -7,6 +7,11 @@ data "azurerm_key_vault" "key_vault" { resource_group_name = data.azurerm_resource_group.rg.name } +data "azurerm_key_vault" "core" { + name = "kv-${var.tre_id}" + resource_group_name = data.azurerm_resource_group.rg.name +} + data "azurerm_subnet" "app_gw_subnet" { name = "AppGwSubnet" virtual_network_name = "vnet-${var.tre_id}" diff --git a/templates/shared_services/certs/terraform/locals.tf b/templates/shared_services/certs/terraform/locals.tf index 19aa23c554..15af70cc1e 100644 --- a/templates/shared_services/certs/terraform/locals.tf +++ b/templates/shared_services/certs/terraform/locals.tf @@ -27,4 +27,10 @@ locals { cmk_name = "tre-encryption-${var.tre_id}" encryption_identity_name = "id-encryption-${var.tre_id}" password_name = "${var.cert_name}-password" + + # Auto-renewal related locals + service_resource_name_suffix = substr(replace(var.tre_resource_id, "-", ""), 0, 6) + location = data.azurerm_resource_group.rg.location + resource_group_name = data.azurerm_resource_group.rg.name + tre_api_base_url = "https://${var.tre_id}.${data.azurerm_resource_group.rg.location}.cloudapp.azure.com" } diff --git a/templates/shared_services/certs/terraform/logic_app_workflow.json b/templates/shared_services/certs/terraform/logic_app_workflow.json new file mode 100644 index 0000000000..1cadc082b0 --- /dev/null +++ b/templates/shared_services/certs/terraform/logic_app_workflow.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": {}, + "resources": [ + { + "type": "Microsoft.Logic/workflows", + "apiVersion": "2019-05-01", + "name": "${workflow_name}", + "location": "${location}", + "properties": { + "definition": { + "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "keyvault_name": { + "defaultValue": "${keyvault_name}", + "type": "String" + }, + "cert_name": { + "defaultValue": "${cert_name}", + "type": "String" + }, + "renewal_threshold_days": { + "defaultValue": "${renewal_threshold_days}", + "type": "Int" + }, + "tre_api_base_url": { + "defaultValue": "${tre_api_base_url}", + "type": "String" + }, + "shared_service_id": { + "defaultValue": "${shared_service_id}", + "type": "String" + } + }, + "triggers": { + "Scheduled_Certificate_Check": { + "recurrence": { + "frequency": "Week", + "interval": 1, + "schedule": { + "hours": [2], + "minutes": [0], + "weekDays": ["Sunday"] + } + }, + "type": "Recurrence" + } + }, + "actions": { + "Initialize_current_date": { + "runAfter": {}, + "type": "InitializeVariable", + "inputs": { + "variables": [ + { + "name": "currentDate", + "type": "string", + "value": "@{utcnow()}" + } + ] + } + }, + "Get_certificate_from_KeyVault": { + "runAfter": { + "Initialize_current_date": ["Succeeded"] + }, + "type": "Http", + "inputs": { + "method": "GET", + "uri": "https://@{parameters('keyvault_name')}.vault.azure.net/certificates/@{parameters('cert_name')}?api-version=7.3", + "authentication": { + "type": "ManagedServiceIdentity" + } + } + }, + "Parse_certificate_response": { + "runAfter": { + "Get_certificate_from_KeyVault": ["Succeeded"] + }, + "type": "ParseJson", + "inputs": { + "content": "@body('Get_certificate_from_KeyVault')", + "schema": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "exp": { + "type": "integer" + } + } + } + } + } + } + }, + "Calculate_expiry_date": { + "runAfter": { + "Parse_certificate_response": ["Succeeded"] + }, + "type": "InitializeVariable", + "inputs": { + "variables": [ + { + "name": "expiryDate", + "type": "string", + "value": "@{addSeconds('1970-01-01T00:00:00Z', body('Parse_certificate_response')?['attributes']?['exp'])}" + } + ] + } + }, + "Calculate_days_until_expiry": { + "runAfter": { + "Calculate_expiry_date": ["Succeeded"] + }, + "type": "InitializeVariable", + "inputs": { + "variables": [ + { + "name": "daysUntilExpiry", + "type": "integer", + "value": "@div(sub(ticks(variables('expiryDate')), ticks(variables('currentDate'))), 864000000000)" + } + ] + } + }, + "Check_if_renewal_needed": { + "runAfter": { + "Calculate_days_until_expiry": ["Succeeded"] + }, + "type": "If", + "expression": { + "and": [ + { + "lessOrEquals": [ + "@variables('daysUntilExpiry')", + "@parameters('renewal_threshold_days')" + ] + } + ] + }, + "actions": { + "Trigger_certificate_renewal": { + "type": "Http", + "inputs": { + "method": "POST", + "uri": "@{parameters('tre_api_base_url')}/api/shared-services/@{parameters('shared_service_id')}/invoke-action?action=renew", + "authentication": { + "type": "ManagedServiceIdentity" + }, + "headers": { + "Content-Type": "application/json" + } + } + }, + "Log_renewal_triggered": { + "runAfter": { + "Trigger_certificate_renewal": ["Succeeded"] + }, + "type": "Compose", + "inputs": { + "message": "Certificate renewal triggered successfully", + "certificate": "@parameters('cert_name')", + "expiry_date": "@variables('expiryDate')", + "days_until_expiry": "@variables('daysUntilExpiry')", + "renewal_response": "@body('Trigger_certificate_renewal')" + } + } + }, + "else": { + "actions": { + "Log_no_renewal_needed": { + "type": "Compose", + "inputs": { + "message": "Certificate does not need renewal yet", + "certificate": "@parameters('cert_name')", + "expiry_date": "@variables('expiryDate')", + "days_until_expiry": "@variables('daysUntilExpiry')", + "threshold_days": "@parameters('renewal_threshold_days')" + } + } + } + } + } + } + }, + "parameters": {} + } + } + ], + "outputs": {} +} \ No newline at end of file diff --git a/templates/shared_services/certs/terraform/outputs.tf b/templates/shared_services/certs/terraform/outputs.tf index 844163ebb0..1c6f34a822 100644 --- a/templates/shared_services/certs/terraform/outputs.tf +++ b/templates/shared_services/certs/terraform/outputs.tf @@ -21,3 +21,18 @@ output "keyvault_name" { output "password_name" { value = local.password_name } + +output "auto_renewal_enabled" { + description = "Whether auto-renewal is enabled for this certificate" + value = var.enable_auto_renewal +} + +output "auto_renewal_logic_app_name" { + description = "Name of the Logic App used for auto-renewal" + value = var.enable_auto_renewal ? azurerm_logic_app_workflow.cert_renewal[0].name : null +} + +output "renewal_threshold_days" { + description = "Number of days before expiry to trigger renewal" + value = var.renewal_threshold_days +} diff --git a/templates/shared_services/certs/terraform/variables.tf b/templates/shared_services/certs/terraform/variables.tf index f0dfa9d95e..491e3b67bf 100644 --- a/templates/shared_services/certs/terraform/variables.tf +++ b/templates/shared_services/certs/terraform/variables.tf @@ -27,3 +27,25 @@ variable "key_store_id" { variable "arm_environment" { type = string } + +variable "enable_auto_renewal" { + type = bool + default = false + description = "Enable automatic renewal of the certificate before expiry" +} + +variable "renewal_threshold_days" { + type = number + default = 30 + description = "Number of days before expiry to trigger renewal" + validation { + condition = var.renewal_threshold_days >= 1 && var.renewal_threshold_days <= 60 + error_message = "Renewal threshold must be between 1 and 60 days." + } +} + +variable "renewal_schedule_cron" { + type = string + default = "0 2 * * 0" + description = "Cron expression for checking certificate expiry (default: weekly on Sunday at 2 AM)" +}