1# This file is part of Buildbot. Buildbot is free software: you can 2# redistribute it and/or modify it under the terms of the GNU General Public 3# License as published by the Free Software Foundation, version 2. 4# 5# This program is distributed in the hope that it will be useful, but WITHOUT 6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 7# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 8# details. 9# 10# You should have received a copy of the GNU General Public License along with 11# this program; if not, write to the Free Software Foundation, Inc., 51 12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 13# 14# Copyright Buildbot Team Members 15""" 16HVAC based providers 17""" 18 19from twisted.internet import defer 20from twisted.internet import threads 21 22from buildbot import config 23from buildbot.secrets.providers.base import SecretProviderBase 24 25 26class VaultAuthenticator: 27 """ 28 base HVAC authenticator class 29 """ 30 31 def authenticate(self, client): 32 pass 33 34 35class VaultAuthenticatorToken(VaultAuthenticator): 36 """ 37 HVAC authenticator for static token 38 """ 39 40 def __init__(self, token): 41 self.token = token 42 43 def authenticate(self, client): 44 client.token = self.token 45 46 47class VaultAuthenticatorApprole(VaultAuthenticator): 48 """ 49 HVAC authenticator for Approle login method 50 """ 51 52 def __init__(self, roleId, secretId): 53 self.roleId = roleId 54 self.secretId = secretId 55 56 def authenticate(self, client): 57 client.auth.approle.login(role_id=self.roleId, secret_id=self.secretId) 58 59 60class HashiCorpVaultKvSecretProvider(SecretProviderBase): 61 """ 62 Basic provider where each secret is stored in Vault KV secret engine. 63 In case more secret engines are going to be supported, each engine should have it's own class. 64 """ 65 66 name = 'SecretInVaultKv' 67 68 def checkConfig(self, vault_server=None, authenticator=None, secrets_mount=None, 69 api_version=2, path_delimiter='|', path_escape='\\'): 70 try: 71 import hvac 72 [hvac] 73 except ImportError: # pragma: no cover 74 config.error(f"{self.__class__.__name__} needs the hvac package installed " + 75 "(pip install hvac)") 76 77 if not isinstance(vault_server, str): 78 config.error("vault_server must be a string while it is {}".format(type(vault_server))) 79 if not isinstance(path_delimiter, str) or len(path_delimiter) > 1: 80 config.error("path_delimiter must be a single character") 81 if not isinstance(path_escape, str) or len(path_escape) > 1: 82 config.error("path_escape must be a single character") 83 if not isinstance(authenticator, VaultAuthenticator): 84 config.error("authenticator must be instance of VaultAuthenticator while it is {}" 85 .format(type(authenticator))) 86 if api_version not in [1, 2]: 87 config.error("api_version {} is not supported".format(api_version)) 88 89 def reconfigService(self, vault_server=None, authenticator=None, secrets_mount=None, 90 api_version=2, path_delimiter='|', path_escape='\\'): 91 try: 92 import hvac 93 except ImportError: # pragma: no cover 94 config.error(f"{self.__class__.__name__} needs the hvac package installed " + 95 "(pip install hvac)") 96 97 if secrets_mount is None: 98 secrets_mount = "secret" 99 self.secrets_mount = secrets_mount 100 self.path_delimiter = path_delimiter 101 self.path_escape = path_escape 102 self.authenticator = authenticator 103 self.api_version = api_version 104 if vault_server.endswith('/'): # pragma: no cover 105 vault_server = vault_server[:-1] 106 self.client = hvac.Client(vault_server) 107 self.client.secrets.kv.default_kv_version = api_version 108 return self 109 110 def escaped_split(self, s): 111 """ 112 parse and split string, respecting escape characters 113 """ 114 ret = [] 115 current = [] 116 itr = iter(s) 117 for ch in itr: 118 if ch == self.path_escape: 119 try: 120 # skip the next character; it has been escaped and remove 121 # escape character 122 current.append(next(itr)) 123 except StopIteration: 124 # escape character on end of the string is safest to ignore, as buildbot for 125 # each secret identifier tries all secret providers until value is found, 126 # meaning we may end up parsing identifiers for other secret providers, where 127 # our escape character may be valid on end of string 128 pass 129 elif ch == self.path_delimiter: 130 # split! (add current to the list and reset it) 131 ret.append(''.join(current)) 132 current = [] 133 else: 134 current.append(ch) 135 ret.append(''.join(current)) 136 return ret 137 138 def thd_hvac_wrap_read(self, path): 139 if self.api_version == 1: 140 return self.client.secrets.kv.v1.read_secret(path=path, mount_point=self.secrets_mount) 141 else: 142 return self.client.secrets.kv.v2.read_secret_version(path=path, 143 mount_point=self.secrets_mount) 144 145 def thd_hvac_get(self, path): 146 """ 147 query secret from Vault and try to re-authenticate in case Unauthorized 148 exception when active token reaches its TTL 149 """ 150 151 # no need to "try" import, it was already handled by reconfigService() 152 import hvac 153 154 try: 155 response = self.thd_hvac_wrap_read(path=path) 156 except (hvac.exceptions.Unauthorized, hvac.exceptions.InvalidRequest): 157 self.authenticator.authenticate(self.client) 158 response = self.thd_hvac_wrap_read(path=path) 159 160 return response 161 162 @defer.inlineCallbacks 163 def get(self, entry): 164 """ 165 get the value from vault secret backend 166 """ 167 168 parts = self.escaped_split(entry) 169 if len(parts) == 1: 170 raise KeyError("Vault secret specification must contain attribute name separated from " 171 "path by '{}'".format(self.path_delimiter)) 172 if len(parts) > 2: 173 raise KeyError("Multiple separators ('{0}') found in vault path '{1}'. All occurences " 174 "of '{0}' in path or attribute name must be escaped using '{2}'" 175 .format(self.path_delimiter, entry, self.path_escape)) 176 name = parts[0] 177 key = parts[1] 178 179 response = yield threads.deferToThread(self.thd_hvac_get, path=name) 180 181 # in KVv2 we have extra "data" dictionary, as vault provides metadata as well 182 if self.api_version == 2: 183 response = response['data'] 184 185 try: 186 return response['data'][key] 187 except KeyError as e: 188 raise KeyError( 189 "The secret {} does not exist in Vault provider: {}".format(entry, e)) from e 190