Bug 559, changes to make optional intenal database key
[osm/common.git] / osm_common / dbbase.py
1 # -*- coding: utf-8 -*-
2
3 # Copyright 2018 Telefonica S.A.
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 # http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14 # implied.
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
17
18 import yaml
19 import logging
20 from http import HTTPStatus
21 from copy import deepcopy
22 from Crypto.Cipher import AES
23 from base64 import b64decode, b64encode
24 from osm_common.common_utils import FakeLock
25 from threading import Lock
26
27 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
28
29
30 class DbException(Exception):
31
32 def __init__(self, message, http_code=HTTPStatus.NOT_FOUND):
33 self.http_code = http_code
34 Exception.__init__(self, "database exception " + str(message))
35
36
37 class DbBase(object):
38
39 def __init__(self, logger_name='db', lock=False):
40 """
41 Constructor of dbBase
42 :param logger_name: logging name
43 :param lock: Used to protect simultaneous access to the same instance class by several threads:
44 False, None: Do not protect, this object will only be accessed by one thread
45 True: This object needs to be protected by several threads accessing.
46 Lock object. Use thi Lock for the threads access protection
47 """
48 self.logger = logging.getLogger(logger_name)
49 self.secret_key = None # 32 bytes length array used for encrypt/decrypt
50 if not lock:
51 self.lock = FakeLock()
52 elif lock is True:
53 self.lock = Lock()
54 elif isinstance(lock, Lock):
55 self.lock = lock
56 else:
57 raise ValueError("lock parameter must be a Lock classclass or boolean")
58
59 def db_connect(self, config, target_version=None):
60 """
61 Connect to database
62 :param config: Configuration of database. Contains among others:
63 host: database hosst (mandatory)
64 port: database port (mandatory)
65 name: database name (mandatory)
66 user: database username
67 password: database password
68 commonkey: common OSM key used for sensible information encryption
69 materpassword: same as commonkey, for backward compatibility. Deprecated, to be removed in the future
70 :param target_version: if provided it checks if database contains required version, raising exception otherwise.
71 :return: None or raises DbException on error
72 """
73 raise DbException("Method 'db_connect' not implemented")
74
75 def db_disconnect(self):
76 """
77 Disconnect from database
78 :return: None
79 """
80 pass
81
82 def get_list(self, table, q_filter=None):
83 """
84 Obtain a list of entries matching q_filter
85 :param table: collection or table
86 :param q_filter: Filter
87 :return: a list (can be empty) with the found entries. Raises DbException on error
88 """
89 raise DbException("Method 'get_list' not implemented")
90
91 def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
92 """
93 Obtain one entry matching q_filter
94 :param table: collection or table
95 :param q_filter: Filter
96 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
97 it raises a DbException
98 :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
99 that it raises a DbException
100 :return: The requested element, or None
101 """
102 raise DbException("Method 'get_one' not implemented")
103
104 def del_list(self, table, q_filter=None):
105 """
106 Deletes all entries that match q_filter
107 :param table: collection or table
108 :param q_filter: Filter
109 :return: Dict with the number of entries deleted
110 """
111 raise DbException("Method 'del_list' not implemented")
112
113 def del_one(self, table, q_filter=None, fail_on_empty=True):
114 """
115 Deletes one entry that matches q_filter
116 :param table: collection or table
117 :param q_filter: Filter
118 :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
119 which case it raises a DbException
120 :return: Dict with the number of entries deleted
121 """
122 raise DbException("Method 'del_one' not implemented")
123
124 def create(self, table, indata):
125 """
126 Add a new entry at database
127 :param table: collection or table
128 :param indata: content to be added
129 :return: database id of the inserted element. Raises a DbException on error
130 """
131 raise DbException("Method 'create' not implemented")
132
133 def set_one(self, table, q_filter, update_dict, fail_on_empty=True):
134 """
135 Modifies an entry at database
136 :param table: collection or table
137 :param q_filter: Filter
138 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
139 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
140 it raises a DbException
141 :return: Dict with the number of entries modified. None if no matching is found.
142 """
143 raise DbException("Method 'set_one' not implemented")
144
145 def set_list(self, table, q_filter, update_dict):
146 """
147 Modifies al matching entries at database
148 :param table: collection or table
149 :param q_filter: Filter
150 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
151 :return: Dict with the number of entries modified
152 """
153 raise DbException("Method 'set_list' not implemented")
154
155 def replace(self, table, _id, indata, fail_on_empty=True):
156 """
157 Replace the content of an entry
158 :param table: collection or table
159 :param _id: internal database id
160 :param indata: content to replace
161 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
162 it raises a DbException
163 :return: Dict with the number of entries replaced
164 """
165 raise DbException("Method 'replace' not implemented")
166
167 def _join_secret_key(self, update_key):
168 """
169 Returns a xor byte combination of the internal secret_key and the provided update_key.
170 It does not modify the internal secret_key. Used for adding salt, join keys, etc.
171 :param update_key: Can be a string, byte or None. Recommended a long one (e.g. 32 byte length)
172 :return: joined key in bytes with a 32 bytes length. Can be None if both internal secret_key and update_key
173 are None
174 """
175 if not update_key:
176 return self.secret_key
177 elif isinstance(update_key, str):
178 update_key_bytes = update_key.encode()
179 else:
180 update_key_bytes = update_key
181
182 new_secret_key = bytearray(self.secret_key) if self.secret_key else bytearray(32)
183 for i, b in enumerate(update_key_bytes):
184 new_secret_key[i % 32] ^= b
185 return bytes(new_secret_key)
186
187 def set_secret_key(self, new_secret_key, replace=False):
188 """
189 Updates internal secret_key used for encryption, with a byte xor
190 :param new_secret_key: string or byte array. It is recommended a 32 byte length
191 :param replace: if True, old value of internal secret_key is ignored and replaced. If false, a byte xor is used
192 :return: None
193 """
194 if replace:
195 self.secret_key = None
196 self.secret_key = self._join_secret_key(new_secret_key)
197
198 def encrypt(self, value, schema_version=None, salt=None):
199 """
200 Encrypt a value
201 :param value: value to be encrypted. It is string/unicode
202 :param schema_version: used for version control. If None or '1.0' no encryption is done.
203 If '1.1' symmetric AES encryption is done
204 :param salt: optional salt to be used. Must be str
205 :return: Encrypted content of value
206 """
207 if not self.secret_key or not schema_version or schema_version == '1.0':
208 return value
209 else:
210 secret_key = self._join_secret_key(salt)
211 cipher = AES.new(secret_key)
212 padded_private_msg = value + ('\0' * ((16-len(value)) % 16))
213 encrypted_msg = cipher.encrypt(padded_private_msg)
214 encoded_encrypted_msg = b64encode(encrypted_msg)
215 return encoded_encrypted_msg.decode("ascii")
216
217 def decrypt(self, value, schema_version=None, salt=None):
218 """
219 Decrypt an encrypted value
220 :param value: value to be decrypted. It is a base64 string
221 :param schema_version: used for known encryption method used. If None or '1.0' no encryption has been done.
222 If '1.1' symmetric AES encryption has been done
223 :param salt: optional salt to be used
224 :return: Plain content of value
225 """
226 if not self.secret_key or not schema_version or schema_version == '1.0':
227 return value
228 else:
229 secret_key = self._join_secret_key(salt)
230 encrypted_msg = b64decode(value)
231 cipher = AES.new(secret_key)
232 decrypted_msg = cipher.decrypt(encrypted_msg)
233 unpadded_private_msg = decrypted_msg.decode().rstrip('\0')
234 return unpadded_private_msg
235
236
237 def deep_update_rfc7396(dict_to_change, dict_reference, key_list=None):
238 """
239 Modifies one dictionary with the information of the other following https://tools.ietf.org/html/rfc7396
240 Basically is a recursive python 'dict_to_change.update(dict_reference)', but a value of None is used to delete.
241 It implements an extra feature that allows modifying an array. RFC7396 only allows replacing the entire array.
242 For that, dict_reference should contains a dict with keys starting by "$" with the following meaning:
243 $[index] <index> is an integer for targeting a concrete index from dict_to_change array. If the value is None
244 the element of the array is deleted, otherwise it is edited.
245 $+[index] The value is inserted at this <index>. A value of None has not sense and an exception is raised.
246 $+ The value is appended at the end. A value of None has not sense and an exception is raised.
247 $val It looks for all the items in the array dict_to_change equal to <val>. <val> is evaluated as yaml,
248 that is, numbers are taken as type int, true/false as boolean, etc. Use quotes to force string.
249 Nothing happens if no match is found. If the value is None the matched elements are deleted.
250 $key: val In case a dictionary is passed in yaml format, if looks for all items in the array dict_to_change
251 that are dictionaries and contains this <key> equal to <val>. Several keys can be used by yaml
252 format '{key: val, key: val, ...}'; and all of them mast match. Nothing happens if no match is
253 found. If value is None the matched items are deleted, otherwise they are edited.
254 $+val If no match if found (see '$val'), the value is appended to the array. If any match is found nothing
255 is changed. A value of None has not sense.
256 $+key: val If no match if found (see '$key: val'), the value is appended to the array. If any match is found
257 nothing is changed. A value of None has not sense.
258 If there are several editions, insertions and deletions; editions and deletions are done first in reverse index
259 order; then insertions also in reverse index order; and finally appends in any order. So indexes used at
260 insertions must take into account the deleted items.
261 :param dict_to_change: Target dictionary to be changed.
262 :param dict_reference: Dictionary that contains changes to be applied.
263 :param key_list: This is used internally for recursive calls. Do not fill this parameter.
264 :return: none or raises and exception only at array modification when there is a bad format or conflict.
265 """
266 def _deep_update_array(array_to_change, _dict_reference, _key_list):
267 to_append = {}
268 to_insert_at_index = {}
269 values_to_edit_delete = {}
270 indexes_to_edit_delete = []
271 array_edition = None
272 _key_list.append("")
273 for k in _dict_reference:
274 _key_list[-1] = str(k)
275 if not isinstance(k, str) or not k.startswith("$"):
276 if array_edition is True:
277 raise DbException("Found array edition (keys starting with '$') and pure dictionary edition in the"
278 " same dict at '{}'".format(":".join(_key_list[:-1])))
279 array_edition = False
280 continue
281 else:
282 if array_edition is False:
283 raise DbException("Found array edition (keys starting with '$') and pure dictionary edition in the"
284 " same dict at '{}'".format(":".join(_key_list[:-1])))
285 array_edition = True
286 insert = False
287 indexes = [] # indexes to edit or insert
288 kitem = k[1:]
289 if kitem.startswith('+'):
290 insert = True
291 kitem = kitem[1:]
292 if _dict_reference[k] is None:
293 raise DbException("A value of None has not sense for insertions at '{}'".format(
294 ":".join(_key_list)))
295
296 if kitem.startswith('[') and kitem.endswith(']'):
297 try:
298 index = int(kitem[1:-1])
299 if index < 0:
300 index += len(array_to_change)
301 if index < 0:
302 index = 0 # skip outside index edition
303 indexes.append(index)
304 except Exception:
305 raise DbException("Wrong format at '{}'. Expecting integer index inside quotes".format(
306 ":".join(_key_list)))
307 elif kitem:
308 # match_found_skip = False
309 try:
310 filter_in = yaml.safe_load(kitem)
311 except Exception:
312 raise DbException("Wrong format at '{}'. Expecting '$<yaml-format>'".format(":".join(_key_list)))
313 if isinstance(filter_in, dict):
314 for index, item in enumerate(array_to_change):
315 for filter_k, filter_v in filter_in.items():
316 if not isinstance(item, dict) or filter_k not in item or item[filter_k] != filter_v:
317 break
318 else: # match found
319 if insert:
320 # match_found_skip = True
321 insert = False
322 break
323 else:
324 indexes.append(index)
325 else:
326 index = 0
327 try:
328 while True: # if not match a ValueError exception will be raise
329 index = array_to_change.index(filter_in, index)
330 if insert:
331 # match_found_skip = True
332 insert = False
333 break
334 indexes.append(index)
335 index += 1
336 except ValueError:
337 pass
338
339 # if match_found_skip:
340 # continue
341 elif not insert:
342 raise DbException("Wrong format at '{}'. Expecting '$+', '$[<index]' or '$[<filter>]'".format(
343 ":".join(_key_list)))
344 for index in indexes:
345 if insert:
346 if index in to_insert_at_index and to_insert_at_index[index] != _dict_reference[k]:
347 # Several different insertions on the same item of the array
348 raise DbException("Conflict at '{}'. Several insertions on same array index {}".format(
349 ":".join(_key_list), index))
350 to_insert_at_index[index] = _dict_reference[k]
351 else:
352 if index in indexes_to_edit_delete and values_to_edit_delete[index] != _dict_reference[k]:
353 # Several different editions on the same item of the array
354 raise DbException("Conflict at '{}'. Several editions on array index {}".format(
355 ":".join(_key_list), index))
356 indexes_to_edit_delete.append(index)
357 values_to_edit_delete[index] = _dict_reference[k]
358 if not indexes:
359 if insert:
360 to_append[k] = _dict_reference[k]
361 # elif _dict_reference[k] is not None:
362 # raise DbException("Not found any match to edit in the array, or wrong format at '{}'".format(
363 # ":".join(_key_list)))
364
365 # edition/deletion is done before insertion
366 indexes_to_edit_delete.sort(reverse=True)
367 for index in indexes_to_edit_delete:
368 _key_list[-1] = str(index)
369 try:
370 if values_to_edit_delete[index] is None: # None->Anything
371 try:
372 del (array_to_change[index])
373 except IndexError:
374 pass # it is not consider an error if this index does not exist
375 elif not isinstance(values_to_edit_delete[index], dict): # NotDict->Anything
376 array_to_change[index] = deepcopy(values_to_edit_delete[index])
377 elif isinstance(array_to_change[index], dict): # Dict->Dict
378 deep_update_rfc7396(array_to_change[index], values_to_edit_delete[index], _key_list)
379 else: # Dict->NotDict
380 if isinstance(array_to_change[index], list): # Dict->List. Check extra array edition
381 if _deep_update_array(array_to_change[index], values_to_edit_delete[index], _key_list):
382 continue
383 array_to_change[index] = deepcopy(values_to_edit_delete[index])
384 # calling deep_update_rfc7396 to delete the None values
385 deep_update_rfc7396(array_to_change[index], values_to_edit_delete[index], _key_list)
386 except IndexError:
387 raise DbException("Array edition index out of range at '{}'".format(":".join(_key_list)))
388
389 # insertion with indexes
390 to_insert_indexes = list(to_insert_at_index.keys())
391 to_insert_indexes.sort(reverse=True)
392 for index in to_insert_indexes:
393 array_to_change.insert(index, to_insert_at_index[index])
394
395 # append
396 for k, insert_value in to_append.items():
397 _key_list[-1] = str(k)
398 insert_value_copy = deepcopy(insert_value)
399 if isinstance(insert_value_copy, dict):
400 # calling deep_update_rfc7396 to delete the None values
401 deep_update_rfc7396(insert_value_copy, insert_value, _key_list)
402 array_to_change.append(insert_value_copy)
403
404 _key_list.pop()
405 if array_edition:
406 return True
407 return False
408
409 if key_list is None:
410 key_list = []
411 key_list.append("")
412 for k in dict_reference:
413 key_list[-1] = str(k)
414 if dict_reference[k] is None: # None->Anything
415 if k in dict_to_change:
416 del dict_to_change[k]
417 elif not isinstance(dict_reference[k], dict): # NotDict->Anything
418 dict_to_change[k] = deepcopy(dict_reference[k])
419 elif k not in dict_to_change: # Dict->Empty
420 dict_to_change[k] = deepcopy(dict_reference[k])
421 # calling deep_update_rfc7396 to delete the None values
422 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
423 elif isinstance(dict_to_change[k], dict): # Dict->Dict
424 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
425 else: # Dict->NotDict
426 if isinstance(dict_to_change[k], list): # Dict->List. Check extra array edition
427 if _deep_update_array(dict_to_change[k], dict_reference[k], key_list):
428 continue
429 dict_to_change[k] = deepcopy(dict_reference[k])
430 # calling deep_update_rfc7396 to delete the None values
431 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
432 key_list.pop()
433
434
435 def deep_update(dict_to_change, dict_reference):
436 """ Maintained for backward compatibility. Use deep_update_rfc7396 instead"""
437 return deep_update_rfc7396(dict_to_change, dict_reference)