diff --git a/templates/workspace_services/azuresql-nwsde/.dockerignore b/templates/workspace_services/azuresql-nwsde/.dockerignore new file mode 100644 index 000000000..01f931413 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/.dockerignore @@ -0,0 +1,7 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file +# Put files here that you don't want copied into your bundle's invocation image +.gitignore +Dockerfile.tmpl + +# Local .terraform directories +**/.terraform/* diff --git a/templates/workspace_services/azuresql-nwsde/.gitignore b/templates/workspace_services/azuresql-nwsde/.gitignore new file mode 100644 index 000000000..e08a3e22b --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/.gitignore @@ -0,0 +1 @@ +.cnab/ diff --git a/templates/workspace_services/azuresql-nwsde/Dockerfile.tmpl b/templates/workspace_services/azuresql-nwsde/Dockerfile.tmpl new file mode 100644 index 000000000..4d1822946 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/Dockerfile.tmpl @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile-upstream:1.4.0 +FROM --platform=linux/amd64 debian:bookworm-slim + +# PORTER_INIT + +RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache + +# git +# +RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt apt-get update \ + && apt-get install -y git --no-install-recommends + +# sqlcmd +# +RUN apt-get update \ + && apt-get install -y curl gnupg \ + && curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - \ + && echo 'deb https://packages.microsoft.com/debian/11/prod bullseye main'> /etc/apt/sources.list.d/prod.list \ + && apt-get update \ + && apt-get -y install sqlcmd --no-install-recommends \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# nslookup +# +RUN apt-get update && \ +apt-get install -y dnsutils && \ +rm -rf /var/lib/apt/lists/* + + +WORKDIR ${BUNDLE_DIR} + +# PORTER_MIXINS + +# Use the BUNDLE_DIR build argument to copy files into the bundle +COPY --link . ${BUNDLE_DIR}/ diff --git a/templates/workspace_services/azuresql-nwsde/create-azuresql-identity-readme.md b/templates/workspace_services/azuresql-nwsde/create-azuresql-identity-readme.md new file mode 100644 index 000000000..bc095a723 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/create-azuresql-identity-readme.md @@ -0,0 +1,36 @@ +# Entra manual configuration for `azuresql-nwsde` + +## Background + +In order for the Azure SQL instance to communicate with Entra +and validate Entra users/groups being added, it requires +a managed identity with MS Graph permissions of +`Directory.Read.All`. + +When the resource processor deploys `azuresql-nwsde` +component it can create the identity, however it does does not +have the permissions to grant another identity MS Graph +admin permissions. + +Therefore a managed identity for the Azure SQL instance +must be created manually in advance of using this template and +passed as an `RP_BUNDLE_VALUES` element. Only one identity +is required per TRE - the identity is re-used across Azure SQL +instances. + +## Create an identity for NWSDE Azure SQL + +1. Ensure your TRE's config.yaml file is created and populated. + +2. Run the `create-azuresql-identity.sh` script, with a user that has Directory granting permissions such as Global Administrator. + +3. Add the resulting identity resource ID to a `azuresql_identity` attribute of the `RP_BUNDLE_VALUES` variable in `config.yaml` or +GitHub secrets, depending on your deployment method. + +The `RP_BUNDLE_VALUES` variable is a JSON object, and the `azuresql_identity` property within it identifies the image gallery that contains the images specified by `source_image_name`: + +```bash +RP_BUNDLE_VALUES='{"azuresql_identity":"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-azuresql-"}' +``` + +4. Once added you will need either re-run a full deployment, or re-run `make deploy-core`. diff --git a/templates/workspace_services/azuresql-nwsde/create-azuresql-identity.sh b/templates/workspace_services/azuresql-nwsde/create-azuresql-identity.sh new file mode 100644 index 000000000..17c318e2a --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/create-azuresql-identity.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# requires a privileged entra role to run, e.g. Global Administrator + +BOLD="\e[1m" +NORMAL="\e[0m" + +echo -e "${BOLD}Creating Azure SQL identity for use in NWSDE-Common-Services deployment${NORMAL}" +echo -e "${BOLD}-----------------------------------------------------------------------${NORMAL}\n" + +CONFIG_YAML=../../../config.yaml + +echo -e "${BOLD}Parsing values from ${CONFIG_YAML}...${NORMAL}\n" + +if [[ ! -f "$CONFIG_YAML" ]]; then + echo -e "config.yaml file not found" + exit 1 +fi + +LOCATION=$(yq '.location' "$CONFIG_YAML") +TRE_ID=$(yq '.tre_id' "$CONFIG_YAML") +MGMT_RESOURCE_GROUP=$(yq '.management.mgmt_resource_group_name' "$CONFIG_YAML") + +if [[ -z "$LOCATION" ]]; then + echo "Value not found for LOCATION in config.yaml" + exit 1 +fi + +if [[ -z "$TRE_ID" ]]; then + echo "Value not found for TRE_ID in config.yaml" + exit 1 +fi + +if [[ -z "$MGMT_RESOURCE_GROUP" ]]; then + echo "Value not found for MGMT_RESOURCE_GROUP in config.yaml" + exit 1 +fi + +IDENTITY_NAME="id-azuresql-$TRE_ID" + +echo -e "Using values:\n" +echo -e " > TRE_ID = ${TRE_ID}" +echo -e " > LOCATION = ${LOCATION}" +echo -e " > MGMT_RESOURCE_GROUP = ${MGMT_RESOURCE_GROUP}" +echo -e "\nCreating identity:\n" +echo -e " > AZURESQL_IDENTITY = ${IDENTITY_NAME}\n" + +echo -e "${BOLD}Checking for resource group $MGMT_RESOURCE_GROUP (and creating if doesn't exist)...${NORMAL}\n" + +az group create --name "$MGMT_RESOURCE_GROUP" \ + --location "$LOCATION" \ + --output table + +echo -e "\n${BOLD}Creating identity $IDENTITY_NAME...${NORMAL}\n" + +az identity create --name "$IDENTITY_NAME" \ + --resource-group "$MGMT_RESOURCE_GROUP" \ + --location "$LOCATION" \ + --tags "manually-created=true" \ + --output table + +echo -e "\n${BOLD}Waiting 30s for Entra service principal to be created...${NORMAL}\n" + +sleep 30 + +echo -e "\n${BOLD}Granting directory read permissions to identity $IDENTITY_NAME...${NORMAL}\n" + +MSGRAPH_APP_ID="00000003-0000-0000-c000-000000000000" +MSGRAPH_APP_PERMISSION="Directory.Read.All" +MSGRAPH_SP_ID=$(az ad sp show --id "$MSGRAPH_APP_ID" --query id --output tsv) +MSGRAPH_APP_PERMISSION_ID=$(az ad sp show --id "$MSGRAPH_APP_ID" --query "appRoles[?value=='$MSGRAPH_APP_PERMISSION'].id" --output tsv) +IDENTITY_SP_ID=$(az identity show --name "$IDENTITY_NAME" --resource-group "$MGMT_RESOURCE_GROUP" --query principalId --output tsv) + +az rest --method POST \ + --uri "https://graph.microsoft.com/v1.0/servicePrincipals/$IDENTITY_SP_ID/appRoleAssignments" \ + --body @- << EOF +{ + "principalId": "$IDENTITY_SP_ID", + "resourceId": "$MSGRAPH_SP_ID", + "appRoleId": "$MSGRAPH_APP_PERMISSION_ID" +} +EOF + +echo -e "\n${BOLD}Now set the azuresql_identity attribute in RP_BUNDLE_VALUES (in deploy.env or GitHub Secrets) to the resource ID below${NORMAL}" +echo -e "${BOLD}----------------------------------------------------------------------------------------------------------------------${NORMAL}\n" + +RESOURCE_ID=$(az identity show --name "$IDENTITY_NAME" \ + --resource-group "$MGMT_RESOURCE_GROUP" \ + --query id \ + --output tsv) + +echo -e "{\"azuresql_identity\":\"${RESOURCE_ID}\"}\n" diff --git a/templates/workspace_services/azuresql-nwsde/parameters.json b/templates/workspace_services/azuresql-nwsde/parameters.json new file mode 100644 index 000000000..d69489f18 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/parameters.json @@ -0,0 +1,68 @@ +{ + "schemaType": "ParameterSet", + "schemaVersion": "1.0.1", + "namespace": "", + "name": "tre-workspace-service-azuresql-nwsde", + "parameters": [ + { + "name": "tre_id", + "source": { + "env": "TRE_ID" + } + }, + { + "name": "id", + "source": { + "env": "ID" + } + }, + { + "name": "tfstate_container_name", + "source": { + "env": "TERRAFORM_STATE_CONTAINER_NAME" + } + }, + { + "name": "tfstate_resource_group_name", + "source": { + "env": "MGMT_RESOURCE_GROUP_NAME" + } + }, + { + "name": "tfstate_storage_account_name", + "source": { + "env": "MGMT_STORAGE_ACCOUNT_NAME" + } + }, + { + "name": "sql_sku", + "source": { + "env": "SQL_SKU" + } + }, + { + "name": "storage_gb", + "source": { + "env": "STORAGE_GB" + } + }, + { + "name": "workspace_id", + "source": { + "env": "WORKSPACE_ID" + } + }, + { + "name": "arm_environment", + "source": { + "env": "ARM_ENVIRONMENT" + } + }, + { + "name": "azuresql_identity", + "source": { + "env": "AZURESQL_IDENTITY" + } + } + ] +} diff --git a/templates/workspace_services/azuresql-nwsde/porter.yaml b/templates/workspace_services/azuresql-nwsde/porter.yaml new file mode 100644 index 000000000..58e19cf45 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/porter.yaml @@ -0,0 +1,153 @@ +--- +schemaVersion: 1.0.0 +name: tre-workspace-service-azuresql-nwsde +version: 1.0.0 +description: "An Azure SQL workspace service [nw]" +registry: azuretre +dockerfile: Dockerfile.tmpl + +credentials: + # Credentials for interacting with the AAD Auth tenant + - name: auth_client_id + env: AUTH_CLIENT_ID + - name: auth_client_secret + env: AUTH_CLIENT_SECRET + - name: auth_tenant_id + env: AUTH_TENANT_ID + # Credentials for interacting with Azure + - name: azure_tenant_id + env: ARM_TENANT_ID + - name: azure_subscription_id + env: ARM_SUBSCRIPTION_ID + - name: azure_client_id + env: ARM_CLIENT_ID + - name: azure_client_secret + env: ARM_CLIENT_SECRET +parameters: + - name: workspace_id + type: string + - name: tre_id + type: string + + # the following are added automatically by the resource processor + - name: id + type: string + description: "Resource ID" + env: id + - name: tfstate_resource_group_name + type: string + description: "Resource group containing the Terraform state storage account" + - name: tfstate_storage_account_name + type: string + description: "The name of the Terraform state storage account" + - name: tfstate_container_name + env: tfstate_container_name + type: string + default: "tfstate" + description: "The name of the Terraform state storage container" + - name: arm_use_msi + env: ARM_USE_MSI + type: boolean + default: false + - name: arm_environment + env: ARM_ENVIRONMENT + type: string + default: "public" + - name: sql_sku + type: string + default: "S2 | 50 DTUs" + - name: storage_gb + type: integer + default: 5 + - name: azuresql_identity + type: string + default: "" + +mixins: + - exec + - terraform: + clientVersion: 1.9.8 + +outputs: + - name: azuresql_fqdn + type: string + applyTo: + - install + - upgrade + - name: workspace_address_spaces + type: string + applyTo: + - install + - upgrade + +install: + - terraform: + description: "Deploy Azure SQL workspace service" + vars: + auth_client_id: ${ bundle.credentials.auth_client_id } + auth_client_secret: ${ bundle.credentials.auth_client_secret } + auth_tenant_id: ${ bundle.credentials.auth_tenant_id } + workspace_id: ${ bundle.parameters.workspace_id } + tre_id: ${ bundle.parameters.tre_id } + tre_resource_id: ${ bundle.parameters.id } + sql_sku: ${ bundle.parameters.sql_sku } + storage_gb: ${ bundle.parameters.storage_gb } + arm_environment: ${ bundle.parameters.arm_environment } + azuresql_identity: ${ bundle.parameters.azuresql_identity } + 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: tre-workspace-service-azuresql-${ bundle.parameters.id } + outputs: + - name: azuresql_fqdn + - name: workspace_address_spaces + +upgrade: + - terraform: + description: "Upgrade Azure SQL workspace service" + vars: + auth_client_id: ${ bundle.credentials.auth_client_id } + auth_client_secret: ${ bundle.credentials.auth_client_secret } + auth_tenant_id: ${ bundle.credentials.auth_tenant_id } + workspace_id: ${ bundle.parameters.workspace_id } + tre_id: ${ bundle.parameters.tre_id } + tre_resource_id: ${ bundle.parameters.id } + sql_sku: ${ bundle.parameters.sql_sku } + storage_gb: ${ bundle.parameters.storage_gb } + arm_environment: ${ bundle.parameters.arm_environment } + azuresql_identity: ${ bundle.parameters.azuresql_identity } + 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: tre-workspace-service-azuresql-${ bundle.parameters.id } + outputs: + - name: azuresql_fqdn + - name: workspace_address_spaces + +uninstall: + - terraform: + description: "Tear down Azure SQL workspace service" + vars: + auth_client_id: ${ bundle.credentials.auth_client_id } + auth_client_secret: ${ bundle.credentials.auth_client_secret } + auth_tenant_id: ${ bundle.credentials.auth_tenant_id } + workspace_id: ${ bundle.parameters.workspace_id } + tre_id: ${ bundle.parameters.tre_id } + tre_resource_id: ${ bundle.parameters.id } + sql_sku: ${ bundle.parameters.sql_sku } + storage_gb: ${ bundle.parameters.storage_gb } + arm_environment: ${ bundle.parameters.arm_environment } + azuresql_identity: ${ bundle.parameters.azuresql_identity } + 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: tre-workspace-service-azuresql-${ bundle.parameters.id } diff --git a/templates/workspace_services/azuresql-nwsde/template_schema.json b/templates/workspace_services/azuresql-nwsde/template_schema.json new file mode 100644 index 000000000..7a9bd94a9 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/template_schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://github.com/microsoft/AzureTRE/templates/workspace_services/azuresql-nwsde/template_schema.json", + "type": "object", + "title": "Azure SQL", + "description": "Provides Azure SQL within the workspace [nw]", + "required": [], + "properties": { + "sql_sku": { + "$id": "#/properties/sql_sku", + "type": "string", + "title": "Azure SQL SKU", + "description": "Azure SQL SKU", + "updateable": true, + "enum": [ + "S1 | 20 DTUs", + "S2 | 50 DTUs", + "S3 | 100 DTUs", + "S4 | 200 DTUs", + "S6 | 400 DTUs" + ], + "default": "S2 | 50 DTUs" + }, + "storage_gb": { + "$id": "#/properties/storage_gb", + "type": "number", + "title": "Max storage allowed for a database (GB)", + "description": "Max storage allowed for a database (GB)", + "default": 5 + } + } +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/.terraform.lock.hcl b/templates/workspace_services/azuresql-nwsde/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..a0d736034 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/.terraform.lock.hcl @@ -0,0 +1,79 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azuread" { + version = "3.1.0" + hashes = [ + "h1:UmSL7MD8ULg/WlRgwisD5lHsjcg9l8AO7AeO0XN96dU=", + "zh:01b796cf12e93cc811cb15c8465605e75de170802060f9e2fe114835968960dd", + "zh:12005fbffb84467ff1d4ce9317370834d1279743bc201d3db95f36315cdf8157", + "zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7", + "zh:1daf7d4ade44e69593488c1f6571b4fbdaf01ec41538207de1f12609b3830907", + "zh:386965c0529ed083b94968c25441385378d8643a5748591b221e6d6d3cea4dbc", + "zh:46ede0628c300c6d584135daa93733400b9ce968d8aebb3f925d904b3fcfa781", + "zh:7af453bf5217e1818ca5c2126edb8fe573c85f17a0557415a3bc7ae92a8652f5", + "zh:b6014600409715ca37aa85ddb066698f592b7d104f09c12a68d45c5b00404272", + "zh:bca84d10cd1e805e6d31a888eb6737a96aee14e1b5b919dee73d2a5a8ff85beb", + "zh:bd7d6e6c2a086bafdeeb33d5d4f919a8789ef3acf1a0baf2b8ea43996b96c213", + "zh:e5b7840b1b9d90c3f6be9a59400b7d0580376415a79aa740eba7f97bf35c25ef", + "zh:e94e114b205de36d60bc17a3758f9c4bfc6b01e63be81ae1d9699f9bf9650362", + ] +} + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.14.0" + constraints = "4.14.0" + hashes = [ + "h1:FYZ9qh8i3X2gDmUTe1jJ/VzdSyjGjVmhBzv2R8D6CBo=", + "zh:05aaea16fc5f27b14d9fbad81654edf0638949ed3585576b2219c76a2bee095a", + "zh:065ce6ed16ba3fa7efcf77888ea582aead54e6a28f184c6701b73d71edd64bb0", + "zh:3c0cd17c249d18aa2e0120acb5f0c14810725158b379a67fec1331110e7c50df", + "zh:5a3ba3ffb2f1ce519fe3bf84a7296aa5862c437c70c62f0b0a5293bea9f2d01c", + "zh:7a8e9d72fa2714f4d567270b1761d4b4e788de7c15dada7db0cf0e29933185a2", + "zh:a11e190073f31c1238c15af29b9162e0f4564f6b0cd0310a3fa94102738450dc", + "zh:a5c004114410cc6dcb8fed584c9f3b84283b58025b0073a7e88d2bdb27840dfa", + "zh:a674a41db118e244eda7591e455d2ec338626664e0856e4125e909eb038f78db", + "zh:b5139010e4cbb2cb1a27c775610593c1c8063d3a7c82b00a65006509c434df2f", + "zh:cbb031223ccd8b099ac4d19b92641142f330b90f2fc6452843e445bae28f832c", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7e7db1b94082a4ac3d4af3dabe7bbd335e1679305bf8e29d011f0ee440724ca", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.2.3" + constraints = "3.2.3" + hashes = [ + "h1:+AnORRgFbRO6qqcfaQyeX80W0eX3VmjadjnUFUJTiXo=", + "zh:22d062e5278d872fe7aed834f5577ba0a5afe34a3bdac2b81f828d8d3e6706d2", + "zh:23dead00493ad863729495dc212fd6c29b8293e707b055ce5ba21ee453ce552d", + "zh:28299accf21763ca1ca144d8f660688d7c2ad0b105b7202554ca60b02a3856d3", + "zh:55c9e8a9ac25a7652df8c51a8a9a422bd67d784061b1de2dc9fe6c3cb4e77f2f", + "zh:756586535d11698a216291c06b9ed8a5cc6a4ec43eee1ee09ecd5c6a9e297ac1", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:9d5eea62fdb587eeb96a8c4d782459f4e6b73baeece4d04b4a40e44faaee9301", + "zh:a6355f596a3fb8fc85c2fb054ab14e722991533f87f928e7169a486462c74670", + "zh:b5a65a789cff4ada58a5baffc76cb9767dc26ec6b45c00d2ec8b1b027f6db4ed", + "zh:db5ab669cf11d0e9f81dc380a6fdfcac437aea3d69109c7aef1a5426639d2d65", + "zh:de655d251c470197bcbb5ac45d289595295acb8f829f6c781d4a75c8c8b7c7dd", + "zh:f5c68199f2e6076bce92a12230434782bf768103a427e9bb9abee99b116af7b5", + ] +} + +provider "registry.terraform.io/hashicorp/template" { + version = "2.2.0" + constraints = "2.2.0" + hashes = [ + "h1:94qn780bi1qjrbC3uQtjJh3Wkfwd5+tTtJHOb7KTg9w=", + "zh:01702196f0a0492ec07917db7aaa595843d8f171dc195f4c988d2ffca2a06386", + "zh:09aae3da826ba3d7df69efeb25d146a1de0d03e951d35019a0f80e4f58c89b53", + "zh:09ba83c0625b6fe0a954da6fbd0c355ac0b7f07f86c91a2a97849140fea49603", + "zh:0e3a6c8e16f17f19010accd0844187d524580d9fdb0731f675ffcf4afba03d16", + "zh:45f2c594b6f2f34ea663704cc72048b212fe7d16fb4cfd959365fa997228a776", + "zh:77ea3e5a0446784d77114b5e851c970a3dde1e08fa6de38210b8385d7605d451", + "zh:8a154388f3708e3df5a69122a23bdfaf760a523788a5081976b3d5616f7d30ae", + "zh:992843002f2db5a11e626b3fc23dc0c87ad3729b3b3cff08e32ffb3df97edbde", + "zh:ad906f4cebd3ec5e43d5cd6dc8f4c5c9cc3b33d2243c89c5fc18f97f7277b51d", + "zh:c979425ddb256511137ecd093e23283234da0154b7fa8b21c2687182d9aea8b2", + ] +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/azuresql-auditing.tf b/templates/workspace_services/azuresql-nwsde/terraform/azuresql-auditing.tf new file mode 100644 index 000000000..9c1b552a3 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/azuresql-auditing.tf @@ -0,0 +1,152 @@ +# +# data +# + +data "azurerm_log_analytics_workspace" "la" { + name = local.workspace_log_analytics_name + resource_group_name = local.workspace_resource_group_name +} + + +# +# resources +# + +// storage to store audits +// +resource "azurerm_storage_account" "stgaudit" { + name = local.storage_account_name + resource_group_name = data.azurerm_resource_group.ws.name + location = data.azurerm_resource_group.ws.location + account_tier = "Standard" + account_replication_type = "ZRS" + allow_nested_items_to_be_public = false + cross_tenant_replication_enabled = false + shared_access_key_enabled = false + local_user_enabled = false + infrastructure_encryption_enabled = true + tags = local.workspace_service_tags + + lifecycle { ignore_changes = [tags] } +} + +// outbound sql server firewall rule +// +resource "azurerm_mssql_outbound_firewall_rule" "azuresqlfwrule" { + name = azurerm_storage_account.stgaudit.primary_blob_host + server_id = azurerm_mssql_server.azuresql.id +} + +// RBAC role for storage account +// +resource "azurerm_role_assignment" "azuresqlstoragerole" { + scope = azurerm_storage_account.stgaudit.id + role_definition_name = "Storage Blob Data Contributor" + principal_id = data.azurerm_user_assigned_identity.sql_identity.principal_id +} + +// server level auditing +// +resource "azurerm_mssql_server_extended_auditing_policy" "azuresqlaudit" { + server_id = azurerm_mssql_server.azuresql.id + storage_endpoint = azurerm_storage_account.stgaudit.primary_blob_endpoint + retention_in_days = 0 + log_monitoring_enabled = true + + depends_on = [ + azurerm_role_assignment.azuresqlstoragerole, + azurerm_mssql_outbound_firewall_rule.azuresqlfwrule + ] +} + +// server level diagnostic setting (for log analytics) +// +resource "azurerm_monitor_diagnostic_setting" "azuresqldiagnosticsetting" { + name = local.azuresql_server_diagnostic_setting_name + target_resource_id = "${azurerm_mssql_server.azuresql.id}/databases/master" + log_analytics_workspace_id = data.azurerm_log_analytics_workspace.la.id + + enabled_log { + category = "SQLSecurityAuditEvents" + } + + lifecycle { + ignore_changes = [metric] + } + + depends_on = [ + azurerm_mssql_server.azuresql, + azurerm_mssql_database.azuresqldatabase, // https://github.com/hashicorp/terraform-provider-azurerm/issues/22226#issuecomment-2464486997 + azurerm_mssql_server_extended_auditing_policy.azuresqlaudit + ] +} + +// server level auditing of microsoft support +// +resource "azurerm_mssql_server_microsoft_support_auditing_policy" "azuresqlaudit2" { + server_id = azurerm_mssql_server.azuresql.id + blob_storage_endpoint = azurerm_storage_account.stgaudit.primary_blob_endpoint + log_monitoring_enabled = true + + depends_on = [ + azurerm_role_assignment.azuresqlstoragerole, + azurerm_mssql_outbound_firewall_rule.azuresqlfwrule + ] +} + +// server level (microsoft support) diagnostic setting (for log analytics) +// +resource "azurerm_monitor_diagnostic_setting" "azuresqldiagnosticsetting2" { + name = local.azuresql_server_diagnostic_setting_name_2 + target_resource_id = "${azurerm_mssql_server.azuresql.id}/databases/master" + log_analytics_workspace_id = data.azurerm_log_analytics_workspace.la.id + + enabled_log { + category = "DevOpsOperationsAudit" + } + + lifecycle { + ignore_changes = [metric] + } + + depends_on = [ + azurerm_mssql_server.azuresql, + azurerm_mssql_database.azuresqldatabase, // https://github.com/hashicorp/terraform-provider-azurerm/issues/22226#issuecomment-2464486997 + azurerm_mssql_server_microsoft_support_auditing_policy.azuresqlaudit2 + ] +} + +// database level auditing +// +resource "azurerm_mssql_database_extended_auditing_policy" "azuresqldbaudit" { + database_id = azurerm_mssql_database.azuresqldatabase.id + storage_endpoint = azurerm_storage_account.stgaudit.primary_blob_endpoint + retention_in_days = 0 + log_monitoring_enabled = true + + depends_on = [ + azurerm_role_assignment.azuresqlstoragerole, + azurerm_mssql_outbound_firewall_rule.azuresqlfwrule + ] +} + +// database level diagnostic setting (for log analytics) +// +resource "azurerm_monitor_diagnostic_setting" "sql_audit3" { + name = local.azuresql_database_diagnostic_setting_name + target_resource_id = azurerm_mssql_database.azuresqldatabase.id + log_analytics_workspace_id = data.azurerm_log_analytics_workspace.la.id + + enabled_log { + category = "SQLSecurityAuditEvents" + } + + lifecycle { + ignore_changes = [metric] + } + + depends_on = [ + azurerm_mssql_database.azuresqldatabase, + azurerm_mssql_database_extended_auditing_policy.azuresqldbaudit + ] +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/azuresql-entra.tf b/templates/workspace_services/azuresql-nwsde/terraform/azuresql-entra.tf new file mode 100644 index 000000000..1724d9d9e --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/azuresql-entra.tf @@ -0,0 +1,62 @@ +# +# data +# + +data "azurerm_client_config" "current" { +} + +data "azuread_group" "sql_admins" { + display_name = local.entra_group_sql_admins +} + +data "azuread_group" "sql_users" { + display_name = local.entra_group_sql_users +} + +data "template_file" "grant_access" { + template = file("${path.module}/scripts/azuresql-add-user.sh") + + vars = { + server_ip = azurerm_private_endpoint.azuresql_private_endpoint.private_service_connection[0].private_ip_address # use ip address to work around dns propagation issues + server_fqdn = azurerm_mssql_server.azuresql.fully_qualified_domain_name + server_name = azurerm_mssql_server.azuresql.name + sp_client_id = data.azurerm_client_config.current.client_id + database_name = azurerm_mssql_database.azuresqldatabase.name + add_database_user_script = "${path.module}/scripts/add-database-user.sql" + add_database_permissions_script = "${path.module}/scripts/add-database-permissions.sql" + user_to_add = data.azuread_group.sql_users.display_name + } +} + +# +# resources +# + +resource "azuread_group_member" "sql_admin_required_member" { + count = contains(data.azuread_group.sql_admins.members, data.azurerm_client_config.current.object_id) ? 0 : 1 + + group_object_id = data.azuread_group.sql_admins.object_id + member_object_id = data.azurerm_client_config.current.object_id +} + +resource "null_resource" "grant_user_access" { + triggers = { + database_id = azurerm_mssql_database.azuresqldatabase.id + database_name = azurerm_mssql_database.azuresqldatabase.name + group_id = data.azuread_group.sql_users.object_id + group_name = data.azuread_group.sql_users.display_name + sql_admin = data.azurerm_client_config.current.object_id + } + + provisioner "local-exec" { + command = data.template_file.grant_access.rendered + interpreter = ["/bin/bash", "-c"] + } + + depends_on = [ + azurerm_private_endpoint.azuresql_private_endpoint, + azuread_group_member.sql_admin_required_member, + azurerm_mssql_server_extended_auditing_policy.azuresqlaudit, + azurerm_mssql_database_extended_auditing_policy.azuresqldbaudit + ] +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/azuresql.tf b/templates/workspace_services/azuresql-nwsde/terraform/azuresql.tf new file mode 100644 index 000000000..9ef145af2 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/azuresql.tf @@ -0,0 +1,95 @@ +# +# data +# + +data "azurerm_resource_group" "ws" { + name = local.workspace_resource_group_name +} + +data "azurerm_virtual_network" "ws" { + name = local.workspace_vnet_name + resource_group_name = data.azurerm_resource_group.ws.name +} + +data "azurerm_subnet" "services" { + name = "ServicesSubnet" + virtual_network_name = data.azurerm_virtual_network.ws.name + resource_group_name = data.azurerm_resource_group.ws.name +} + +data "azurerm_private_dns_zone" "azuresql" { + name = module.terraform_azurerm_environment_configuration.private_links["privatelink.database.windows.net"] + resource_group_name = local.core_resource_group_name +} + +data "azurerm_user_assigned_identity" "sql_identity" { + name = local.azuresql_identity_parsed["resource_name"] + resource_group_name = local.azuresql_identity_parsed["resource_group_name"] +} + + +# +# resources +# + +resource "azurerm_mssql_server" "azuresql" { + name = local.azuresql_server_name + resource_group_name = data.azurerm_resource_group.ws.name + location = data.azurerm_resource_group.ws.location + version = "12.0" + minimum_tls_version = "1.2" + public_network_access_enabled = false + outbound_network_restriction_enabled = true + tags = local.workspace_service_tags + + azuread_administrator { + azuread_authentication_only = true + login_username = data.azuread_group.sql_admins.display_name + object_id = data.azuread_group.sql_admins.object_id + tenant_id = data.azurerm_client_config.current.tenant_id + } + + identity { + type = "UserAssigned" + + identity_ids = [ data.azurerm_user_assigned_identity.sql_identity.id ] + } + + primary_user_assigned_identity_id = data.azurerm_user_assigned_identity.sql_identity.id + + lifecycle { ignore_changes = [tags] } +} + +resource "azurerm_mssql_database" "azuresqldatabase" { + name = local.azuresql_database_name + server_id = azurerm_mssql_server.azuresql.id + collation = local.azuresql_collation + license_type = "LicenseIncluded" + max_size_gb = var.storage_gb + sku_name = local.azuresql_sku[var.sql_sku].value + tags = local.workspace_service_tags + + lifecycle { ignore_changes = [tags] } +} + +resource "azurerm_private_endpoint" "azuresql_private_endpoint" { + name = local.azuresql_private_endpoint_name + location = data.azurerm_resource_group.ws.location + resource_group_name = data.azurerm_resource_group.ws.name + subnet_id = data.azurerm_subnet.services.id + tags = local.workspace_service_tags + + private_service_connection { + private_connection_resource_id = azurerm_mssql_server.azuresql.id + name = local.azuresql_private_service_connection_name + subresource_names = ["sqlServer"] + is_manual_connection = false + } + + private_dns_zone_group { + name = module.terraform_azurerm_environment_configuration.private_links["privatelink.database.windows.net"] + private_dns_zone_ids = [data.azurerm_private_dns_zone.azuresql.id] + } + + lifecycle { ignore_changes = [tags] } +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/locals.tf b/templates/workspace_services/azuresql-nwsde/terraform/locals.tf new file mode 100644 index 000000000..af99c63ce --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/locals.tf @@ -0,0 +1,45 @@ +locals { + + core_resource_group_name = "rg-${var.tre_id}" + + workspace_short_id = substr(var.workspace_id, -4, -1) + workspace_resource_name_suffix = "${var.tre_id}-ws-${local.workspace_short_id}" + workspace_resource_group_name = "rg-${local.workspace_resource_name_suffix}" + workspace_vnet_name = "vnet-${local.workspace_resource_name_suffix}" + workspace_keyvault_name = lower("kv-${substr(local.workspace_resource_name_suffix, -20, -1)}") + workspace_log_analytics_name = "log-${local.workspace_resource_name_suffix}" + + service_short_id = substr(var.tre_resource_id, -4, -1) + service_resource_name_suffix = "${local.workspace_resource_name_suffix}-svc-${local.service_short_id}" + + azuresql_server_name = "sql-${local.service_resource_name_suffix}" + azuresql_database_name = "sqldb-${local.service_resource_name_suffix}" + azuresql_collation = "SQL_Latin1_General_CP1_CI_AS" + azuresql_private_endpoint_name = "pe-${azurerm_mssql_server.azuresql.name}" + azuresql_private_service_connection_name = "psc-${azurerm_mssql_server.azuresql.name}" + azuresql_identity_auditing = "id-${local.azuresql_server_name}-auditing" + azuresql_server_diagnostic_setting_name = "ds-${local.azuresql_server_name}" + azuresql_server_diagnostic_setting_name_2 = "ds-${local.azuresql_server_name}-2" + azuresql_database_diagnostic_setting_name = "ds-${local.azuresql_database_name}" + + storage_account_name = lower(replace("stg${substr(local.service_resource_name_suffix, -16, -1)}", "-", "")) + + entra_group_sql_admins = "sg-TRE-${local.workspace_resource_name_suffix}-owners" + entra_group_sql_users = "sg-TRE-${local.workspace_resource_name_suffix}-researchers" + + azuresql_identity_parsed = provider::azurerm::parse_resource_id(var.azuresql_identity) + + azuresql_sku = { + "S1 | 20 DTUs" = { value = "S1" }, + "S2 | 50 DTUs" = { value = "S2" }, + "S3 | 100 DTUs" = { value = "S3" }, + "S4 | 200 DTUs" = { value = "S4" }, + "S6 | 400 DTUs" = { value = "S6" }, + } + + workspace_service_tags = { + tre_id = var.tre_id + tre_workspace_id = var.workspace_id + tre_workspace_service_id = var.tre_resource_id + } +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/outputs.tf b/templates/workspace_services/azuresql-nwsde/terraform/outputs.tf new file mode 100644 index 000000000..279b650db --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/outputs.tf @@ -0,0 +1,7 @@ +output "azuresql_fqdn" { + value = azurerm_mssql_server.azuresql.fully_qualified_domain_name +} + +output "workspace_address_spaces" { + value = data.azurerm_virtual_network.ws.address_space +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/providers.tf b/templates/workspace_services/azuresql-nwsde/terraform/providers.tf new file mode 100644 index 000000000..d15eeb589 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/providers.tf @@ -0,0 +1,52 @@ +# Azure Provider source and version being used +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "4.14.0" + } + azuread = { + source = "hashicorp/azuread" + version = "3.1.0" + } + null = { + source = "hashicorp/null" + version = "3.2.3" + } + template = { + source = "hashicorp/template" + version = "2.2.0" + } + } + + backend "azurerm" {} +} + +provider "azurerm" { + features { + key_vault { + # Don't purge on destroy (this would fail due to purge protection being enabled on keyvault) + purge_soft_delete_on_destroy = false + purge_soft_deleted_secrets_on_destroy = false + purge_soft_deleted_certificates_on_destroy = false + purge_soft_deleted_keys_on_destroy = false + # When recreating an environment, recover any previously soft deleted secrets - set to true by default + recover_soft_deleted_key_vaults = true + recover_soft_deleted_secrets = true + recover_soft_deleted_certificates = true + recover_soft_deleted_keys = true + } + } + storage_use_azuread = true +} + +module "terraform_azurerm_environment_configuration" { + source = "git::https://github.com/microsoft/terraform-azurerm-environment-configuration.git?ref=0.5.0" + arm_environment = var.arm_environment +} + +provider "azuread" { + client_id = var.auth_client_id + client_secret = var.auth_client_secret + tenant_id = var.auth_tenant_id +} diff --git a/templates/workspace_services/azuresql-nwsde/terraform/scripts/add-database-permissions.sql b/templates/workspace_services/azuresql-nwsde/terraform/scripts/add-database-permissions.sql new file mode 100644 index 000000000..e6be7192d --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/scripts/add-database-permissions.sql @@ -0,0 +1,32 @@ +-- user DB script + +declare @username nvarchar(500) = '$(entra_username)' + +declare @sql1 nvarchar(500) = 'alter role db_datareader add member [' + @username + '];' +declare @sql2 nvarchar(500) = 'alter role db_datawriter add member [' + @username + '];' +declare @sql3 nvarchar(500) = 'alter role db_ddladmin add member [' + @username + '];' +declare @sql4 nvarchar(500) = 'grant execute on database::[' + db_name() + '] to [' + @username + '];' + +execute sp_executesql @sql = @sql1 +execute sp_executesql @sql = @sql2 +execute sp_executesql @sql = @sql3 +execute sp_executesql @sql = @sql4 + + +select + dp.name as [user], + rp.name as [role] +from sys.database_principals dp +left join sys.database_role_members drm on dp.principal_id = drm.member_principal_id +left join sys.database_principals rp on drm.role_principal_id = rp.principal_id +where dp.name = @username; + +select + pr.state_desc as [permission state], + pr.permission_name as [permission], + pr.class_desc as [securable type], + dp.name as [user] +from sys.database_principals as dp +join sys.database_permissions as pr on pr.grantee_principal_id = dp.principal_id +where pr.permission_name = 'EXECUTE' +and dp.name = @username; diff --git a/templates/workspace_services/azuresql-nwsde/terraform/scripts/add-database-user.sql b/templates/workspace_services/azuresql-nwsde/terraform/scripts/add-database-user.sql new file mode 100644 index 000000000..6eef085ca --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/scripts/add-database-user.sql @@ -0,0 +1,27 @@ +declare @username varchar(100) = '$(entra_username)' + +if not exists +( + select * + from sys.database_principals + where type in ('E', 'X') + and name = @username +) +begin + print 'User ' + @username + ' not found, adding...' + + declare @create_user nvarchar(500) = 'create user [' + @username + '] from external provider' + execute sp_executesql @sql = @create_user +end +else +begin + print 'User ' + @username + ' already exists...' +end + +select + name, + type_desc, + create_date +from sys.database_principals +where type in ('E', 'X') +and name = @username diff --git a/templates/workspace_services/azuresql-nwsde/terraform/scripts/azuresql-add-user.sh b/templates/workspace_services/azuresql-nwsde/terraform/scripts/azuresql-add-user.sh new file mode 100644 index 000000000..c78304338 --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/scripts/azuresql-add-user.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# shellcheck disable=SC2154 + +set -e # exit on error + +# +# variables populated by terraform template +# + +SERVER_FQDN="${server_fqdn}" +SERVER_IP="${server_ip}" +DATABASE_NAME="${database_name}" +ADD_DATABASE_USER_SCRIPT="${add_database_user_script}" +ADD_DATABASE_PERMISSIONS_SCRIPT="${add_database_permissions_script}" +USER_TO_ADD="${user_to_add}" + +echo -e "\nAzure SQL add user starting...\n" + +# +# test private DNS resolution +# + +MAX_ATTEMPTS=30 +ATTEMPT=0 +STABLE_COUNT=0 +STABLE_REQUIRED=5 +RESOLVED_IP="" + +while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + + RESOLVED_IP=$(nslookup "$SERVER_FQDN" | tail -n +4 | grep "Address:" | awk '{print $2}' | head -n 1) + + echo -e "Attempt $((ATTEMPT+1)): $SERVER_FQDN resolves to $RESOLVED_IP. Actual IP is $SERVER_IP\n" + + if [[ "$RESOLVED_IP" == "$SERVER_IP" ]]; then + STABLE_COUNT=$((STABLE_COUNT + 1)) + else + STABLE_COUNT=0 + fi + + if [ $STABLE_COUNT -eq $STABLE_REQUIRED ]; then + echo -e "DNS has stablised with $STABLE_REQUIRED correct sequential resolutions\n" + break + fi + + ATTEMPT=$((ATTEMPT + 1)) + sleep 10 + +done + +if [[ "$RESOLVED_IP" != "$SERVER_IP" ]]; then + echo -e "\n$SERVER_FQDN IP was not resolved to private IP after $MAX_ATTEMPTS, quitting\n" + exit 1 +fi + +# +# add users +# + +echo -e "\n > Adding master database user...\n" + +sqlcmd --server "$SERVER_FQDN" \ + --database-name master \ + --use-aad \ + --input-file "$ADD_DATABASE_USER_SCRIPT" \ + --variables entra_username="$USER_TO_ADD" \ + --exit-on-error + +echo -e "\n > Adding $DATABASE_NAME database user...\n" + +sqlcmd --server "$SERVER_FQDN" \ + --database-name "$DATABASE_NAME" \ + --use-aad \ + --input-file "$ADD_DATABASE_USER_SCRIPT" \ + --variables entra_username="$USER_TO_ADD" \ + --exit-on-error + +echo -e "\n > Adding $DATABASE_NAME database permissions...\n" + +sqlcmd --server "$SERVER_FQDN" \ + --database-name "$DATABASE_NAME" \ + --use-aad \ + --input-file "$ADD_DATABASE_PERMISSIONS_SCRIPT" \ + --variables entra_username="$USER_TO_ADD" \ + --exit-on-error + +# +# end +# + +echo -e "\nAzure SQL add user completed\n" diff --git a/templates/workspace_services/azuresql-nwsde/terraform/variables.tf b/templates/workspace_services/azuresql-nwsde/terraform/variables.tf new file mode 100644 index 000000000..c076bbc8d --- /dev/null +++ b/templates/workspace_services/azuresql-nwsde/terraform/variables.tf @@ -0,0 +1,48 @@ +variable "workspace_id" { + type = string +} + +variable "tre_id" { + type = string +} + +variable "tre_resource_id" { + type = string +} + +variable "sql_sku" { + type = string +} + +variable "storage_gb" { + type = number + + validation { + condition = var.storage_gb > 1 && var.storage_gb < 1024 + error_message = "The storage value is out of range." + } +} + +variable "arm_environment" { + type = string +} + +variable "auth_tenant_id" { + type = string + description = "Used to authenticate into the AAD Tenant to create the AAD App" +} + +variable "auth_client_id" { + type = string + description = "Used to authenticate into the AAD Tenant to create the AAD App" +} + +variable "auth_client_secret" { + type = string + description = "Used to authenticate into the AAD Tenant to create the AAD App" +} + +variable "azuresql_identity" { + type = string + description = "User Managed Identity for the Azure SQL instance, please see create-azuresql-identity-readme.md on how to create" +}