1"""
2:maintainer:    SaltStack
3:maturity:      new
4:platform:      all
5
6Utilities supporting modules for Hashicorp Vault. Configuration instructions are
7documented in the execution module docs.
8"""
9
10import base64
11import logging
12import os
13import time
14
15import requests
16import salt.crypt
17import salt.exceptions
18import salt.utils.versions
19
20log = logging.getLogger(__name__)
21
22
23# Load the __salt__ dunder if not already loaded (when called from utils-module)
24__salt__ = None
25
26
27def __virtual__():
28    try:
29        global __salt__  # pylint: disable=global-statement
30        if not __salt__:
31            __salt__ = salt.loader.minion_mods(__opts__)
32            logging.getLogger("requests").setLevel(logging.WARNING)
33            return True
34    except Exception as e:  # pylint: disable=broad-except
35        log.error("Could not load __salt__: %s", e)
36        return False
37
38
39def _get_token_and_url_from_master():
40    """
41    Get a token with correct policies for the minion, and the url to the Vault
42    service
43    """
44    minion_id = __grains__["id"]
45    pki_dir = __opts__["pki_dir"]
46    # Allow minion override salt-master settings/defaults
47    try:
48        uses = __opts__.get("vault", {}).get("auth", {}).get("uses", None)
49        ttl = __opts__.get("vault", {}).get("auth", {}).get("ttl", None)
50    except (TypeError, AttributeError):
51        # If uses or ttl are not defined, just use defaults
52        uses = None
53        ttl = None
54
55    # When rendering pillars, the module executes on the master, but the token
56    # should be issued for the minion, so that the correct policies are applied
57    if __opts__.get("__role", "minion") == "minion":
58        private_key = "{}/minion.pem".format(pki_dir)
59        log.debug("Running on minion, signing token request with key %s", private_key)
60        signature = base64.b64encode(salt.crypt.sign_message(private_key, minion_id))
61        result = __salt__["publish.runner"](
62            "vault.generate_token", arg=[minion_id, signature, False, ttl, uses]
63        )
64    else:
65        private_key = "{}/master.pem".format(pki_dir)
66        log.debug(
67            "Running on master, signing token request for %s with key %s",
68            minion_id,
69            private_key,
70        )
71        signature = base64.b64encode(salt.crypt.sign_message(private_key, minion_id))
72        result = __salt__["saltutil.runner"](
73            "vault.generate_token",
74            minion_id=minion_id,
75            signature=signature,
76            impersonated_by_master=True,
77            ttl=ttl,
78            uses=uses,
79        )
80    if not result:
81        log.error(
82            "Failed to get token from master! No result returned - "
83            "is the peer publish configuration correct?"
84        )
85        raise salt.exceptions.CommandExecutionError(result)
86    if not isinstance(result, dict):
87        log.error("Failed to get token from master! Response is not a dict: %s", result)
88        raise salt.exceptions.CommandExecutionError(result)
89    if "error" in result:
90        log.error(
91            "Failed to get token from master! An error was returned: %s",
92            result["error"],
93        )
94        raise salt.exceptions.CommandExecutionError(result)
95    if "session" in result.get("token_backend", "session"):
96        # This is the only way that this key can be placed onto __context__
97        # Thus is tells the minion that the master is configured for token_backend: session
98        log.debug("Using session storage for vault credentials")
99        __context__["vault_secret_path_metadata"] = {}
100    return {
101        "url": result["url"],
102        "token": result["token"],
103        "verify": result.get("verify", None),
104        "namespace": result.get("namespace"),
105        "uses": result.get("uses", 1),
106        "lease_duration": result["lease_duration"],
107        "issued": result["issued"],
108    }
109
110
111def get_vault_connection():
112    """
113    Get the connection details for calling Vault, from local configuration if
114    it exists, or from the master otherwise
115    """
116
117    def _use_local_config():
118        log.debug("Using Vault connection details from local config")
119        # Vault Enterprise requires a namespace
120        namespace = __opts__["vault"].get("namespace")
121        try:
122            if __opts__["vault"]["auth"]["method"] == "approle":
123                verify = __opts__["vault"].get("verify", None)
124                if _selftoken_expired():
125                    log.debug("Vault token expired. Recreating one")
126                    # Requesting a short ttl token
127                    url = "{}/v1/auth/approle/login".format(__opts__["vault"]["url"])
128                    payload = {"role_id": __opts__["vault"]["auth"]["role_id"]}
129                    if "secret_id" in __opts__["vault"]["auth"]:
130                        payload["secret_id"] = __opts__["vault"]["auth"]["secret_id"]
131                    if namespace is not None:
132                        headers = {"X-Vault-Namespace": namespace}
133                        response = requests.post(
134                            url, headers=headers, json=payload, verify=verify
135                        )
136                    else:
137                        response = requests.post(url, json=payload, verify=verify)
138                    if response.status_code != 200:
139                        errmsg = "An error occurred while getting a token from approle"
140                        raise salt.exceptions.CommandExecutionError(errmsg)
141                    __opts__["vault"]["auth"]["token"] = response.json()["auth"][
142                        "client_token"
143                    ]
144            if __opts__["vault"]["auth"]["method"] == "wrapped_token":
145                verify = __opts__["vault"].get("verify", None)
146                if _wrapped_token_valid():
147                    url = "{}/v1/sys/wrapping/unwrap".format(__opts__["vault"]["url"])
148                    headers = {"X-Vault-Token": __opts__["vault"]["auth"]["token"]}
149                    if namespace is not None:
150                        headers["X-Vault-Namespace"] = namespace
151                    response = requests.post(url, headers=headers, verify=verify)
152                    if response.status_code != 200:
153                        errmsg = "An error occured while unwrapping vault token"
154                        raise salt.exceptions.CommandExecutionError(errmsg)
155                    __opts__["vault"]["auth"]["token"] = response.json()["auth"][
156                        "client_token"
157                    ]
158            return {
159                "url": __opts__["vault"]["url"],
160                "namespace": namespace,
161                "token": __opts__["vault"]["auth"]["token"],
162                "verify": __opts__["vault"].get("verify", None),
163                "issued": int(round(time.time())),
164                "ttl": 3600,
165            }
166        except KeyError as err:
167            errmsg = 'Minion has "vault" config section, but could not find key "{}" within'.format(
168                err
169            )
170            raise salt.exceptions.CommandExecutionError(errmsg)
171
172    if "vault" in __opts__ and __opts__.get("__role", "minion") == "master":
173        if "id" in __grains__:
174            log.debug("Contacting master for Vault connection details")
175            return _get_token_and_url_from_master()
176        else:
177            return _use_local_config()
178    elif any(
179        (
180            __opts__.get("local", None),
181            __opts__.get("file_client", None) == "local",
182            __opts__.get("master_type", None) == "disable",
183        )
184    ):
185        return _use_local_config()
186    else:
187        log.debug("Contacting master for Vault connection details")
188        return _get_token_and_url_from_master()
189
190
191def del_cache():
192    """
193    Delete cache file
194    """
195    log.debug("Deleting cache file")
196    cache_file = os.path.join(__opts__["cachedir"], "salt_vault_token")
197
198    if os.path.exists(cache_file):
199        os.remove(cache_file)
200    else:
201        log.debug("Attempted to delete vault cache file, but it does not exist.")
202
203
204def write_cache(connection):
205    """
206    Write the vault token to cache
207    """
208    # If uses is 1 and unlimited_use_token is not true, then this is a single use token and should not be cached
209    # In that case, we still want to cache the vault metadata lookup information for paths, so continue on
210    if (
211        connection.get("uses", None) == 1
212        and "unlimited_use_token" not in connection
213        and "vault_secret_path_metadata" not in connection
214    ):
215        log.debug("Not caching vault single use token")
216        __context__["vault_token"] = connection
217        return True
218    elif (
219        "vault_secret_path_metadata" in __context__
220        and "vault_secret_path_metadata" not in connection
221    ):
222        # If session storage is being used, and info passed is not the already saved metadata
223        log.debug("Storing token only for this session")
224        __context__["vault_token"] = connection
225        return True
226    elif "vault_secret_path_metadata" in __context__:
227        # Must have been passed metadata. This is already handled by _get_secret_path_metadata
228        #  and does not need to be resaved
229        return True
230
231    cache_file = os.path.join(__opts__["cachedir"], "salt_vault_token")
232    try:
233        log.debug("Writing vault cache file")
234        # Detect if token was issued without use limit
235        if connection.get("uses") == 0:
236            connection["unlimited_use_token"] = True
237        else:
238            connection["unlimited_use_token"] = False
239        with salt.utils.files.fpopen(cache_file, "w", mode=0o600) as fp_:
240            fp_.write(salt.utils.json.dumps(connection))
241        return True
242    except OSError:
243        log.error(
244            "Failed to cache vault information", exc_info_on_loglevel=logging.DEBUG
245        )
246        return False
247
248
249def _read_cache_file():
250    """
251    Return contents of cache file
252    """
253    try:
254        cache_file = os.path.join(__opts__["cachedir"], "salt_vault_token")
255        with salt.utils.files.fopen(cache_file, "r") as contents:
256            return salt.utils.json.load(contents)
257    except FileNotFoundError:
258        return {}
259
260
261def get_cache():
262    """
263    Return connection information from vault cache file
264    """
265
266    def _gen_new_connection():
267        log.debug("Refreshing token")
268        connection = get_vault_connection()
269        write_status = write_cache(connection)
270        return connection
271
272    connection = _read_cache_file()
273    # If no cache, or only metadata info is saved in cache, generate a new token
274    if not connection or "url" not in connection:
275        return _gen_new_connection()
276
277    # Drop 10 seconds from ttl to be safe
278    if "lease_duration" in connection:
279        ttl = connection["lease_duration"]
280    else:
281        ttl = connection["ttl"]
282    ttl10 = connection["issued"] + ttl - 10
283    cur_time = int(round(time.time()))
284
285    # Determine if ttl still valid
286    if ttl10 < cur_time:
287        log.debug("Cached token has expired %s < %s: DELETING", ttl10, cur_time)
288        del_cache()
289        return _gen_new_connection()
290    else:
291        log.debug("Token has not expired %s > %s", ttl10, cur_time)
292    return connection
293
294
295def make_request(
296    method,
297    resource,
298    token=None,
299    vault_url=None,
300    namespace=None,
301    get_token_url=False,
302    retry=False,
303    **args
304):
305    """
306    Make a request to Vault
307    """
308    if "vault_token" in __context__:
309        connection = __context__["vault_token"]
310    else:
311        connection = get_cache()
312    token = connection["token"] if not token else token
313    vault_url = connection["url"] if not vault_url else vault_url
314    namespace = namespace or connection["namespace"]
315    if "verify" in args:
316        args["verify"] = args["verify"]
317    else:
318        try:
319            args["verify"] = __opts__.get("vault").get("verify", None)
320        except (TypeError, AttributeError):
321            # Don't worry about setting verify if it doesn't exist
322            pass
323    url = "{}/{}".format(vault_url, resource)
324    headers = {"X-Vault-Token": str(token), "Content-Type": "application/json"}
325    if namespace is not None:
326        headers["X-Vault-Namespace"] = namespace
327    response = requests.request(method, url, headers=headers, **args)
328    if not response.ok and response.json().get("errors", None) == ["permission denied"]:
329        log.info("Permission denied from vault")
330        del_cache()
331        if not retry:
332            log.debug("Retrying with new credentials")
333            response = make_request(
334                method,
335                resource,
336                token=None,
337                vault_url=vault_url,
338                get_token_url=get_token_url,
339                retry=True,
340                **args
341            )
342        else:
343            log.error("Unable to connect to vault server: %s", response.text)
344            return response
345    elif not response.ok:
346        log.error("Error from vault: %s", response.text)
347        return response
348
349    # Decrement vault uses, only on secret URL lookups and multi use tokens
350    if (
351        "uses" in connection
352        and not connection.get("unlimited_use_token")
353        and not resource.startswith("v1/sys")
354    ):
355        log.debug("Decrementing Vault uses on limited token for url: %s", resource)
356        connection["uses"] -= 1
357        if connection["uses"] <= 0:
358            log.debug("Cached token has no more uses left.")
359            if "vault_token" not in __context__:
360                del_cache()
361            else:
362                log.debug("Deleting token from memory")
363                del __context__["vault_token"]
364        else:
365            log.debug("Token has %s uses left", connection["uses"])
366            write_cache(connection)
367
368    if get_token_url:
369        return response, token, vault_url
370    else:
371        return response
372
373
374def _selftoken_expired():
375    """
376    Validate the current token exists and is still valid
377    """
378    try:
379        verify = __opts__["vault"].get("verify", None)
380        # Vault Enterprise requires a namespace
381        namespace = __opts__["vault"].get("namespace")
382        url = "{}/v1/auth/token/lookup-self".format(__opts__["vault"]["url"])
383        if "token" not in __opts__["vault"]["auth"]:
384            return True
385        headers = {"X-Vault-Token": __opts__["vault"]["auth"]["token"]}
386        if namespace is not None:
387            headers["X-Vault-Namespace"] = namespace
388        response = requests.get(url, headers=headers, verify=verify)
389        if response.status_code != 200:
390            return True
391        return False
392    except Exception as e:  # pylint: disable=broad-except
393        raise salt.exceptions.CommandExecutionError(
394            "Error while looking up self token : {}".format(e)
395        )
396
397
398def _wrapped_token_valid():
399    """
400    Validate the wrapped token exists and is still valid
401    """
402    try:
403        verify = __opts__["vault"].get("verify", None)
404        # Vault Enterprise requires a namespace
405        namespace = __opts__["vault"].get("namespace")
406        url = "{}/v1/sys/wrapping/lookup".format(__opts__["vault"]["url"])
407        if "token" not in __opts__["vault"]["auth"]:
408            return False
409        headers = {"X-Vault-Token": __opts__["vault"]["auth"]["token"]}
410        if namespace is not None:
411            headers["X-Vault-Namespace"] = namespace
412        response = requests.post(url, headers=headers, verify=verify)
413        if response.status_code != 200:
414            return False
415        return True
416    except Exception as e:  # pylint: disable=broad-except
417        raise salt.exceptions.CommandExecutionError(
418            "Error while looking up wrapped token : {}".format(e)
419        )
420
421
422def is_v2(path):
423    """
424    Determines if a given secret path is kv version 1 or 2
425
426    CLI Example:
427
428    .. code-block:: bash
429
430        salt '*' vault.is_v2 "secret/my/secret"
431    """
432    ret = {"v2": False, "data": path, "metadata": path, "delete": path, "type": None}
433    path_metadata = _get_secret_path_metadata(path)
434    if not path_metadata:
435        # metadata lookup failed. Simply return not v2
436        return ret
437    ret["type"] = path_metadata.get("type", "kv")
438    if (
439        ret["type"] == "kv"
440        and path_metadata["options"] is not None
441        and path_metadata.get("options", {}).get("version", "1") in ["2"]
442    ):
443        ret["v2"] = True
444        ret["data"] = _v2_the_path(path, path_metadata.get("path", path))
445        ret["metadata"] = _v2_the_path(
446            path, path_metadata.get("path", path), "metadata"
447        )
448        ret["destroy"] = _v2_the_path(path, path_metadata.get("path", path), "destroy")
449    return ret
450
451
452def _v2_the_path(path, pfilter, ptype="data"):
453    """
454    Given a path, a filter, and a path type, properly inject 'data' or 'metadata' into the path
455
456    CLI Example:
457
458    .. code-block:: python
459
460        _v2_the_path('dev/secrets/fu/bar', 'dev/secrets', 'data') => 'dev/secrets/data/fu/bar'
461    """
462    possible_types = ["data", "metadata", "destroy"]
463    assert ptype in possible_types
464    msg = (
465        "Path {} already contains {} in the right place - saltstack duct tape?".format(
466            path, ptype
467        )
468    )
469
470    path = path.rstrip("/").lstrip("/")
471    pfilter = pfilter.rstrip("/").lstrip("/")
472
473    together = pfilter + "/" + ptype
474
475    otype = possible_types[0] if possible_types[0] != ptype else possible_types[1]
476    other = pfilter + "/" + otype
477    if path.startswith(other):
478        path = path.replace(other, together, 1)
479        msg = 'Path is a "{}" type but "{}" type requested - Flipping: {}'.format(
480            otype, ptype, path
481        )
482    elif not path.startswith(together):
483        msg = "Converting path to v2 {} => {}".format(
484            path, path.replace(pfilter, together, 1)
485        )
486        path = path.replace(pfilter, together, 1)
487
488    log.debug(msg)
489    return path
490
491
492def _get_secret_path_metadata(path):
493    """
494    Given a path, query vault to determine mount point, type, and version
495
496    CLI Example:
497
498    .. code-block:: python
499
500        _get_secret_path_metadata('dev/secrets/fu/bar')
501    """
502    ckey = "vault_secret_path_metadata"
503
504    # Attempt to lookup from cache
505    if ckey in __context__:
506        cache_content = __context__[ckey]
507    else:
508        cache_content = _read_cache_file()
509    if ckey not in cache_content:
510        cache_content[ckey] = {}
511
512    ret = None
513    if path.startswith(tuple(cache_content[ckey].keys())):
514        log.debug("Found cached metadata for %s", path)
515        ret = next(v for k, v in cache_content[ckey].items() if path.startswith(k))
516    else:
517        log.debug("Fetching metadata for %s", path)
518        try:
519            url = "v1/sys/internal/ui/mounts/{}".format(path)
520            response = make_request("GET", url)
521            if response.ok:
522                response.raise_for_status()
523            if response.json().get("data", False):
524                log.debug("Got metadata for %s", path)
525                ret = response.json()["data"]
526                # Write metadata to cache file
527                # Check for new cache content from make_request
528                if "url" not in cache_content:
529                    if ckey in __context__:
530                        cache_content = __context__[ckey]
531                    else:
532                        cache_content = _read_cache_file()
533                    if ckey not in cache_content:
534                        cache_content[ckey] = {}
535                cache_content[ckey][path] = ret
536                write_cache(cache_content)
537            else:
538                raise response.json()
539        except Exception as err:  # pylint: disable=broad-except
540            log.error("Failed to get secret metadata %s: %s", type(err).__name__, err)
541    return ret
542