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