From 7a43f00a5df54771a5f97541792093e03488f9f3 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 22 Feb 2019 14:45:27 -0500 Subject: [PATCH] add support for HashiCorp signed SSH certificates --- awx/main/credential_plugins/azure_kv.py | 2 +- awx/main/credential_plugins/hashivault.py | 94 ++++++++++++++----- awx/main/models/credential/__init__.py | 10 +- awx/main/tasks.py | 33 ++++++- awx/main/tests/functional/test_credential.py | 4 +- setup.py | 3 +- .../awx.egg-info/entry_points.txt | 3 +- 7 files changed, 114 insertions(+), 35 deletions(-) diff --git a/awx/main/credential_plugins/azure_kv.py b/awx/main/credential_plugins/azure_kv.py index e695d52e6b..2fe8c00ec9 100644 --- a/awx/main/credential_plugins/azure_kv.py +++ b/awx/main/credential_plugins/azure_kv.py @@ -37,7 +37,7 @@ azure_keyvault_inputs = { } -def azure_keyvault_backend(**kwargs): +def azure_keyvault_backend(raw, **kwargs): url = kwargs['url'] def auth_callback(server, resource, scope): diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index c78082e809..555de51c27 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -6,22 +6,12 @@ from .plugin import CredentialPlugin from hvac import Client -hashi_inputs = { +base_inputs = { 'fields': [{ 'id': 'url', 'label': 'Server URL', 'type': 'string', 'help_text': 'The URL to the HashiCorp Vault', - }, { - 'id': 'secret_path', - 'label': 'Path to Secret', - 'type': 'string', - 'help_text': 'The path to the secret e.g., /some-engine/some-secret/', - }, { - 'id': 'secret_field', - 'label': 'Key Name', - 'type': 'string', - 'help_text': 'The name of the key to look up in the secret.', }, { 'id': 'token', 'label': 'Token', @@ -29,31 +19,60 @@ hashi_inputs = { 'secret': True, 'help_text': 'The access token used to authenticate to the Vault server', }, { - 'id': 'api_version', - 'label': 'API Version', - 'choices': ['v1', 'v2'], - 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', - 'default': 'v1', + 'id': 'secret_path', + 'label': 'Path to Secret', + 'type': 'string', + 'help_text': 'The path to the secret e.g., /some-engine/some-secret/', + }], + 'required': ['url', 'token', 'secret_path'], +} + +hashi_kv_inputs = { + 'fields': base_inputs['fields'] + [{ + 'id': 'secret_field', + 'label': 'Key Name', + 'type': 'string', + 'help_text': 'The name of the key to look up in the secret.', }, { 'id': 'secret_version', 'label': 'Secret Version (v2 only)', 'type': 'string', 'help_text': 'Used to specify a specific secret version (if left empty, the latest version will be used).', + }, { + 'id': 'api_version', + 'label': 'API Version', + 'choices': ['v1', 'v2'], + 'help_text': 'API v1 is for static key/value lookups. API v2 is for versioned key/value lookups.', + 'default': 'v1', }], - 'required': ['url', 'secret_path', 'secret_field', 'token', 'api_version'], + 'required': base_inputs['required'] + ['secret_field', 'api_version'] +} + +hashi_ssh_inputs = { + 'fields': base_inputs['fields'] + [{ + 'id': 'role', + 'label': 'Role Name', + 'type': 'string', + 'help_text': 'The name of the role used to sign.' + }, { + 'id': 'valid_principals', + 'label': 'Valid Principals', + 'type': 'string', + 'help_text': 'Valid principals (either usernames or hostnames) that the certificate should be signed for.', + }], + 'required': base_inputs['required'] + ['role'] } -def hashi_backend(**kwargs): - token = kwargs.get('token') - url = kwargs.get('url') - secret_path = kwargs.get('secret_path') +def kv_backend(raw, **kwargs): + token = kwargs['token'] + url = kwargs['url'] + secret_path = kwargs['secret_path'] secret_field = kwargs.get('secret_field', None) - verify = kwargs.get('verify', False) api_version = kwargs.get('api_version', None) - client = Client(url=url, token=token, verify=verify) + client = Client(url=url, token=token, verify=True) if api_version == 'v2': try: mount_point, *path = pathlib.Path(secret_path.lstrip(os.sep)).parts @@ -90,8 +109,31 @@ def hashi_backend(**kwargs): return response['data'] -hashivault_plugin = CredentialPlugin( +def ssh_backend(raw, **kwargs): + token = kwargs['token'] + url = kwargs['url'] + + client = Client(url=url, token=token, verify=True) + json = { + 'public_key': raw + } + if kwargs.get('valid_principals'): + json['valid_principals'] = kwargs['valid_principals'] + resp = client._adapter.post( + '/v1/{}/sign/{}'.format(kwargs['secret_path'], kwargs['role']), + json=json, + ) + return resp.json()['data']['signed_key'] + + +hashivault_kv_plugin = CredentialPlugin( 'HashiCorp Vault Secret Lookup', - inputs=hashi_inputs, - backend=hashi_backend + inputs=hashi_kv_inputs, + backend=kv_backend +) + +hashivault_ssh_plugin = CredentialPlugin( + 'HashiCorp Vault Signed SSH', + inputs=hashi_ssh_inputs, + backend=ssh_backend ) diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index cf914a3f33..03c86f6800 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -808,6 +808,11 @@ ManagedCredentialType( 'format': 'ssh_private_key', 'secret': True, 'multiline': True + }, { + 'id': 'ssh_public_key_data', + 'label': ugettext_noop('Signed SSH Certificate'), + 'type': 'string', + 'multiline': True, }, { 'id': 'ssh_key_unlock', 'label': ugettext_noop('Private Key Passphrase'), @@ -1342,7 +1347,10 @@ class CredentialInputSource(PrimordialModel): backend_kwargs[field_name] = decrypt_field(self.source_credential, field_name) else: backend_kwargs[field_name] = value - return backend(**backend_kwargs) + return backend( + self.target_credential.inputs.get(self.input_field_name), + **backend_kwargs + ) def get_absolute_url(self, request=None): view_name = 'api:credential_input_source_detail' diff --git a/awx/main/tasks.py b/awx/main/tasks.py index a95074b19d..cb479779e7 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -772,7 +772,12 @@ class BaseTask(object): 'credentials': { : '/path/to/decrypted/data', : '/path/to/decrypted/data', - : '/path/to/decrypted/data', + ... + }, + 'certificates': { + : /path/to/signed/ssh/certificate, + : /path/to/signed/ssh/certificate, + ... } } ''' @@ -787,7 +792,6 @@ class BaseTask(object): # and we're running an earlier version (<6.5). if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported: raise RuntimeError(OPENSSH_KEY_ERROR) - for credential, data in private_data.get('credentials', {}).items(): # OpenSSH formatted keys must have a trailing newline to be # accepted by ssh-add. if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'): @@ -813,6 +817,13 @@ class BaseTask(object): f.close() os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) private_data_files['credentials'][credential] = path + for credential, data in private_data.get('certificates', {}).items(): + name = 'credential_%d-cert.pub' % credential.pk + path = os.path.join(kwargs['private_data_dir'], name) + with open(path, 'w') as f: + f.write(data) + f.close() + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) return private_data_files def build_passwords(self, instance, runtime_passwords): @@ -1269,7 +1280,12 @@ class RunJob(BaseTask): 'credentials': { : , : , - : + ... + }, + 'certificates': { + : , + : , + ... } } ''' @@ -1279,6 +1295,8 @@ class RunJob(BaseTask): # back (they will be written to a temporary file). if credential.has_input('ssh_key_data'): private_data['credentials'][credential] = credential.get_input('ssh_key_data', default='') + if credential.has_input('ssh_public_key_data'): + private_data.setdefault('certificates', {})[credential] = credential.get_input('ssh_public_key_data', default='') if credential.kind == 'openstack': openstack_auth = dict(auth_url=credential.get_input('host', default=''), @@ -2187,7 +2205,12 @@ class RunAdHocCommand(BaseTask): 'credentials': { : , : , - : + ... + }, + 'certificates': { + : , + : , + ... } } ''' @@ -2197,6 +2220,8 @@ class RunAdHocCommand(BaseTask): private_data = {'credentials': {}} if creds and creds.has_input('ssh_key_data'): private_data['credentials'][creds] = creds.get_input('ssh_key_data', default='') + if creds and creds.has_input('ssh_public_key_data'): + private_data.setdefault('certificates', {})[creds] = creds.get_input('ssh_public_key_data', default='') return private_data def build_passwords(self, ad_hoc_command, runtime_passwords): diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 6ccbfc6344..0dd0f8fdce 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -76,10 +76,12 @@ GLqbpJyX2r3p/Rmo6mLY71SqpA== def test_default_cred_types(): assert sorted(CredentialType.defaults.keys()) == [ 'aws', + 'azure_kv', 'azure_rm', 'cloudforms', 'gce', - 'hashivault', + 'hashivault_kv', + 'hashivault_ssh', 'insights', 'net', 'openstack', diff --git a/setup.py b/setup.py index 237d6df1e4..1fadc25dcf 100755 --- a/setup.py +++ b/setup.py @@ -115,7 +115,8 @@ setup( 'awx-manage = awx:manage', ], 'awx.credential_plugins': [ - 'hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin', + 'hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin', + 'hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin', 'azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin', ] }, diff --git a/tools/docker-compose/awx.egg-info/entry_points.txt b/tools/docker-compose/awx.egg-info/entry_points.txt index accfb15870..f1832e730c 100644 --- a/tools/docker-compose/awx.egg-info/entry_points.txt +++ b/tools/docker-compose/awx.egg-info/entry_points.txt @@ -3,5 +3,6 @@ tower-manage = awx:manage awx-manage = awx:manage [awx.credential_plugins] -hashivault = awx.main.credential_plugins.hashivault:hashivault_plugin +hashivault_kv = awx.main.credential_plugins.hashivault:hashivault_kv_plugin +hashivault_ssh = awx.main.credential_plugins.hashivault:hashivault_ssh_plugin azure_kv = awx.main.credential_plugins.azure_kv:azure_keyvault_plugin