From 11382fac7a29bb0bdf91536ca39c9908f41c9882 Mon Sep 17 00:00:00 2001 From: Ian Philpot Date: Tue, 6 Feb 2018 23:40:00 -0500 Subject: [PATCH] Az mod keyvault key (#33607) * key module init * Create/Delete pass sanity * Integration tests passed * Added check_mode * Updated to support tags * updated key tests * fixed sanity * added things that went missing during rebase * fixed sanity * fixed doc * fix copyright --- .../cloud/azure/azure_rm_keyvaultkey.py | 278 ++++++++++++++++++ .../targets/azure_rm_keyvaultkey/aliases | 3 + .../azure_rm_keyvaultkey/meta/main.yml | 2 + .../azure_rm_keyvaultkey/tasks/main.yml | 69 +++++ 4 files changed, 352 insertions(+) create mode 100644 lib/ansible/modules/cloud/azure/azure_rm_keyvaultkey.py create mode 100644 test/integration/targets/azure_rm_keyvaultkey/aliases create mode 100644 test/integration/targets/azure_rm_keyvaultkey/meta/main.yml create mode 100644 test/integration/targets/azure_rm_keyvaultkey/tasks/main.yml diff --git a/lib/ansible/modules/cloud/azure/azure_rm_keyvaultkey.py b/lib/ansible/modules/cloud/azure/azure_rm_keyvaultkey.py new file mode 100644 index 00000000000..29dd53f7cd0 --- /dev/null +++ b/lib/ansible/modules/cloud/azure/azure_rm_keyvaultkey.py @@ -0,0 +1,278 @@ +#!/usr/bin/python +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: azure_rm_keyvaultkey +version_added: 2.5 +short_description: Use Azure KeyVault keys. +description: + - Create or delete a key within a given keyvault. By using Key Vault, you can encrypt + keys and secrets (such as authentication keys, storage account keys, data encryption keys, .PFX files, and passwords). +options: + keyvault_uri: + description: + - URI of the keyvault endpoint. + required: true + key_name: + description: + - Name of the keyvault key. + required: true + byok_file: + description: + - BYOK file. + pem_file: + description: + - PEM file. + pem_password: + description: + - PEM password. + state: + description: + - Assert the state of the key. Use 'present' to create a key and + 'absent' to delete a key. + required: false + default: present + choices: + - absent + - present + +extends_documentation_fragment: + - azure + - azure_tags + +author: + - "Ian Philpot (@tripdubroot)" + +''' + +EXAMPLES = ''' + - name: Create a key + azure_rm_keyvaultkey: + key_name: MyKey + keyvault_uri: https://contoso.vault.azure.net/ + + - name: Delete a key + azure_rm_keyvaultkey: + key_name: MyKey + keyvault_uri: https://contoso.vault.azure.net/ + state: absent +''' + +RETURN = ''' +state: + description: Current state of the key. + returned: success + type: complex + contains: + key_id: + description: key resource path. + type: str + example: https://contoso.vault.azure.net/keys/hello/e924f053839f4431b35bc54393f98423 +''' + +from ansible.module_utils.azure_rm_common import AzureRMModuleBase + +try: + import re + import codecs + from azure.keyvault import KeyVaultClient, KeyVaultId + from azure.keyvault.models import KeyAttributes, JsonWebKey + from azure.common.credentials import ServicePrincipalCredentials + from azure.keyvault.models.key_vault_error import KeyVaultErrorException + from OpenSSL import crypto +except ImportError: + # This is handled in azure_rm_common + pass + + +class AzureRMKeyVaultKey(AzureRMModuleBase): + ''' Module that creates or deletes keys in Azure KeyVault ''' + + def __init__(self): + + self.module_arg_spec = dict( + key_name=dict(type='str', required=True), + keyvault_uri=dict(type='str', required=True), + pem_file=dict(type='str'), + pem_password=dict(type='str'), + byok_file=dict(type='str'), + state=dict(type='str', default='present', choices=['present', 'absent']) + ) + + self.results = dict( + changed=False, + state=dict() + ) + + self.key_name = None + self.keyvault_uri = None + self.pem_file = None + self.pem_password = None + self.state = None + self.client = None + self.tags = None + + required_if = [ + ('pem_password', 'present', ['pem_file']) + ] + + super(AzureRMKeyVaultKey, self).__init__(self.module_arg_spec, + supports_check_mode=True, + required_if=required_if, + supports_tags=True) + + def exec_module(self, **kwargs): + + for key in list(self.module_arg_spec.keys()) + ['tags']: + setattr(self, key, kwargs[key]) + + # Create KeyVaultClient + self.client = KeyVaultClient(self.azure_credentials) + + results = dict() + changed = False + + try: + results['key_id'] = self.get_key(self.key_name) + + # Key exists and will be deleted + if self.state == 'absent': + changed = True + + except KeyVaultErrorException: + # Key doesn't exist + if self.state == 'present': + changed = True + + self.results['changed'] = changed + self.results['state'] = results + + if not self.check_mode: + + # Create key + if self.state == 'present' and changed: + results['key_id'] = self.create_key(self.key_name, self.tags) + self.results['state'] = results + self.results['state']['status'] = 'Created' + # Delete key + elif self.state == 'absent' and changed: + results['key_id'] = self.delete_key(self.key_name) + self.results['state'] = results + self.results['state']['status'] = 'Deleted' + else: + if self.state == 'present' and changed: + self.results['state']['status'] = 'Created' + elif self.state == 'absent' and changed: + self.results['state']['status'] = 'Deleted' + + return self.results + + def get_key(self, name, version=''): + ''' Gets an existing key ''' + key_bundle = self.client.get_key(self.keyvault_uri, name, version) + if key_bundle: + key_id = KeyVaultId.parse_key_id(key_bundle.key.kid) + return key_id.id + + def create_key(self, name, tags, kty='RSA'): + ''' Creates a key ''' + key_bundle = self.client.create_key(self.keyvault_uri, name, kty, tags=tags) + key_id = KeyVaultId.parse_key_id(key_bundle.key.kid) + return key_id.id + + def delete_key(self, name): + ''' Deletes a key ''' + deleted_key = self.client.delete_key(self.keyvault_uri, name) + key_id = KeyVaultId.parse_key_id(deleted_key.key.kid) + return key_id.id + + def import_key(self, key_name, destination=None, key_ops=None, disabled=False, expires=None, + not_before=None, tags=None, pem_file=None, pem_password=None, byok_file=None): + """ Import a private key. Supports importing base64 encoded private keys from PEM files. + Supports importing BYOK keys into HSM for premium KeyVaults. """ + + def _to_bytes(hex_string): + # zero pads and decodes a hex string + if len(hex_string) % 2: + hex_string = '{0}'.format(hex_string) + return codecs.decode(hex_string, 'hex_codec') + + def _set_rsa_parameters(dest, src): + # map OpenSSL parameter names to JsonWebKey property names + conversion_dict = { + 'modulus': 'n', + 'publicExponent': 'e', + 'privateExponent': 'd', + 'prime1': 'p', + 'prime2': 'q', + 'exponent1': 'dp', + 'exponent2': 'dq', + 'coefficient': 'qi' + } + # regex: looks for matches that fit the following patterns: + # integerPattern: 65537 (0x10001) + # hexPattern: + # 00:a0:91:4d:00:23:4a:c6:83:b2:1b:4c:15:d5:be: + # d8:87:bd:c9:59:c2:e5:7a:f5:4a:e7:34:e8:f0:07: + # The desired match should always be the first component of the match + regex = re.compile(r'([^:\s]*(:[^\:)]+\))|([^:\s]*(:\s*[0-9A-Fa-f]{2})+))') + # regex2: extracts the hex string from a format like: 65537 (0x10001) + regex2 = re.compile(r'(?<=\(0x{1})([0-9A-Fa-f]*)(?=\))') + + key_params = crypto.dump_privatekey(crypto.FILETYPE_TEXT, src).decode('utf-8') + for match in regex.findall(key_params): + comps = match[0].split(':', 1) + name = conversion_dict.get(comps[0], None) + if name: + value = comps[1].replace(' ', '').replace('\n', '').replace(':', '') + try: + value = _to_bytes(value) + except Exception: # pylint:disable=broad-except + # if decoding fails it is because of an integer pattern. Extract the hex + # string and retry + value = _to_bytes(regex2.findall(value)[0]) + setattr(dest, name, value) + + key_attrs = KeyAttributes(not disabled, not_before, expires) + key_obj = JsonWebKey(key_ops=key_ops) + if pem_file: + key_obj.kty = 'RSA' + with open(pem_file, 'r') as f: + pem_data = f.read() + # load private key and prompt for password if encrypted + try: + pem_password = str(pem_password).encode() if pem_password else None + # despite documentation saying password should be a string, it needs to actually + # be UTF-8 encoded bytes + pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, pem_data, pem_password) + except crypto.Error: + pass # wrong password + except TypeError: + pass # no pass provided + _set_rsa_parameters(key_obj, pkey) + elif byok_file: + with open(byok_file, 'rb') as f: + byok_data = f.read() + key_obj.kty = 'RSA-HSM' + key_obj.t = byok_data + + return self.client.import_key( + self.keyvault_uri, key_name, key_obj, destination == 'hsm', key_attrs, tags) + + +def main(): + AzureRMKeyVaultKey() + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/azure_rm_keyvaultkey/aliases b/test/integration/targets/azure_rm_keyvaultkey/aliases new file mode 100644 index 00000000000..d6ff84111cd --- /dev/null +++ b/test/integration/targets/azure_rm_keyvaultkey/aliases @@ -0,0 +1,3 @@ +cloud/azure +posix/ci/cloud/azure +destructive diff --git a/test/integration/targets/azure_rm_keyvaultkey/meta/main.yml b/test/integration/targets/azure_rm_keyvaultkey/meta/main.yml new file mode 100644 index 00000000000..95e1952f989 --- /dev/null +++ b/test/integration/targets/azure_rm_keyvaultkey/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_azure diff --git a/test/integration/targets/azure_rm_keyvaultkey/tasks/main.yml b/test/integration/targets/azure_rm_keyvaultkey/tasks/main.yml new file mode 100644 index 00000000000..b617c922ab7 --- /dev/null +++ b/test/integration/targets/azure_rm_keyvaultkey/tasks/main.yml @@ -0,0 +1,69 @@ +- name: Prepare random number + set_fact: + rpfx: "{{ resource_group | hash('md5') | truncate(7, True, '') }}{{ 1000 | random }}" + run_once: yes + +- name: Create instance of Key Vault + azure_rm_keyvault: + resource_group: "{{ resource_group }}" + vault_name: "vault{{ rpfx }}" + enabled_for_deployment: yes + vault_tenant: "{{ azure_tenant }}" + sku: + name: standard + family: A + access_policies: + - tenant_id: "{{ azure_tenant }}" + object_id: 97567bfa-cf13-4217-8fa3-cc56bc1867fe + keys: + - get + - list + - update + - create + - import + - delete + - recover + - backup + - restore + secrets: + - get + - list + - set + - delete + - recover + - backup + - restore + register: output + +- name: create a kevyault key + block: + - azure_rm_keyvaultkey: + keyvault_uri: https://vault{{ rpfx }}.vault.azure.net + key_name: testkey + tags: + testing: test + delete: on-exit + register: output + - assert: + that: output.changed + rescue: + - azure_rm_keyvaultkey: + keyvault_uri: https://vault{{ rpfx }}.vault.azure.net + state: absent + key_name: testkey + +- name: delete a kevyault key + azure_rm_keyvaultkey: + keyvault_uri: https://vault{{ rpfx }}.vault.azure.net + state: absent + key_name: testkey + register: output + +- assert: + that: output.changed + +- name: Delete instance of Key Vault + azure_rm_keyvault: + resource_group: "{{ resource_group }}" + vault_name: "vault{{ rpfx }}" + state: absent