Merge tag '1.2.2' into debian/stein
castellan 1.2.2 release
meta:version: 1.2.2
meta:diff-start: -
meta:series: stein
meta:release-type: release
meta:pypi: no
meta:first: no
meta:release:Author: Ben Nemec <bnemec@redhat.com>
meta:release:Commit: Ben Nemec <bnemec@redhat.com>
meta:release:Change-Id: I4f3b9ea53f61b869bcc329ea18990795f40a4769
meta:release:Code-Review+2: Sean McGinnis <sean.mcginnis@gmail.com>
meta:release:Workflow+1: Sean McGinnis <sean.mcginnis@gmail.com>
Thomas Goirand
4 years ago
24 | 24 | # Unit test / coverage reports |
25 | 25 | .coverage |
26 | 26 | .tox |
27 | nosetests.xml | |
28 | .testrepository | |
27 | .stestr/ | |
29 | 28 | .venv |
30 | 29 | cover |
30 | vault_* | |
31 | 31 | |
32 | 32 | # Translations |
33 | 33 | *.mo |
0 | [DEFAULT] | |
1 | test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \ | |
2 | OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} \ | |
3 | OS_TEST_TIMEOUT=${OS_TEST_TIMEOUT:-60} \ | |
4 | ${PYTHON:-python} -m subunit.run discover -t ./ ${OS_TEST_PATH:-./castellan/tests} $LISTOPT $IDOPTION | |
5 | test_id_option=--load-list $IDFILE | |
6 | test_list_option=--list |
50 | 50 | jobs: |
51 | 51 | - castellan-functional-vault |
52 | 52 | - castellan-functional-devstack |
53 | - openstack-tox-lower-constraints | |
54 | 53 | - barbican-simple-crypto-devstack-tempest-castellan-from-git |
55 | 54 | gate: |
56 | 55 | jobs: |
57 | 56 | - castellan-functional-vault |
58 | 57 | - castellan-functional-devstack |
59 | - openstack-tox-lower-constraints | |
60 | 58 | - barbican-simple-crypto-devstack-tempest-castellan-from-git |
61 | 59 | templates: |
60 | - check-requirements | |
61 | - openstack-lower-constraints-jobs | |
62 | 62 | - openstack-python-jobs |
63 | - openstack-python35-jobs | |
63 | - openstack-python36-jobs | |
64 | - openstack-python37-jobs | |
65 | - periodic-stable-jobs | |
66 | - publish-openstack-docs-pti | |
64 | 67 | - release-notes-jobs-python3 |
65 | - publish-openstack-docs-pti | |
66 | - check-requirements | |
67 | - periodic-stable-jobs |
7 | 7 | * Documentation: https://docs.openstack.org/castellan/latest |
8 | 8 | * Source: https://git.openstack.org/cgit/openstack/castellan |
9 | 9 | * Bugs: https://bugs.launchpad.net/castellan |
10 | * Release notes: https://docs.openstack.org/releasenotes/castellan | |
10 | 11 | |
11 | 12 | Team and repository tags |
12 | 13 | ======================== |
0 | # Licensed under the Apache License, Version 2.0 (the "License"); you may | |
1 | # not use this file except in compliance with the License. You may obtain | |
2 | # a copy of the License at | |
3 | # | |
4 | # http://www.apache.org/licenses/LICENSE-2.0 | |
5 | # | |
6 | # Unless required by applicable law or agreed to in writing, software | |
7 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |
8 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |
9 | # License for the specific language governing permissions and limitations | |
10 | # under the License. | |
11 | ||
12 | r""" | |
13 | Castellan Oslo Config Driver | |
14 | ---------------------------- | |
15 | ||
16 | This driver is an oslo.config backend driver implemented with Castellan. It | |
17 | extends oslo.config's capabilities by enabling it to retrieve configuration | |
18 | values from a secret manager behind Castellan. | |
19 | ||
20 | The setup of a Castellan configuration source is as follow:: | |
21 | ||
22 | [DEFAULT] | |
23 | config_source = castellan_config_group | |
24 | ||
25 | [castellan_config_group] | |
26 | driver = castellan | |
27 | config_file = castellan.conf | |
28 | mapping_file = mapping.conf | |
29 | ||
30 | In the following sessions, you can find more information about this driver's | |
31 | classes and its options. | |
32 | ||
33 | The Driver Class | |
34 | ================ | |
35 | ||
36 | .. autoclass:: CastellanConfigurationSourceDriver | |
37 | ||
38 | The Configuration Source Class | |
39 | ============================== | |
40 | ||
41 | .. autoclass:: CastellanConfigurationSource | |
42 | ||
43 | """ | |
44 | from castellan.common.exception import KeyManagerError | |
45 | from castellan.common.exception import ManagedObjectNotFoundError | |
46 | from castellan import key_manager | |
47 | ||
48 | from oslo_config import cfg | |
49 | from oslo_config import sources | |
50 | from oslo_log import log | |
51 | ||
52 | LOG = log.getLogger(__name__) | |
53 | ||
54 | ||
55 | class CastellanConfigurationSourceDriver(sources.ConfigurationSourceDriver): | |
56 | """A backend driver for configuration values served through castellan. | |
57 | ||
58 | Required options: | |
59 | - config_file: The castellan configuration file. | |
60 | ||
61 | - mapping_file: A configuration/castellan_id mapping file. This file | |
62 | creates connections between configuration options and | |
63 | castellan ids. The group and option name remains the | |
64 | same, while the value gets stored a secret manager behind | |
65 | castellan and is replaced by its castellan id. The ids | |
66 | will be used to fetch the values through castellan. | |
67 | """ | |
68 | ||
69 | _castellan_driver_opts = [ | |
70 | cfg.StrOpt( | |
71 | 'config_file', | |
72 | required=True, | |
73 | sample_default='etc/castellan/castellan.conf', | |
74 | help=('The path to a castellan configuration file.'), | |
75 | ), | |
76 | cfg.StrOpt( | |
77 | 'mapping_file', | |
78 | required=True, | |
79 | sample_default='etc/castellan/secrets_mapping.conf', | |
80 | help=('The path to a configuration/castellan_id mapping file.'), | |
81 | ), | |
82 | ] | |
83 | ||
84 | def list_options_for_discovery(self): | |
85 | return self._castellan_driver_opts | |
86 | ||
87 | def open_source_from_opt_group(self, conf, group_name): | |
88 | conf.register_opts(self._castellan_driver_opts, group_name) | |
89 | ||
90 | return CastellanConfigurationSource( | |
91 | group_name, | |
92 | conf[group_name].config_file, | |
93 | conf[group_name].mapping_file) | |
94 | ||
95 | ||
96 | class CastellanConfigurationSource(sources.ConfigurationSource): | |
97 | """A configuration source for configuration values served through castellan. | |
98 | ||
99 | :param config_file: The path to a castellan configuration file. | |
100 | ||
101 | :param mapping_file: The path to a configuration/castellan_id mapping file. | |
102 | """ | |
103 | ||
104 | def __init__(self, group_name, config_file, mapping_file): | |
105 | conf = cfg.ConfigOpts() | |
106 | conf(args=[], default_config_files=[config_file]) | |
107 | ||
108 | self._name = group_name | |
109 | self._mngr = key_manager.API(conf) | |
110 | self._mapping = {} | |
111 | ||
112 | cfg.ConfigParser(mapping_file, self._mapping).parse() | |
113 | ||
114 | def get(self, group_name, option_name, opt): | |
115 | try: | |
116 | group_name = group_name or "DEFAULT" | |
117 | ||
118 | castellan_id = self._mapping[group_name][option_name][0] | |
119 | ||
120 | return (self._mngr.get("ctx", castellan_id).get_encoded().decode(), | |
121 | cfg.LocationInfo(cfg.Locations.user, castellan_id)) | |
122 | ||
123 | except KeyError: | |
124 | # no mapping 'option = castellan_id' | |
125 | LOG.info("option '[%s] %s' not present in '[%s] mapping_file'", | |
126 | group_name, option_name, self._name) | |
127 | ||
128 | except KeyManagerError: | |
129 | # bad mapping 'option =' without a castellan_id | |
130 | LOG.warning("missing castellan_id for option " | |
131 | "'[%s] %s' in '[%s] mapping_file'", | |
132 | group_name, option_name, self._name) | |
133 | ||
134 | except ManagedObjectNotFoundError: | |
135 | # good mapping, but unknown castellan_id by secret manager | |
136 | LOG.warning("invalid castellan_id for option " | |
137 | "'[%s] %s' in '[%s] mapping_file'", | |
138 | group_name, option_name, self._name) | |
139 | ||
140 | return (sources._NoValue, None) |
28 | 28 | from keystoneauth1 import loading |
29 | 29 | from oslo_config import cfg |
30 | 30 | from oslo_log import log as logging |
31 | from oslo_utils import timeutils | |
31 | 32 | import requests |
32 | 33 | import six |
33 | 34 | |
42 | 43 | from castellan.key_manager import key_manager |
43 | 44 | |
44 | 45 | DEFAULT_VAULT_URL = "http://127.0.0.1:8200" |
46 | DEFAULT_MOUNTPOINT = "secret" | |
45 | 47 | |
46 | 48 | vault_opts = [ |
47 | 49 | cfg.StrOpt('root_token_id', |
48 | 50 | help='root token for vault'), |
51 | cfg.StrOpt('approle_role_id', | |
52 | help='AppRole role_id for authentication with vault'), | |
53 | cfg.StrOpt('approle_secret_id', | |
54 | help='AppRole secret_id for authentication with vault'), | |
55 | cfg.StrOpt('kv_mountpoint', | |
56 | default=DEFAULT_MOUNTPOINT, | |
57 | help='Mountpoint of KV store in Vault to use, for example: ' | |
58 | '{}'.format(DEFAULT_MOUNTPOINT)), | |
49 | 59 | cfg.StrOpt('vault_url', |
50 | 60 | default=DEFAULT_VAULT_URL, |
51 | 61 | help='Use this endpoint to connect to Vault, for example: ' |
87 | 97 | loading.register_session_conf_options(self._conf, VAULT_OPT_GROUP) |
88 | 98 | self._session = requests.Session() |
89 | 99 | self._root_token_id = self._conf.vault.root_token_id |
100 | self._approle_role_id = self._conf.vault.approle_role_id | |
101 | self._approle_secret_id = self._conf.vault.approle_secret_id | |
102 | self._cached_approle_token_id = None | |
103 | self._approle_token_ttl = None | |
104 | self._approle_token_issue = None | |
105 | self._kv_mountpoint = self._conf.vault.kv_mountpoint | |
90 | 106 | self._vault_url = self._conf.vault.vault_url |
91 | 107 | if self._vault_url.startswith("https://"): |
92 | 108 | self._verify_server = self._conf.vault.ssl_ca_crt_file or True |
93 | 109 | else: |
94 | 110 | self._verify_server = False |
111 | self._vault_kv_version = None | |
95 | 112 | |
96 | 113 | def _get_url(self): |
97 | 114 | if not self._vault_url.endswith('/'): |
98 | 115 | self._vault_url += '/' |
99 | 116 | return self._vault_url |
117 | ||
118 | def _get_api_version(self): | |
119 | if self._vault_kv_version: | |
120 | return self._vault_kv_version | |
121 | ||
122 | resource_url = '{}v1/sys/internal/ui/mounts/{}'.format( | |
123 | self._get_url(), | |
124 | self._kv_mountpoint | |
125 | ) | |
126 | resp = self._do_http_request(self._session.get, resource_url) | |
127 | ||
128 | if resp.status_code == requests.codes['not_found']: | |
129 | self._vault_kv_version = '1' | |
130 | else: | |
131 | self._vault_kv_version = resp.json()['data']['options']['version'] | |
132 | ||
133 | return self._vault_kv_version | |
134 | ||
135 | def _get_resource_url(self, key_id=None): | |
136 | return '{}v1/{}/{}{}'.format( | |
137 | self._get_url(), | |
138 | self._kv_mountpoint, | |
139 | ||
140 | '' if self._get_api_version() == '1' else | |
141 | 'data/' if key_id else | |
142 | 'metadata/', # no key_id is for listing and 'data/' doesn't works | |
143 | ||
144 | key_id if key_id else '?list=true') | |
145 | ||
146 | @property | |
147 | def _approle_token_id(self): | |
148 | if (all((self._approle_token_issue, self._approle_token_ttl)) and | |
149 | timeutils.is_older_than(self._approle_token_issue, | |
150 | self._approle_token_ttl)): | |
151 | self._cached_approle_token_id = None | |
152 | return self._cached_approle_token_id | |
153 | ||
154 | def _build_auth_headers(self): | |
155 | if self._root_token_id: | |
156 | return {'X-Vault-Token': self._root_token_id} | |
157 | ||
158 | if self._approle_token_id: | |
159 | return {'X-Vault-Token': self._approle_token_id} | |
160 | ||
161 | if self._approle_role_id: | |
162 | params = { | |
163 | 'role_id': self._approle_role_id | |
164 | } | |
165 | if self._approle_secret_id: | |
166 | params['secret_id'] = self._approle_secret_id | |
167 | approle_login_url = '{}v1/auth/approle/login'.format( | |
168 | self._get_url() | |
169 | ) | |
170 | token_issue_utc = timeutils.utcnow() | |
171 | try: | |
172 | resp = self._session.post(url=approle_login_url, | |
173 | json=params, | |
174 | verify=self._verify_server) | |
175 | except requests.exceptions.Timeout as ex: | |
176 | raise exception.KeyManagerError(six.text_type(ex)) | |
177 | except requests.exceptions.ConnectionError as ex: | |
178 | raise exception.KeyManagerError(six.text_type(ex)) | |
179 | except Exception as ex: | |
180 | raise exception.KeyManagerError(six.text_type(ex)) | |
181 | ||
182 | if resp.status_code in _EXCEPTIONS_BY_CODE: | |
183 | raise exception.KeyManagerError(resp.reason) | |
184 | if resp.status_code == requests.codes['forbidden']: | |
185 | raise exception.Forbidden() | |
186 | ||
187 | resp = resp.json() | |
188 | self._cached_approle_token_id = resp['auth']['client_token'] | |
189 | self._approle_token_issue = token_issue_utc | |
190 | self._approle_token_ttl = resp['auth']['lease_duration'] | |
191 | return {'X-Vault-Token': self._approle_token_id} | |
192 | ||
193 | return {} | |
194 | ||
195 | def _do_http_request(self, method, resource, json=None): | |
196 | verify = self._verify_server | |
197 | headers = self._build_auth_headers() | |
198 | ||
199 | try: | |
200 | resp = method(resource, headers=headers, json=json, verify=verify) | |
201 | except requests.exceptions.Timeout as ex: | |
202 | raise exception.KeyManagerError(six.text_type(ex)) | |
203 | except requests.exceptions.ConnectionError as ex: | |
204 | raise exception.KeyManagerError(six.text_type(ex)) | |
205 | except Exception as ex: | |
206 | raise exception.KeyManagerError(six.text_type(ex)) | |
207 | ||
208 | if resp.status_code in _EXCEPTIONS_BY_CODE: | |
209 | raise exception.KeyManagerError(resp.reason) | |
210 | if resp.status_code == requests.codes['forbidden']: | |
211 | raise exception.Forbidden() | |
212 | ||
213 | return resp | |
100 | 214 | |
101 | 215 | def create_key_pair(self, context, algorithm, length, |
102 | 216 | expiration=None, name=None): |
156 | 270 | raise exception.KeyManagerError( |
157 | 271 | "Unknown type for value : %r" % value) |
158 | 272 | |
159 | headers = {'X-Vault-Token': self._root_token_id} | |
160 | try: | |
161 | resource_url = self._get_url() + 'v1/secret/' + key_id | |
162 | record = { | |
163 | 'type': type_value, | |
164 | 'value': binascii.hexlify(value.get_encoded()).decode('utf-8'), | |
165 | 'algorithm': (value.algorithm if hasattr(value, 'algorithm') | |
166 | else None), | |
167 | 'bit_length': (value.bit_length if hasattr(value, 'bit_length') | |
168 | else None), | |
169 | 'name': value.name, | |
170 | 'created': value.created | |
171 | } | |
172 | resp = self._session.post(resource_url, | |
173 | verify=self._verify_server, | |
174 | json=record, | |
175 | headers=headers) | |
176 | except requests.exceptions.Timeout as ex: | |
177 | raise exception.KeyManagerError(six.text_type(ex)) | |
178 | except requests.exceptions.ConnectionError as ex: | |
179 | raise exception.KeyManagerError(six.text_type(ex)) | |
180 | except Exception as ex: | |
181 | raise exception.KeyManagerError(six.text_type(ex)) | |
182 | ||
183 | if resp.status_code in _EXCEPTIONS_BY_CODE: | |
184 | raise exception.KeyManagerError(resp.reason) | |
185 | if resp.status_code == requests.codes['forbidden']: | |
186 | raise exception.Forbidden() | |
273 | record = { | |
274 | 'type': type_value, | |
275 | 'value': binascii.hexlify(value.get_encoded()).decode('utf-8'), | |
276 | 'algorithm': (value.algorithm if hasattr(value, 'algorithm') | |
277 | else None), | |
278 | 'bit_length': (value.bit_length if hasattr(value, 'bit_length') | |
279 | else None), | |
280 | 'name': value.name, | |
281 | 'created': value.created | |
282 | } | |
283 | if self._get_api_version() != '1': | |
284 | record = {'data': record} | |
285 | ||
286 | self._do_http_request(self._session.post, | |
287 | self._get_resource_url(key_id), | |
288 | json=record) | |
187 | 289 | |
188 | 290 | return key_id |
189 | 291 | |
195 | 297 | msg = _("User is not authorized to use key manager.") |
196 | 298 | raise exception.Forbidden(msg) |
197 | 299 | |
300 | if length % 8: | |
301 | msg = _("Length must be multiple of 8.") | |
302 | raise ValueError(msg) | |
303 | ||
198 | 304 | key_id = uuid.uuid4().hex |
199 | key_value = os.urandom(length or 32) | |
305 | key_value = os.urandom((length or 256) // 8) | |
200 | 306 | key = sym_key.SymmetricKey(algorithm, |
201 | length or 32, | |
307 | length or 256, | |
202 | 308 | key_value, |
203 | 309 | key_id, |
204 | 310 | name or int(time.time())) |
311 | ||
205 | 312 | return self._store_key_value(key_id, key) |
206 | 313 | |
207 | 314 | def store(self, context, key_value, **kwargs): |
226 | 333 | if not key_id: |
227 | 334 | raise exception.KeyManagerError('key identifier not provided') |
228 | 335 | |
229 | headers = {'X-Vault-Token': self._root_token_id} | |
230 | try: | |
231 | resource_url = self._get_url() + 'v1/secret/' + key_id | |
232 | resp = self._session.get(resource_url, | |
233 | verify=self._verify_server, | |
234 | headers=headers) | |
235 | except requests.exceptions.Timeout as ex: | |
236 | raise exception.KeyManagerError(six.text_type(ex)) | |
237 | except requests.exceptions.ConnectionError as ex: | |
238 | raise exception.KeyManagerError(six.text_type(ex)) | |
239 | except Exception as ex: | |
240 | raise exception.KeyManagerError(six.text_type(ex)) | |
241 | ||
242 | if resp.status_code in _EXCEPTIONS_BY_CODE: | |
243 | raise exception.KeyManagerError(resp.reason) | |
244 | if resp.status_code == requests.codes['forbidden']: | |
245 | raise exception.Forbidden() | |
336 | resp = self._do_http_request(self._session.get, | |
337 | self._get_resource_url(key_id)) | |
338 | ||
246 | 339 | if resp.status_code == requests.codes['not_found']: |
247 | 340 | raise exception.ManagedObjectNotFoundError(uuid=key_id) |
248 | 341 | |
249 | 342 | record = resp.json()['data'] |
343 | if self._get_api_version() != '1': | |
344 | record = record['data'] | |
345 | ||
250 | 346 | key = None if metadata_only else binascii.unhexlify(record['value']) |
251 | 347 | |
252 | 348 | clazz = None |
282 | 378 | if not key_id: |
283 | 379 | raise exception.KeyManagerError('key identifier not provided') |
284 | 380 | |
285 | headers = {'X-Vault-Token': self._root_token_id} | |
286 | try: | |
287 | resource_url = self._get_url() + 'v1/secret/' + key_id | |
288 | resp = self._session.delete(resource_url, | |
289 | verify=self._verify_server, | |
290 | headers=headers) | |
291 | except requests.exceptions.Timeout as ex: | |
292 | raise exception.KeyManagerError(six.text_type(ex)) | |
293 | except requests.exceptions.ConnectionError as ex: | |
294 | raise exception.KeyManagerError(six.text_type(ex)) | |
295 | except Exception as ex: | |
296 | raise exception.KeyManagerError(six.text_type(ex)) | |
297 | ||
298 | if resp.status_code in _EXCEPTIONS_BY_CODE: | |
299 | raise exception.KeyManagerError(resp.reason) | |
300 | if resp.status_code == requests.codes['forbidden']: | |
301 | raise exception.Forbidden() | |
381 | resp = self._do_http_request(self._session.delete, | |
382 | self._get_resource_url(key_id)) | |
383 | ||
302 | 384 | if resp.status_code == requests.codes['not_found']: |
303 | 385 | raise exception.ManagedObjectNotFoundError(uuid=key_id) |
304 | 386 | |
314 | 396 | msg = _("Invalid secret type: %s") % object_type |
315 | 397 | raise exception.KeyManagerError(reason=msg) |
316 | 398 | |
317 | headers = {'X-Vault-Token': self._root_token_id} | |
318 | try: | |
319 | resource_url = self._get_url() + 'v1/secret/?list=true' | |
320 | resp = self._session.get(resource_url, | |
321 | verify=self._verify_server, | |
322 | headers=headers) | |
323 | keys = resp.json()['data']['keys'] | |
324 | except requests.exceptions.Timeout as ex: | |
325 | raise exception.KeyManagerError(six.text_type(ex)) | |
326 | except requests.exceptions.ConnectionError as ex: | |
327 | raise exception.KeyManagerError(six.text_type(ex)) | |
328 | except Exception as ex: | |
329 | raise exception.KeyManagerError(six.text_type(ex)) | |
330 | ||
331 | if resp.status_code in _EXCEPTIONS_BY_CODE: | |
332 | raise exception.KeyManagerError(resp.reason) | |
333 | if resp.status_code == requests.codes['forbidden']: | |
334 | raise exception.Forbidden() | |
399 | resp = self._do_http_request(self._session.get, | |
400 | self._get_resource_url()) | |
401 | ||
335 | 402 | if resp.status_code == requests.codes['not_found']: |
336 | 403 | keys = [] |
404 | else: | |
405 | keys = resp.json()['data']['keys'] | |
337 | 406 | |
338 | 407 | objects = [] |
339 | 408 | for obj_id in keys: |
38 | 38 | def set_defaults(conf, backend=None, barbican_endpoint=None, |
39 | 39 | barbican_api_version=None, auth_endpoint=None, |
40 | 40 | retry_delay=None, number_of_retries=None, verify_ssl=None, |
41 | api_class=None, vault_root_token_id=None, vault_url=None, | |
41 | api_class=None, vault_root_token_id=None, | |
42 | vault_approle_role_id=None, vault_approle_secret_id=None, | |
43 | vault_kv_mountpoint=None, vault_url=None, | |
42 | 44 | vault_ssl_ca_crt_file=None, vault_use_ssl=None, |
43 | 45 | barbican_endpoint_type=None): |
44 | 46 | """Set defaults for configuration values. |
53 | 55 | :param number_of_retries: Use this attribute to set number of retries. |
54 | 56 | :param verify_ssl: Use this to specify if ssl should be verified. |
55 | 57 | :param vault_root_token_id: Use this for the root token id for vault. |
58 | :param vault_approle_role_id: Use this for the approle role_id for vault. | |
59 | :param vault_approle_secret_id: Use this for the approle secret_id | |
60 | for vault. | |
61 | :param vault_kv_mountpoint: Mountpoint of KV store in vault to use. | |
56 | 62 | :param vault_url: Use this for the url for vault. |
57 | 63 | :param vault_use_ssl: Use this to force vault driver to use ssl. |
58 | 64 | :param vault_ssl_ca_crt_file: Use this for the CA file for vault. |
96 | 102 | if vkm is not None: |
97 | 103 | if vault_root_token_id is not None: |
98 | 104 | conf.set_default('root_token_id', vault_root_token_id, |
105 | group=vkm.VAULT_OPT_GROUP) | |
106 | if vault_approle_role_id is not None: | |
107 | conf.set_default('approle_role_id', vault_approle_role_id, | |
108 | group=vkm.VAULT_OPT_GROUP) | |
109 | if vault_approle_secret_id is not None: | |
110 | conf.set_default('approle_secret_id', vault_approle_secret_id, | |
111 | group=vkm.VAULT_OPT_GROUP) | |
112 | if vault_kv_mountpoint is not None: | |
113 | conf.set_default('kv_mountpoint', vault_kv_mountpoint, | |
99 | 114 | group=vkm.VAULT_OPT_GROUP) |
100 | 115 | if vault_url is not None: |
101 | 116 | conf.set_default('vault_url', vault_url, |
16 | 16 | """ |
17 | 17 | import abc |
18 | 18 | import os |
19 | import uuid | |
19 | 20 | |
20 | 21 | from oslo_config import cfg |
21 | 22 | from oslo_context import context |
22 | 23 | from oslo_utils import uuidutils |
23 | 24 | from oslotest import base |
25 | import requests | |
24 | 26 | from testtools import testcase |
25 | 27 | |
26 | 28 | from castellan.common import exception |
108 | 110 | base.BaseTestCase): |
109 | 111 | def get_context(self): |
110 | 112 | return context.get_admin_context() |
113 | ||
114 | ||
115 | TEST_POLICY = ''' | |
116 | path "{backend}/*" {{ | |
117 | capabilities = ["create", "read", "update", "delete", "list"] | |
118 | }} | |
119 | ||
120 | path "sys/internal/ui/mounts/{backend}" {{ | |
121 | capabilities = ["read"] | |
122 | }} | |
123 | ''' | |
124 | ||
125 | AUTH_ENDPOINT = 'v1/sys/auth/{auth_type}' | |
126 | POLICY_ENDPOINT = 'v1/sys/policy/{policy_name}' | |
127 | APPROLE_ENDPOINT = 'v1/auth/approle/role/{role_name}' | |
128 | ||
129 | ||
130 | class VaultKeyManagerAppRoleTestCase(VaultKeyManagerOSLOContextTestCase): | |
131 | ||
132 | mountpoint = 'secret' | |
133 | ||
134 | def _create_key_manager(self): | |
135 | key_mgr = vault_key_manager.VaultKeyManager(cfg.CONF) | |
136 | ||
137 | if ('VAULT_TEST_URL' not in os.environ or | |
138 | 'VAULT_TEST_ROOT_TOKEN' not in os.environ): | |
139 | raise testcase.TestSkipped('Missing Vault setup information') | |
140 | ||
141 | self.root_token_id = os.environ['VAULT_TEST_ROOT_TOKEN'] | |
142 | self.vault_url = os.environ['VAULT_TEST_URL'] | |
143 | ||
144 | test_uuid = str(uuid.uuid4()) | |
145 | vault_policy = 'policy-{}'.format(test_uuid) | |
146 | vault_approle = 'approle-{}'.format(test_uuid) | |
147 | ||
148 | self.session = requests.Session() | |
149 | self.session.headers.update({'X-Vault-Token': self.root_token_id}) | |
150 | ||
151 | self._mount_kv(self.mountpoint) | |
152 | self._enable_approle() | |
153 | self._create_policy(vault_policy) | |
154 | self._create_approle(vault_approle, vault_policy) | |
155 | ||
156 | key_mgr._approle_role_id, key_mgr._approle_secret_id = ( | |
157 | self._retrieve_approle(vault_approle) | |
158 | ) | |
159 | key_mgr._kv_mountpoint = self.mountpoint | |
160 | key_mgr._vault_url = self.vault_url | |
161 | return key_mgr | |
162 | ||
163 | def _mount_kv(self, vault_mountpoint): | |
164 | backends = self.session.get( | |
165 | '{}/v1/sys/mounts'.format(self.vault_url)).json() | |
166 | if vault_mountpoint not in backends: | |
167 | params = { | |
168 | 'type': 'kv', | |
169 | 'options': { | |
170 | 'version': 2, | |
171 | } | |
172 | } | |
173 | self.session.post( | |
174 | '{}/v1/sys/mounts/{}'.format(self.vault_url, | |
175 | vault_mountpoint), | |
176 | json=params) | |
177 | ||
178 | def _enable_approle(self): | |
179 | params = { | |
180 | 'type': 'approle' | |
181 | } | |
182 | self.session.post( | |
183 | '{}/{}'.format( | |
184 | self.vault_url, | |
185 | AUTH_ENDPOINT.format(auth_type='approle') | |
186 | ), | |
187 | json=params, | |
188 | ) | |
189 | ||
190 | def _create_policy(self, vault_policy): | |
191 | params = { | |
192 | 'rules': TEST_POLICY.format(backend=self.mountpoint), | |
193 | } | |
194 | self.session.put( | |
195 | '{}/{}'.format( | |
196 | self.vault_url, | |
197 | POLICY_ENDPOINT.format(policy_name=vault_policy) | |
198 | ), | |
199 | json=params, | |
200 | ) | |
201 | ||
202 | def _create_approle(self, vault_approle, vault_policy): | |
203 | params = { | |
204 | 'token_ttl': '60s', | |
205 | 'token_max_ttl': '60s', | |
206 | 'policies': [vault_policy], | |
207 | 'bind_secret_id': 'true', | |
208 | 'bound_cidr_list': '127.0.0.1/32' | |
209 | } | |
210 | self.session.post( | |
211 | '{}/{}'.format( | |
212 | self.vault_url, | |
213 | APPROLE_ENDPOINT.format(role_name=vault_approle) | |
214 | ), | |
215 | json=params, | |
216 | ) | |
217 | ||
218 | def _retrieve_approle(self, vault_approle): | |
219 | approle_role_id = ( | |
220 | self.session.get( | |
221 | '{}/v1/auth/approle/role/{}/role-id'.format( | |
222 | self.vault_url, | |
223 | vault_approle | |
224 | )).json()['data']['role_id'] | |
225 | ) | |
226 | approle_secret_id = ( | |
227 | self.session.post( | |
228 | '{}/v1/auth/approle/role/{}/secret-id'.format( | |
229 | self.vault_url, | |
230 | vault_approle | |
231 | )).json()['data']['secret_id'] | |
232 | ) | |
233 | return (approle_role_id, approle_secret_id) | |
234 | ||
235 | ||
236 | class VaultKeyManagerAltMountpointTestCase(VaultKeyManagerAppRoleTestCase): | |
237 | ||
238 | mountpoint = 'different-secrets' |
0 | # Licensed under the Apache License, Version 2.0 (the "License"); you may | |
1 | # not use this file except in compliance with the License. You may obtain | |
2 | # a copy of the License at | |
3 | # | |
4 | # http://www.apache.org/licenses/LICENSE-2.0 | |
5 | # | |
6 | # Unless required by applicable law or agreed to in writing, software | |
7 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |
8 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |
9 | # License for the specific language governing permissions and limitations | |
10 | # under the License. | |
11 | ||
12 | """ | |
13 | Functional test cases for the Castellan Oslo Config Driver. | |
14 | ||
15 | Note: This requires local running instance of Vault. | |
16 | """ | |
17 | import tempfile | |
18 | ||
19 | from oslo_config import cfg | |
20 | from oslo_config import fixture | |
21 | ||
22 | from oslotest import base | |
23 | ||
24 | from castellan import _config_driver | |
25 | from castellan.common.objects import opaque_data | |
26 | from castellan.tests.unit.key_manager import fake | |
27 | ||
28 | ||
29 | class CastellanSourceTestCase(base.BaseTestCase): | |
30 | ||
31 | def setUp(self): | |
32 | super(CastellanSourceTestCase, self).setUp() | |
33 | self.driver = _config_driver.CastellanConfigurationSourceDriver() | |
34 | self.conf = cfg.ConfigOpts() | |
35 | self.conf_fixture = self.useFixture(fixture.Config(self.conf)) | |
36 | ||
37 | def test_incomplete_driver(self): | |
38 | # The group exists, but does not specify the | |
39 | # required options for this driver. | |
40 | self.conf_fixture.load_raw_values( | |
41 | group='incomplete_driver', | |
42 | driver='castellan', | |
43 | ) | |
44 | source = self.conf._open_source_from_opt_group('incomplete_driver') | |
45 | ||
46 | self.assertIsNone(source) | |
47 | self.assertEqual(self.conf.incomplete_driver.driver, 'castellan') | |
48 | ||
49 | def test_complete_driver(self): | |
50 | self.conf_fixture.load_raw_values( | |
51 | group='castellan_source', | |
52 | driver='castellan', | |
53 | config_file='config.conf', | |
54 | mapping_file='mapping.conf', | |
55 | ) | |
56 | ||
57 | with base.mock.patch.object( | |
58 | _config_driver, | |
59 | 'CastellanConfigurationSource') as source_class: | |
60 | self.driver.open_source_from_opt_group( | |
61 | self.conf, 'castellan_source') | |
62 | ||
63 | source_class.assert_called_once_with( | |
64 | 'castellan_source', | |
65 | self.conf.castellan_source.config_file, | |
66 | self.conf.castellan_source.mapping_file) | |
67 | ||
68 | def test_fetch_secret(self): | |
69 | # fake KeyManager populated with secret | |
70 | km = fake.fake_api() | |
71 | secret_id = km.store("fake_context", | |
72 | opaque_data.OpaqueData(b"super_secret!")) | |
73 | ||
74 | # driver config | |
75 | config = "[key_manager]\nbackend=vault" | |
76 | mapping = "[DEFAULT]\nmy_secret=" + secret_id | |
77 | ||
78 | # creating temp files | |
79 | with tempfile.NamedTemporaryFile() as config_file: | |
80 | config_file.write(config.encode("utf-8")) | |
81 | config_file.flush() | |
82 | ||
83 | with tempfile.NamedTemporaryFile() as mapping_file: | |
84 | mapping_file.write(mapping.encode("utf-8")) | |
85 | mapping_file.flush() | |
86 | ||
87 | self.conf_fixture.load_raw_values( | |
88 | group='castellan_source', | |
89 | driver='castellan', | |
90 | config_file=config_file.name, | |
91 | mapping_file=mapping_file.name, | |
92 | ) | |
93 | ||
94 | source = self.driver.open_source_from_opt_group( | |
95 | self.conf, | |
96 | 'castellan_source') | |
97 | ||
98 | # replacing key_manager with fake one | |
99 | source._mngr = km | |
100 | ||
101 | # testing if the source is able to retrieve | |
102 | # the secret value stored in the key_manager | |
103 | # using the secret_id from the mapping file | |
104 | self.assertEqual("super_secret!", | |
105 | source.get("DEFAULT", | |
106 | "my_secret", | |
107 | cfg.StrOpt(""))[0]) |
32 | 32 | netifaces==0.10.4 |
33 | 33 | openstackdocstheme==1.18.1 |
34 | 34 | os-client-config==1.28.0 |
35 | oslo.config==5.2.0 | |
35 | oslo.config==6.4.0 | |
36 | 36 | oslo.context==2.19.2 |
37 | 37 | oslo.i18n==3.15.3 |
38 | 38 | oslo.log==3.36.0 |
65 | 65 | Sphinx==1.6.2 |
66 | 66 | sphinxcontrib-websupport==1.0.1 |
67 | 67 | stevedore==1.20.0 |
68 | testrepository==0.0.18 | |
68 | stestr==2.0.0 | |
69 | 69 | testscenarios==0.4 |
70 | 70 | testtools==2.2.0 |
71 | 71 | traceback2==1.4.0 |
0 | --- | |
1 | fixes: | |
2 | - | | |
3 | Fixed VaultKeyManager.create_key() to consider the `length` param as bits | |
4 | instead of bytes for the key length. This was causing a discrepancy between | |
5 | keys generated by the HashiCorp Vault backend and the OpenStack Barbican | |
6 | backend. Considering `km` as an instance of a key manager, the following | |
7 | code `km.create_key(ctx, "AES", 256)` was generating a 256 bit AES key when | |
8 | Barbican is configured as the backend, but generating a 2048 bit AES key | |
9 | when Vault was configured as the backend. |
0 | --- | |
1 | features: | |
2 | - | | |
3 | Added support for AppRole based authentication to the Vault | |
4 | key manager configured using new approle_role_id and | |
5 | optional approle_secret_id options. | |
6 | (https://www.vaultproject.io/docs/auth/approle.html) |
0 | --- | |
1 | features: | |
2 | - | | |
3 | Added configuration option to the Vault key manager to allow | |
4 | the KV store mountpoint in Vault to be specified; the existing | |
5 | default of 'secret' is maintained. |
0 | =================================== | |
1 | Rocky Series Release Notes | |
2 | =================================== | |
3 | ||
4 | .. release-notes:: | |
5 | :branch: stable/rocky |
5 | 5 | Babel!=2.4.0,>=2.3.4 # BSD |
6 | 6 | cryptography>=2.1 # BSD/Apache-2.0 |
7 | 7 | python-barbicanclient>=4.5.2 # Apache-2.0 |
8 | oslo.config>=5.2.0 # Apache-2.0 | |
8 | oslo.config>=6.4.0 # Apache-2.0 | |
9 | 9 | oslo.context>=2.19.2 # Apache-2.0 |
10 | 10 | oslo.i18n>=3.15.3 # Apache-2.0 |
11 | 11 | oslo.log>=3.36.0 # Apache-2.0 |
3 | 3 | description-file = |
4 | 4 | README.rst |
5 | 5 | author = OpenStack |
6 | author-email = openstack-dev@lists.openstack.org | |
6 | author-email = openstack-discuss@lists.openstack.org | |
7 | 7 | home-page = https://docs.openstack.org/castellan/latest/ |
8 | 8 | classifier = |
9 | 9 | Environment :: OpenStack |
25 | 25 | oslo.config.opts = |
26 | 26 | castellan.tests.functional.config = castellan.tests.functional.config:list_opts |
27 | 27 | castellan.config = castellan.options:list_opts |
28 | ||
29 | oslo.config.driver = | |
30 | castellan = castellan._config_driver:CastellanConfigurationSourceDriver | |
28 | 31 | |
29 | 32 | castellan.drivers = |
30 | 33 | barbican = castellan.key_manager.barbican_key_manager:BarbicanKeyManager |
8 | 8 | sphinx!=1.6.6,!=1.6.7,>=1.6.2 # BSD |
9 | 9 | openstackdocstheme>=1.18.1 # Apache-2.0 |
10 | 10 | oslotest>=3.2.0 # Apache-2.0 |
11 | testrepository>=0.0.18 # Apache-2.0/BSD | |
11 | stestr>=2.0.0 # Apache-2.0 | |
12 | fixtures>=3.0.0 # Apache-2.0/BSD | |
12 | 13 | testscenarios>=0.4 # Apache-2.0/BSD |
13 | 14 | testtools>=2.2.0 # MIT |
14 | 15 | bandit>=1.1.0 # Apache-2.0 |
0 | 0 | #!/bin/bash |
1 | 1 | set -eux |
2 | 2 | if [ -z "$(which vault)" ]; then |
3 | VAULT_VERSION=0.7.3 | |
3 | VAULT_VERSION=0.10.4 | |
4 | 4 | SUFFIX=zip |
5 | 5 | case `uname -s` in |
6 | 6 | Darwin) |
0 | 0 | [tox] |
1 | minversion = 1.6 | |
2 | envlist = py35,py27,pep8 | |
1 | minversion = 2.0 | |
2 | envlist = py36,py27,pep8 | |
3 | 3 | skipsdist = True |
4 | 4 | |
5 | 5 | [testenv] |
6 | basepython = python3 | |
7 | 6 | usedevelop = True |
8 | 7 | install_command = pip install {opts} {packages} |
9 | 8 | setenv = |
13 | 12 | -c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt?h=stable/rocky} |
14 | 13 | -r{toxinidir}/requirements.txt |
15 | 14 | -r{toxinidir}/test-requirements.txt |
16 | commands = python setup.py testr --slowest --testr-args='{posargs}' | |
15 | commands = stestr run --slowest {posargs} | |
17 | 16 | |
18 | 17 | [testenv:py27] |
19 | 18 | basepython = python2.7 |
20 | 19 | |
21 | 20 | [testenv:pep8] |
21 | basepython = python3 | |
22 | 22 | commands = |
23 | 23 | flake8 |
24 | 24 | bandit -r castellan -x tests -s B105,B106,B107,B607 |
25 | 25 | |
26 | 26 | [testenv:bandit] |
27 | basepython = python3 | |
27 | 28 | # This command runs the bandit security linter against the castellan |
28 | 29 | # codebase minus the tests directory. Some tests are being excluded to |
29 | 30 | # reduce the number of positives before a team inspection, and to ensure a |
36 | 37 | bandit -r castellan -x tests -s B105,B106,B107,B607 |
37 | 38 | |
38 | 39 | [testenv:venv] |
40 | basepython = python3 | |
39 | 41 | commands = {posargs} |
40 | 42 | |
41 | 43 | [testenv:debug] |
44 | basepython = python3 | |
42 | 45 | commands = oslo_debug_helper {posargs} |
43 | 46 | |
44 | 47 | [testenv:cover] |
48 | basepython = python3 | |
49 | setenv = | |
50 | PYTHON=coverage run --source $project --parallel-mode | |
45 | 51 | commands = |
46 | python setup.py testr --coverage --testr-args='{posargs}' | |
47 | coverage report | |
52 | stestr run {posargs} | |
53 | coverage combine | |
54 | coverage html -d cover | |
55 | coverage xml -o cover/coverage.xml | |
56 | coverage report | |
48 | 57 | |
49 | 58 | [testenv:docs] |
59 | basepython = python3 | |
50 | 60 | commands = python setup.py build_sphinx |
51 | 61 | |
52 | 62 | [testenv:releasenotes] |
63 | basepython = python3 | |
53 | 64 | commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html |
54 | 65 | |
55 | 66 | [testenv:functional] |
58 | 69 | setenv = |
59 | 70 | VIRTUAL_ENV={envdir} |
60 | 71 | OS_TEST_PATH=./castellan/tests/functional |
61 | commands = python setup.py testr --slowest --testr-args='{posargs}' | |
72 | commands = stestr run --slowest {posargs} | |
62 | 73 | |
63 | 74 | [testenv:functional-vault] |
64 | 75 | passenv = HOME |
68 | 79 | VIRTUAL_ENV={envdir} |
69 | 80 | OS_TEST_PATH=./castellan/tests/functional |
70 | 81 | commands = |
71 | {toxinidir}/tools/setup-vault-env.sh pifpaf -e VAULT_TEST run vault -- python setup.py testr --slowest --testr-args='{posargs}' | |
82 | {toxinidir}/tools/setup-vault-env.sh pifpaf -e VAULT_TEST run vault -- stestr run --slowest {posargs} | |
72 | 83 | |
73 | 84 | [testenv:genconfig] |
85 | basepython = python3 | |
74 | 86 | commands = |
75 | 87 | oslo-config-generator --config-file=etc/castellan/functional-config-generator.conf |
76 | 88 | oslo-config-generator --config-file=etc/castellan/sample-config-generator.conf |
86 | 98 | import_exceptions = castellan.i18n |
87 | 99 | |
88 | 100 | [testenv:lower-constraints] |
101 | basepython = python3 | |
89 | 102 | deps = |
90 | 103 | -c{toxinidir}/lower-constraints.txt |
91 | 104 | -r{toxinidir}/test-requirements.txt |