Join a Virtual Machine to Existing Domain with Key Vault and ARM templates

One of my customers is building a set of ARM templates for their internal users.  They wanted to remove the burden for users to specify the local administrator’s credentials, but wanted to store the credentials securely.

To skip the explanation and just go to the code, see https://github.com/kaevans/vm-domain-join-key-vault.

The problem is that you cannot reference the Key Vault from within the template itself, its value has to be passed as a secure string.  The way around this is to create a nested deployment.  The first template will create the network interface, then call the second template, passing parameter values to it.  This is where we can leverage Key Vault such that the user can provision a VM that references Key Vault without ever knowing the Key Vault even exists or having access to the secrets it contains.

image

There are a few moving parts to make this work.  We need a domain controller with a properly-configured virtual network.

Create an Azure VM with a new Active Directory Forest

If you go to the Azure Quickstart Templates page, the first thing you will see is the list of most popular templates.  The second item is the template to Create an Azure VM with a new AD Forest.

image

Click that item, then click the big “Deploy to Azure” button.

image

You are taken to the Azure portal, where you need to fill in the parameters for the template.

image

Wait until it’s complete, and you will have a properly configured domain controller and Azure virtual network where the DNS settings have been updated to use the DNS from the domain controller.  Inspect the resulting resource group and see the virtual network is named “adVNET”.

image

We author our new template that it will place our new virtual machine into that existing virtual network.

Create the Key Vault

Now that we have the domain controller provisioned, we can create a Key Vault.  This vault doesn’t have to reside in the same resource group, but we do need to enable access to Azure Resource Manager for template deployment.  Do this by settings the advanced access policy.

image

Add the local machine administrator’s username:

image

Add the local machine administrator’s password:

image

Make note of the name of each of these secrets, we’ll use these later.

Create the Virtual Machine Template

The first template we create will have a whole bunch of parameters because it is designed to be reused in different contexts.  This template will create a VM using an existing network interface (implying the NIC has already been configured on a virtual network) and then join it to an existing domain.  This sample is very similar to the sample Join a VM to an existing domain, with the notable change that the sample below uses managed disks, removing the need for storage accounts.

 [code language='javascript' ]
{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters":
  {
    "region":
    {
      "type": "string"
    },
    "vmName":
    {
      "type": "string"
    },
    "nicName":
    {
      "type": "string"
    },
    "vmSize":
    {
      "type": "string"
    },
    "vmAdminUsername":
    {
      "type": "string"
    },
    "vmAdminPassword":
    {
      "type": "securestring"
    },
    "domainToJoin":
    {
      "type": "string"
    },
    "domainUsername":
    {
      "type": "string"
    },
    "domainPassword":
    {
      "type": "securestring"
    },
    "ouPath":
    {
      "type": "string",
      "defaultValue": "",
      "metadata":
      {
        "description": "Specifies an organizational unit (OU) for the domain account. Enter the full distinguished name of the OU in quotation marks. Example: 'OU=testOU; DC=domain; DC=Domain; DC=com"
      }
    },
    "domainJoinOptions":
    {
      "type": "int",
      "defaultValue": 3,
      "metadata":
      {
        "description": "Set of bit flags that define the join options. Default value of 3 is a combination of NETSETUP_JOIN_DOMAIN (0x00000001) & NETSETUP_ACCT_CREATE (0x00000002) i.e. will join the domain and create the account on the domain. For more information see https://msdn.microsoft.com/en-us/library/aa392154(v=vs.85).aspx"
      }
    },
    "windowsOSVersion":
    {
      "type": "string",
      "defaultValue": "2012-R2-Datacenter",
      "allowedValues":
      [
        "2008-R2-SP1",
        "2012-Datacenter",
        "2012-R2-Datacenter",
        "2016-Datacenter"
      ],
      "metadata":
      {
        "description": "The Windows version for the VM. This will pick a fully patched image of this given Windows version. Allowed values: 2008-R2-SP1, 2012-Datacenter, 2012-R2-Datacenter."
      }
    },
    "storageSku":
    {
      "type": "string",
      "allowedValues":
      [
        "Standard_LRS",
        "Premium_LRS"
      ]
    }
  },
  "variables":
  {
    "OSDiskName": "[concat(parameters('vmName'),'osdisk')]",
    "imagePublisher": "MicrosoftWindowsServer",
    "imageOffer": "WindowsServer"
  },
  "resources":
  [
    {
      "apiVersion": "2016-04-30-preview",
      "type": "Microsoft.Compute/virtualMachines",
      "name": "[parameters('vmName')]",
      "location": "[parameters('region')]",
      "tags":
      {
        "displayName": "VirtualMachine"
      },
      "properties":
      {
        "hardwareProfile":
        {
          "vmSize": "[parameters('vmSize')]"
        },
        "osProfile":
        {
          "computerName": "[parameters('vmName')]",
          "adminUsername": "[parameters('vmAdminUsername')]",
          "adminPassword": "[parameters('vmAdminPassword')]",
          "windowsConfiguration":
          {
            "provisionVMAgent": true
          }
        },
        "storageProfile":
        {
          "imageReference":
          {
            "publisher": "[variables('imagePublisher')]",
            "offer": "[variables('imageOffer')]",
            "sku": "[parameters('windowsOSVersion')]",
            "version": "latest"
          },
          "osDisk":
          {
            "name": "[variables('OSDiskName')]",
            "managedDisk":
            {
              "storageAccountType": "[parameters('storageSku')]"
            },
            "caching": "ReadWrite",
            "createOption": "FromImage"
          }
        },
        "networkProfile":
        {
          "networkInterfaces":
          [
            {
              "id": "[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"
            }
          ]
        }
      }
    },
    {
      "apiVersion": "2016-04-30-preview",
      "type": "Microsoft.Compute/virtualMachines/extensions",
      "name": "[concat(parameters('vmName'),'/joindomain')]",
      "location": "[parameters('region')]",
      "dependsOn":
      [
        "[concat('Microsoft.Compute/virtualMachines/', parameters('vmName'))]"
      ],
      "tags":
      {
        "displayName": "JsonADDomainExtension"
      },
      "properties":
      {
        "publisher": "Microsoft.Compute",
        "type": "JsonADDomainExtension",
        "typeHandlerVersion": "1.3",
        "autoUpgradeMinorVersion": true,
        "settings":
        {
          "Name": "[parameters('domainToJoin')]",
          "OUPath": "[parameters('ouPath')]",
          "User": "[concat(parameters('domainToJoin'), '\\', parameters('domainUsername'))]",
          "Restart": "true",
          "Options": "[parameters('domainJoinOptions')]"
        },
        "protectedSettings":
        {
          "Password": "[parameters('domainPassword')]"
        }
      }
    }

  ],
  "outputs": {
  }
}

There are no secrets or proprietary logic in this file, so we can upload it to a storage account or GitHub and make it publicly accessible.  I create a new container in a storage account, changing its access type to Blob.

image

I upload the file to blob storage and verify that I can download it publicly.  Note that we don’t have to use a public template, there are ways of uploading this to a private container with a SAS URL that grants temporary read access.  For our demo, a publicly accessible file is fine.

image

Create the Deployment Template

Typically, I expose many parameters to the user.  However, in this case, the customer explicitly did not want the person deploying this template to have to specify the local administrator credentials.  They also do not want the user who is deploying the template to have to know anything about Key Vault.  Instead of exposing this configuration as a parameter, we will embed the Key Vault reference as a variable.  This makes the template less portable, but still usable within this customer’s context.

 [code language='javascript'  highlight='62,63,64,115,145,153']
{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters":
  {
    "region":
    {
      "type": "string",
      "minLength": 1
    },
    "vmName":
    {
      "type": "string",
      "minLength": 1
    },
    "domainToJoin":
    {
      "type": "string",
      "minLength": 1,
      "defaultValue": "blueskyabove.local",
      "metadata":
      {
        "description": "The domain user alias to use when joining the machine to the specified domain"
      }
    },
    "domainUsername":
    {
      "type": "string",      
      "minLength": 1,
      "metadata":
      {
        "description": "The domain user alias to use when joining the machine to the specified domain"
      }
    },
    "domainPassword":
    {
      "type": "securestring",
      "minLength": 1,
      "metadata":
      {
        "description": "The password of the domain user to use when joining the machine to the specified domain"
      }
    }
  },
  "variables":
  {
    "existingVNETName": "adVNET",
    "existingVNETResourceGroupName": "ADDemo",
    "vnetRef": "[resourceId(subscription().subscriptionId, variables('existingVNETResourceGroupName'),'Microsoft.Network/virtualNetworks',variables('existingVNETName'))]",
    "subnetName": "adSubnet",
    "subnetRef": "[concat(variables('vnetRef'), '/subnets/', variables('subnetName'))]",
    "imagePublisher": "MicrosoftWindowsServer",
    "imageOffer": "WindowsServer",
    "imageSku": "2016-Datacenter",
    "OSDiskName": "vm1OSDisk",
    "storageSku": "Standard_LRS",
    "vmSize": "Standard_D2_v2",
    "nicName": "[concat(parameters('vmName'), 'NetworkInterface')]",
    "pipName": "[concat(parameters('vmName'), 'PublicIP')]",
    "domainJoinOptions": "3",
    "ouPath": "",    
    "keyVaultRef": "/subscriptions/678fbfe2-79a7-4689-839d-0a187e5b6b1a/resourceGroups/ADDemo/providers/Microsoft.KeyVault/vaults/ADDemoKeyVault",
    "usernameSecretName": "localAdminUsername",
    "passwordSecretName": "localAdminPassword"
  },
  "resources":
  [
    {
      "name": "[variables('nicName')]",
      "type": "Microsoft.Network/networkInterfaces",
      "location": "[parameters('region')]",
      "apiVersion": "2016-03-30",
      "dependsOn":
      [        
        "[resourceId('Microsoft.Network/publicIPAddresses', variables('pipName'))]"
      ],
      "tags":
      {
        "displayName": "vmNic"
      },
      "properties":
      {
        "ipConfigurations":
        [
          {
            "name": "ipconfig1",
            "properties":
            {
              "privateIPAllocationMethod": "Dynamic",
              "subnet":
              {
                "id": "[variables('subnetRef')]"
              },
              "publicIPAddress": {
                "id": "[resourceId('Microsoft.Network/publicIPAddresses', variables('pipName'))]"
              }
            }
          }
        ]
      }
    },
    {
      "apiVersion": "2016-02-01",
      "name": "vmTemplate",
      "type": "Microsoft.Resources/deployments",
      "dependsOn":
      [
        "[resourceId('Microsoft.Network/networkInterfaces', variables('nicName'))]"
      ],
      "properties":
      {
        "mode": "Incremental",
        "templateLink":
        {
          "uri": "https://2lgwchcjfghlgadsa.blob.core.windows.net/arm/newVM.json",
          "contentVersion": "1.0.0.0"
        },
        "parameters":
        {
          "region":
          {
            "value": "[parameters('region')]"
          },
          "vmName":
          {
            "value": "[parameters('vmName')]"
          },
          "nicName":
          {
            "value": "[variables('nicName')]"
          },
          "vmSize":
          {
            "value": "[variables('vmSize')]"
          },
          "vmAdminUsername":
          {
            "reference":
            {
              "keyVault":
              {
                "id": "[variables('keyVaultRef')]"
              },
              "secretName": "[variables('usernameSecretName')]"
            }
          },
          "vmAdminPassword":
          {
            "reference":
            {
              "keyVault":
              {
                "id": "[variables('keyVaultRef')]"
              },
              "secretName": "[variables('passwordSecretName')]"
            }
          },
          "domainToJoin":
          {
            "value": "[parameters('domainToJoin')]"
          },
          "domainUsername":
          {
            "value": "[parameters('domainUsername')]"
          },
          "domainPassword":
          {
            "value": "[parameters('domainPassword')]"
          },
          "ouPath":
          {
            "value": "[variables('ouPath')]"
          },
          "windowsOSVersion":
          {
            "value": "[variables('imageSku')]"
          },
          "storageSku":
          {
            "value": "[variables('storageSku')]"
          }
        }
      }
    },
    {
      "name": "[variables('pipName')]",
      "type": "Microsoft.Network/publicIPAddresses",
      "location": "[parameters('region')]",
      "apiVersion": "2016-03-30",
      "dependsOn":
      [
      ],
      "tags":
      {
        "displayName": "vmPIP"
      },
      "properties":
      {
        "publicIPAllocationMethod": "Dynamic"
      }
    }
  ],
  "outputs":
  {

  }
}

Notice the hard-coded reference to the Key Vault resource ID in the variables section.  If this template is used in other contexts, the Key Vault resource ID would first need to be edited.  Also notice the URI in the templateLink property of the nested deployment. Again, there are other ways to manage this, but the customer wants a very specific scenario: upload from the Templates library in the portal.

To deploy the template, we will this template’s text to the Templates library in the Azure portal.

image

Once in the Templates library, a user can now easily deploy the template from the library, specifying a few parameters.  They do provide their domain credentials, but do not need to provide local administrator credentials as those are obtained from Key Vault.

image

Click Deploy, and in a few minutes you’ll have a virtual machine that is joined to the domain we previously created.  If you are getting impatient and want to see what is happening, click on the alerts button in the top right of the portal to view the deployment.  Once you see the deployment, click on the Events link to get a more detailed view of the deployment events.

image

Notice in the log that the VMADMINUSERNAME and VMADMINPASSWORD fields are blank, this is because these were secrets obtained from Key Vault and expressed as SecureString values.

Summary

The sample code is posted to GitHub - https://github.com/kaevans/vm-domain-join-key-vault.

This post shows how you can use Key Vault to provide credentials from within a template without exposing them to the user who is deploying the template.  It’s worth noting that the user can edit the template, removing the Key Vault bindings in order to provide their own parameter values.  This is simply a convenience factor.

From a security standpoint, using the same local administrator credentials for all machines is an anti-pattern: Compromising one machine means you now have access to all machines.  This is how the SQL Slammer attack started, when the default SQL Server and Desktop Engine credentials were “sa” with a blank password. However, centrally managing these credentials through Key Vault helps prevent against users who provide weak or frequently re-used passwords, and the use of Key Vault enables frequent rolling of passwords used to create virtual machines. This particular customer’s environment is mitigated through group policy in Active Directory (the template joins the VM to the domain, and the GPO subsequently removes local administrator accounts).  Remember sound security practices when deploying virtual machines.

Resources

Azure Quickstart Templates page Create an Azure VM with a new AD Forest Join a VM to an existing domain View this sample on GitHub