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