Skip to content

Commit

Permalink
add vault_write lookup and module (#223)
Browse files Browse the repository at this point in the history
* add vault_write lookup

* add vault_write module

* lookup updates

* update docstring

* move fixtures to be more general

* add units for vault_write lookup

* improve tests

* add units for vault_write module

* remove unused fixture

* update patch_ansible_module with parametrize compatibility

* add wrapping doc fragment

* vault_write module did not properly set changed status

* update vault_write module to support wrapping, fix assert order

* fix typo is orphan policy configure

* typo in doc fragment name

* add wrapping support to vault_write lookup

* add integration for module_vault_write

* add integration for vault_write lookup

* update examples

* update version_added on module

* fix check mode support

* improve documentation and examples

* fix module reference

* empty

* add description for ref

* empty

* typo

* empty
  • Loading branch information
briantist authored Mar 26, 2022
1 parent 9604e74 commit 3a91571
Show file tree
Hide file tree
Showing 19 changed files with 1,039 additions and 41 deletions.
24 changes: 24 additions & 0 deletions plugins/doc_fragments/wrapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-

# Copyright: (c) 2022, Brian Scholer (@briantist)
# 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


class ModuleDocFragment(object):

DOCUMENTATION = r'''
options:
wrap_ttl:
description: Specifies response wrapping token creation with duration. For example C(15s), C(20m), C(25h).
type: str
'''

PLUGINS = r'''
options:
wrap_ttl:
vars:
- name: ansible_hashi_vault_wrap_ttl
'''
190 changes: 190 additions & 0 deletions plugins/lookup/vault_write.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# (c) 2022, Brian Scholer (@briantist)
# 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

DOCUMENTATION = """
name: vault_write
version_added: 2.4.0
author:
- Brian Scholer (@briantist)
short_description: Perform a write operation against HashiCorp Vault
requirements:
- C(hvac) (L(Python library,https://hvac.readthedocs.io/en/stable/overview.html))
- For detailed requirements, see R(the collection requirements page,ansible_collections.community.hashi_vault.docsite.user_guide.requirements).
description:
- Performs a generic write operation against a given path in HashiCorp Vault, returning any output.
seealso:
- module: community.hashi_vault.vault_write
- ref: community.hashi_vault.vault_read lookup <ansible_collections.community.hashi_vault.vault_read_lookup>
description: The official documentation for the C(community.hashi_vault.vault_read) lookup plugin.
- module: community.hashi_vault.vault_read
- ref: community.hashi_vault Lookup Guide <ansible_collections.community.hashi_vault.docsite.lookup_guide>
description: Guidance on using lookups in C(community.hashi_vault).
notes:
- C(vault_write) is a generic plugin to do operations that do not yet have a dedicated plugin. Where a specific plugin exists, that should be used instead.
- In the vast majority of cases, it will be better to do writes as a task, with the M(community.hashi_vault.vault_write) module.
- The lookup can be used in cases where you need a value directly in templating, but there is risk of executing the write many times unintentionally.
- The lookup is best used for endpoints that directly manipulate the input data and return a value, while not changing state in Vault.
- See the R(Lookup Guide,ansible_collections.community.hashi_vault.docsite.lookup_guide) for more information.
extends_documentation_fragment:
- community.hashi_vault.connection
- community.hashi_vault.connection.plugins
- community.hashi_vault.auth
- community.hashi_vault.auth.plugins
- community.hashi_vault.wrapping
- community.hashi_vault.wrapping.plugins
options:
_terms:
description: Vault path(s) to be written to.
type: str
required: true
data:
description: A dictionary to be serialized to JSON and then sent as the request body.
type: dict
required: false
default: {}
"""

EXAMPLES = """
# These examples show some uses that might work well as a lookup.
# For most uses, the vault_write module should be used.
- name: Retrieve and display random data
vars:
data:
format: hex
num_bytes: 64
ansible.builtin.debug:
msg: "{{ lookup('community.hashi_vault.vault_write', 'sys/tools/random/' ~ num_bytes, data=data) }}"
- name: Hash some data and display the hash
vars:
input: |
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Pellentesque posuere dui a ipsum dapibus, et placerat nibh bibendum.
data:
input: '{{ input | b64encode }}'
hash_algo: sha2-256
ansible.builtin.debug:
msg: "The hash is {{ lookup('community.hashi_vault.vault_write', 'sys/tools/hash/' ~ hash_algo, data=data) }}"
# In this next example, the Ansible controller's token does not have permission to read the secrets we need.
# It does have permission to generate new secret IDs for an approle which has permission to read the secrets,
# however the approle is configured to:
# 1) allow a maximum of 1 use per secret ID
# 2) restrict the IPs allowed to use login using the approle to those of the remote hosts
#
# Normally, the fact that a new secret ID would be generated on every loop iteration would not be desirable,
# but here it's quite convenient.
- name: Retrieve secrets from the remote host with one-time-use approle creds
vars:
role_id: "{{ lookup('community.hashi_vault.vault_read', 'auth/approle/role/role-name/role-id') }}"
secret_id: "{{ lookup('community.hashi_vault.vault_write', 'auth/approle/role/role-name/secret-id') }}"
community.hashi_vault.vault_read:
auth_method: approle
role_id: '{{ role_id }}'
secret_id: '{{ secret_id }}'
path: '{{ item }}'
register: secret_data
loop:
- secret/data/secret1
- secret/data/app/deploy-key
- secret/data/access-codes/self-destruct
# This time we have a secret values on the controller, and we need to run a command the remote host,
# that is expecting to a use single-use token as input, so we need to use wrapping to send the data.
- name: Run a command that needs wrapped secrets
vars:
secrets:
secret1: '{{ my_secret_1 }}'
secret2: '{{ second_secret }}'
wrapped: "{{ lookup('community.hashi_vault.vault_write', 'sys/wrapping/wrap', data=secrets) }}"
ansible.builtin.command: 'vault unwrap {{ wrapped }}'
"""

RETURN = """
_raw:
description: The raw result of the write against the given path.
type: list
elements: dict
"""

from ansible.errors import AnsibleError
from ansible.utils.display import Display

from ansible.module_utils.six import raise_from

from ansible_collections.community.hashi_vault.plugins.plugin_utils._hashi_vault_lookup_base import HashiVaultLookupBase
from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError

display = Display()

try:
import hvac
except ImportError as imp_exc:
HVAC_IMPORT_ERROR = imp_exc
else:
HVAC_IMPORT_ERROR = None


class LookupModule(HashiVaultLookupBase):
def run(self, terms, variables=None, **kwargs):
if HVAC_IMPORT_ERROR:
raise_from(
AnsibleError("This plugin requires the 'hvac' Python library"),
HVAC_IMPORT_ERROR
)

ret = []

self.set_options(direct=kwargs, var_options=variables)
# TODO: remove process_deprecations() if backported fix is available (see method definition)
self.process_deprecations()

self.connection_options.process_connection_options()
client_args = self.connection_options.get_hvac_connection_options()
client = self.helper.get_vault_client(**client_args)

data = self._options_adapter.get_option('data')
wrap_ttl = self._options_adapter.get_option_default('wrap_ttl')

try:
self.authenticator.validate()
self.authenticator.authenticate(client)
except (NotImplementedError, HashiVaultValueError) as e:
raise_from(AnsibleError(e), e)

for term in terms:
try:
response = client.write(path=term, wrap_ttl=wrap_ttl, **data)
except hvac.exceptions.Forbidden as e:
raise_from(AnsibleError("Forbidden: Permission Denied to path '%s'." % term), e)
except hvac.exceptions.InvalidPath as e:
raise_from(AnsibleError("The path '%s' doesn't seem to exist." % term), e)
except hvac.exceptions.InternalServerError as e:
raise_from(AnsibleError("Internal Server Error: %s" % str(e)), e)

# https://github.com/hvac/hvac/issues/797
# HVAC returns a raw response object when the body is not JSON.
# That includes 204 responses, which are successful with no body.
# So we will try to detect that and a act accordingly.
# A better way may be to implement our own adapter for this
# collection, but it's a little premature to do that.
if hasattr(response, 'json') and callable(response.json):
if response.status_code == 204:
output = {}
else:
display.warning('Vault returned status code %i and an unparsable body.' % response.status_code)
output = response.content
else:
output = response

ret.append(output)

return ret
174 changes: 174 additions & 0 deletions plugins/modules/vault_write.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# (c) 2022, Brian Scholer (@briantist)
# 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

DOCUMENTATION = """
module: vault_write
version_added: 2.4.0
author:
- Brian Scholer (@briantist)
short_description: Perform a write operation against HashiCorp Vault
requirements:
- C(hvac) (L(Python library,https://hvac.readthedocs.io/en/stable/overview.html))
- For detailed requirements, see R(the collection requirements page,ansible_collections.community.hashi_vault.docsite.user_guide.requirements).
description:
- Performs a generic write operation against a given path in HashiCorp Vault, returning any output.
notes:
- C(vault_write) is a generic module to do operations that do not yet have a dedicated module. Where a specific module exists, that should be used instead.
- The I(data) option is not treated as secret and may be logged. Use the C(no_log) keyword if I(data) contains sensitive values.
- This module always reports C(changed) status because it cannot guarantee idempotence.
- Use C(changed_when) to control that in cases where the operation is known to not change state.
seealso:
- ref: community.hashi_vault.vault_write lookup <ansible_collections.community.hashi_vault.vault_write_lookup>
description: The official documentation for the C(community.hashi_vault.vault_write) lookup plugin.
- module: community.hashi_vault.vault_read
- ref: community.hashi_vault.vault_read lookup <ansible_collections.community.hashi_vault.vault_read_lookup>
description: The official documentation for the C(community.hashi_vault.vault_read) lookup plugin.
extends_documentation_fragment:
- community.hashi_vault.connection
- community.hashi_vault.auth
- community.hashi_vault.wrapping
options:
path:
description: Vault path to be written to.
type: str
required: True
data:
description: A dictionary to be serialized to JSON and then sent as the request body.
type: dict
required: false
default: {}
"""

EXAMPLES = """
- name: Write a value to the cubbyhole via the remote host with userpass auth
community.hashi_vault.vault_write:
url: https://vault:8201
path: cubbyhole/mysecret
data:
key1: val1
key2: val2
auth_method: userpass
username: user
password: '{{ passwd }}'
register: result
- name: Display the result of the write (this can be empty)
ansible.builtin.debug:
msg: "{{ result.data }}"
- name: Retrieve an approle role ID from Vault via the remote host
community.hashi_vault.vault_read:
url: https://vault:8201
path: auth/approle/role/role-name/role-id
register: approle_id
- name: Generate a secret-id for the given approle
community.hashi_vault.vault_write:
url: https://vault:8201
path: auth/approle/role/role-name/secret-id
register: secret_id
- name: Display the role ID and secret ID
ansible.builtin.debug:
msg:
- "role-id: {{ approle_id.data.data.role_id }}"
- "secret-id: {{ secret_id.data.data.secret_id }}"
"""

RETURN = """
data:
description: The raw result of the write against the given path.
returned: success
type: dict
"""

import traceback

from ansible.module_utils._text import to_native
from ansible.module_utils.basic import missing_required_lib

from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_module import HashiVaultModule
from ansible_collections.community.hashi_vault.plugins.module_utils._hashi_vault_common import HashiVaultValueError

try:
import hvac
except ImportError:
HAS_HVAC = False
HVAC_IMPORT_ERROR = traceback.format_exc()
else:
HAS_HVAC = True


def run_module():
argspec = HashiVaultModule.generate_argspec(
path=dict(type='str', required=True),
data=dict(type='dict', required=False, default={}),
wrap_ttl=dict(type='str'),
)

module = HashiVaultModule(
argument_spec=argspec,
supports_check_mode=True
)

if not HAS_HVAC:
module.fail_json(
msg=missing_required_lib('hvac'),
exception=HVAC_IMPORT_ERROR
)

path = module.params.get('path')
data = module.params.get('data')
wrap_ttl = module.params.get('wrap_ttl')

module.connection_options.process_connection_options()
client_args = module.connection_options.get_hvac_connection_options()
client = module.helper.get_vault_client(**client_args)

try:
module.authenticator.validate()
module.authenticator.authenticate(client)
except (NotImplementedError, HashiVaultValueError) as e:
module.fail_json(msg=to_native(e), exception=traceback.format_exc())

try:
if module.check_mode:
response = {}
else:
response = client.write(path=path, wrap_ttl=wrap_ttl, **data)
except hvac.exceptions.Forbidden:
module.fail_json(msg="Forbidden: Permission Denied to path '%s'." % path, exception=traceback.format_exc())
except hvac.exceptions.InvalidPath:
module.fail_json(msg="The path '%s' doesn't seem to exist." % path, exception=traceback.format_exc())
except hvac.exceptions.InternalServerError as e:
module.fail_json(msg="Internal Server Error: %s" % to_native(e), exception=traceback.format_exc())

# https://github.com/hvac/hvac/issues/797
# HVAC returns a raw response object when the body is not JSON.
# That includes 204 responses, which are successful with no body.
# So we will try to detect that and a act accordingly.
# A better way may be to implement our own adapter for this
# collection, but it's a little premature to do that.
if hasattr(response, 'json') and callable(response.json):
if response.status_code == 204:
output = {}
else:
module.warn('Vault returned status code %i and an unparsable body.' % response.status_code)
output = response.content
else:
output = response

module.exit_json(changed=True, data=output)


def main():
run_module()


if __name__ == '__main__':
main()
1 change: 1 addition & 0 deletions tests/integration/targets/lookup_vault_write/aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# empty
3 changes: 3 additions & 0 deletions tests/integration/targets/lookup_vault_write/meta/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
dependencies:
- setup_vault_test_plugins
Loading

0 comments on commit 3a91571

Please sign in to comment.