1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

passwordstore: Add configurable locking (#4194) (#4243)

* passwordstore: Add configurable locking

Passwordstore cannot be accessed safely in parallel, which causes
various issues:

- When accessing the same path, multiple different secrets are
  returned when the secret didn't exist (missing=create).
- When accessing the same _or different_ paths, multiple pinentry
  dialogs will be spawned by gpg-agent sequentially, having to enter
  the password for the same gpg key multiple times in a row.
- Due to issues in gpg dependencies, accessing gpg-agent in parallel
  is not reliable, causing plays to fail (this can be fixed by adding
  `auto-expand-secmem` to _~/.gnupg/gpg-agent.conf_ though).

These problems have been described in various github issues in the past,
e.g., ansible/ansible#23816 and ansible/ansible#27277.

This cannot be worked around in playbooks by users in a non-error-prone
way.

It is addressed by adding new configuration options:

- lock:
  - readwrite: Lock all operations
  - write: Only lock write operations (default)
  - none: Disable locking
- locktimeout: Time to wait for getting a lock (s/m/h suffix)
  (defaults to 15m)

These options can also be set in ansible.cfg, e.g.:

    [passwordstore_lookup]
    lock=readwrite
    locktimeout=30s

Also, add a note about modifying gpg-agent.conf.

* Tidy up locking config

There is no reason why lock configuration should be part of self.paramvals.
Now locking and its configuration happen all in one place.

* Change timeout description wording to the suggested value.

* Rearrange plugin setup, apply PR feedback

(cherry picked from commit 2416b81aa4)

Co-authored-by: grembo <freebsd@grem.de>
This commit is contained in:
patchback[bot] 2022-02-21 21:37:47 +01:00 committed by GitHub
parent df6a00dc89
commit 3d2caf3933
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 118 additions and 50 deletions

View file

@ -0,0 +1,2 @@
minor_changes:
- passwordstore lookup plugin - add configurable ``lock`` and ``locktimeout`` options to avoid race conditions in itself and in the ``pass`` utility it calls. By default, the plugin now locks on write operations (https://github.com/ansible-collections/community.general/pull/4194).

View file

@ -14,6 +14,8 @@ DOCUMENTATION = '''
description: description:
- Enables Ansible to retrieve, create or update passwords from the passwordstore.org pass utility. - Enables Ansible to retrieve, create or update passwords from the passwordstore.org pass utility.
It also retrieves YAML style keys stored as multilines in the passwordfile. It also retrieves YAML style keys stored as multilines in the passwordfile.
- To avoid problems when accessing multiple secrets at once, add C(auto-expand-secmem) to
C(~/.gnupg/gpg-agent.conf). Where this is not possible, consider using I(lock=readwrite) instead.
options: options:
_terms: _terms:
description: query key. description: query key.
@ -77,10 +79,45 @@ DOCUMENTATION = '''
- warn - warn
- empty - empty
- create - create
lock:
description:
- How to synchronize operations.
- The default of C(write) only synchronizes write operations.
- C(readwrite) synchronizes all operations (including read). This makes sure that gpg-agent is never called in parallel.
- C(none) does not do any synchronization.
ini:
- section: passwordstore_lookup
key: lock
type: str
default: write
choices:
- readwrite
- write
- none
version_added: 4.5.0
locktimeout:
description:
- Lock timeout applied when I(lock) is not C(none).
- Time with a unit suffix, C(s), C(m), C(h) for seconds, minutes, and hours, respectively. For example, C(900s) equals C(15m).
- Correlates with C(pinentry-timeout) in C(~/.gnupg/gpg-agent.conf), see C(man gpg-agent) for details.
ini:
- section: passwordstore_lookup
key: locktimeout
type: str
default: 15m
version_added: 4.5.0
''' '''
EXAMPLES = """ EXAMPLES = """
ansible.cfg: |
[passwordstore_lookup]
lock=readwrite
locktimeout=45s
playbook.yml: |
---
# Debug is used for examples, BAD IDEA to show passwords on screen # Debug is used for examples, BAD IDEA to show passwords on screen
- name: Basic lookup. Fails if example/test doesn't exist - name: Basic lookup. Fails if example/test does not exist
ansible.builtin.debug: ansible.builtin.debug:
msg: "{{ lookup('community.general.passwordstore', 'example/test')}}" msg: "{{ lookup('community.general.passwordstore', 'example/test')}}"
@ -135,13 +172,15 @@ _raw:
elements: str elements: str
""" """
from contextlib import contextmanager
import os import os
import re
import subprocess import subprocess
import time import time
import yaml import yaml
from ansible.errors import AnsibleError, AnsibleAssertionError from ansible.errors import AnsibleError, AnsibleAssertionError
from ansible.module_utils.common.file import FileLock
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
from ansible.module_utils.parsing.convert_bool import boolean from ansible.module_utils.parsing.convert_bool import boolean
from ansible.utils.display import Display from ansible.utils.display import Display
@ -328,8 +367,25 @@ class LookupModule(LookupBase):
else: else:
return None return None
def run(self, terms, variables, **kwargs): @contextmanager
result = [] def opt_lock(self, type):
if self.get_option('lock') == type:
tmpdir = os.environ.get('TMPDIR', '/tmp')
lockfile = os.path.join(tmpdir, '.passwordstore.lock')
with FileLock().lock_file(lockfile, tmpdir, self.lock_timeout):
self.locked = type
yield
self.locked = None
else:
yield
def setup(self, variables):
self.locked = None
timeout = self.get_option('locktimeout')
if not re.match('^[0-9]+[smh]$', timeout):
raise AnsibleError("{0} is not a correct value for locktimeout".format(timeout))
unit_to_seconds = {"s": 1, "m": 60, "h": 3600}
self.lock_timeout = int(timeout[:-1]) * unit_to_seconds[timeout[-1]]
self.paramvals = { self.paramvals = {
'subkey': 'password', 'subkey': 'password',
'directory': variables.get('passwordstore', os.environ.get( 'directory': variables.get('passwordstore', os.environ.get(
@ -345,15 +401,25 @@ class LookupModule(LookupBase):
'missing': 'error', 'missing': 'error',
} }
def run(self, terms, variables, **kwargs):
self.setup(variables)
result = []
for term in terms: for term in terms:
self.parse_params(term) # parse the input into paramvals self.parse_params(term) # parse the input into paramvals
with self.opt_lock('readwrite'):
if self.check_pass(): # password exists if self.check_pass(): # password exists
if self.paramvals['overwrite'] and self.paramvals['subkey'] == 'password': if self.paramvals['overwrite'] and self.paramvals['subkey'] == 'password':
with self.opt_lock('write'):
result.append(self.update_password()) result.append(self.update_password())
else: else:
result.append(self.get_passresult()) result.append(self.get_passresult())
else: # password does not exist else: # password does not exist
if self.paramvals['missing'] == 'create': if self.paramvals['missing'] == 'create':
with self.opt_lock('write'):
if self.locked == 'write' and self.check_pass(): # lookup password again if under write lock
result.append(self.get_passresult())
else:
result.append(self.generate_password()) result.append(self.generate_password())
else: else:
result.append(None) result.append(None)