Code Coverage

Cobertura Coverage Report > osm_common >

dbbase.py

Trend

File Coverage summary

NameClassesLinesConditionals
dbbase.py
100%
1/1
82%
197/240
100%
0/0

Coverage Breakdown by Class

NameLinesConditionals
dbbase.py
82%
197/240
N/A

Source

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 1 from base64 import b64decode, b64encode
19 1 from copy import deepcopy
20 1 from http import HTTPStatus
21 1 import logging
22 1 import re
23 1 from threading import Lock
24
25 1 from Crypto.Cipher import AES
26 1 from osm_common.common_utils import FakeLock
27 1 import yaml
28
29 1 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
30
31
32 1 class DbException(Exception):
33 1     def __init__(self, message, http_code=HTTPStatus.NOT_FOUND):
34 1         self.http_code = http_code
35 1         Exception.__init__(self, "database exception " + str(message))
36
37
38 1 class DbBase(object):
39 1     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 1         self.logger = logging.getLogger(logger_name)
49 1         self.secret_key = None  # 32 bytes length array used for encrypt/decrypt
50 1         if not lock:
51 1             self.lock = FakeLock()
52 1         elif lock is True:
53 1             self.lock = Lock()
54 0         elif isinstance(lock, Lock):
55 0             self.lock = lock
56         else:
57 0             raise ValueError("lock parameter must be a Lock classclass or boolean")
58
59 1     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 host (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 0         raise DbException("Method 'db_connect' not implemented")
74
75 1     def db_disconnect(self):
76         """
77         Disconnect from database
78         :return: None
79         """
80 0         pass
81
82 1     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 0         raise DbException("Method 'get_list' not implemented")
90
91 1     def count(self, table, q_filter=None):
92         """
93         Count the number of entries matching q_filter
94         :param table: collection or table
95         :param q_filter: Filter
96         :return: number of entries found (can be zero)
97         :raise: DbException on error
98         """
99 0         raise DbException("Method 'count' not implemented")
100
101 1     def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
102         """
103         Obtain one entry matching q_filter
104         :param table: collection or table
105         :param q_filter: Filter
106         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
107         it raises a DbException
108         :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
109         that it raises a DbException
110         :return: The requested element, or None
111         """
112 0         raise DbException("Method 'get_one' not implemented")
113
114 1     def del_list(self, table, q_filter=None):
115         """
116         Deletes all entries that match q_filter
117         :param table: collection or table
118         :param q_filter: Filter
119         :return: Dict with the number of entries deleted
120         """
121 0         raise DbException("Method 'del_list' not implemented")
122
123 1     def del_one(self, table, q_filter=None, fail_on_empty=True):
124         """
125         Deletes one entry that matches q_filter
126         :param table: collection or table
127         :param q_filter: Filter
128         :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
129         which case it raises a DbException
130         :return: Dict with the number of entries deleted
131         """
132 0         raise DbException("Method 'del_one' not implemented")
133
134 1     def create(self, table, indata):
135         """
136         Add a new entry at database
137         :param table: collection or table
138         :param indata: content to be added
139         :return: database '_id' of the inserted element. Raises a DbException on error
140         """
141 0         raise DbException("Method 'create' not implemented")
142
143 1     def create_list(self, table, indata_list):
144         """
145         Add several entries at once
146         :param table: collection or table
147         :param indata_list: list of elements to insert. Each element must be a dictionary.
148             An '_id' key based on random uuid is added at each element if missing
149         :return: list of inserted '_id's. Exception on error
150         """
151 0         raise DbException("Method 'create_list' not implemented")
152
153 1     def set_one(
154         self,
155         table,
156         q_filter,
157         update_dict,
158         fail_on_empty=True,
159         unset=None,
160         pull=None,
161         push=None,
162         push_list=None,
163         pull_list=None,
164     ):
165         """
166         Modifies an entry at database
167         :param table: collection or table
168         :param q_filter: Filter
169         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
170         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
171         it raises a DbException
172         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
173                       ignored. If not exist, it is ignored
174         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
175                      if exist in the array is removed. If not exist, it is ignored
176         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
177                      is appended to the end of the array
178         :param pull_list: Same as pull but values are arrays where each item is removed from the array
179         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
180                           whole array
181         :return: Dict with the number of entries modified. None if no matching is found.
182         """
183 0         raise DbException("Method 'set_one' not implemented")
184
185 1     def set_list(
186         self,
187         table,
188         q_filter,
189         update_dict,
190         unset=None,
191         pull=None,
192         push=None,
193         push_list=None,
194         pull_list=None,
195     ):
196         """
197         Modifies al matching entries at database
198         :param table: collection or table
199         :param q_filter: Filter
200         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
201         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
202                       ignored. If not exist, it is ignored
203         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
204                      if exist in the array is removed. If not exist, it is ignored
205         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
206                      is appended to the end of the array
207         :param pull_list: Same as pull but values are arrays where each item is removed from the array
208         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
209                           whole array
210         :return: Dict with the number of entries modified
211         """
212 0         raise DbException("Method 'set_list' not implemented")
213
214 1     def replace(self, table, _id, indata, fail_on_empty=True):
215         """
216         Replace the content of an entry
217         :param table: collection or table
218         :param _id: internal database id
219         :param indata: content to replace
220         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
221         it raises a DbException
222         :return: Dict with the number of entries replaced
223         """
224 0         raise DbException("Method 'replace' not implemented")
225
226 1     def _join_secret_key(self, update_key):
227         """
228         Returns a xor byte combination of the internal secret_key and the provided update_key.
229         It does not modify the internal secret_key. Used for adding salt, join keys, etc.
230         :param update_key: Can be a string, byte or None. Recommended a long one (e.g. 32 byte length)
231         :return: joined key in bytes with a 32 bytes length. Can be None if both internal secret_key and update_key
232                  are None
233         """
234 1         if not update_key:
235 1             return self.secret_key
236 1         elif isinstance(update_key, str):
237 1             update_key_bytes = update_key.encode()
238         else:
239 1             update_key_bytes = update_key
240
241 1         new_secret_key = (
242             bytearray(self.secret_key) if self.secret_key else bytearray(32)
243         )
244 1         for i, b in enumerate(update_key_bytes):
245 1             new_secret_key[i % 32] ^= b
246 1         return bytes(new_secret_key)
247
248 1     def set_secret_key(self, new_secret_key, replace=False):
249         """
250         Updates internal secret_key used for encryption, with a byte xor
251         :param new_secret_key: string or byte array. It is recommended a 32 byte length
252         :param replace: if True, old value of internal secret_key is ignored and replaced. If false, a byte xor is used
253         :return: None
254         """
255 1         if replace:
256 1             self.secret_key = None
257 1         self.secret_key = self._join_secret_key(new_secret_key)
258
259 1     def get_secret_key(self):
260         """
261         Get the database secret key in case it is not done when "connect" is called. It can happens when database is
262         empty after an initial install. It should skip if secret is already obtained.
263         """
264 1         pass
265
266 1     def encrypt(self, value, schema_version=None, salt=None):
267         """
268         Encrypt a value
269         :param value: value to be encrypted. It is string/unicode
270         :param schema_version: used for version control. If None or '1.0' no encryption is done.
271                If '1.1' symmetric AES encryption is done
272         :param salt: optional salt to be used. Must be str
273         :return: Encrypted content of value
274         """
275 1         self.get_secret_key()
276 1         if not self.secret_key or not schema_version or schema_version == "1.0":
277 1             return value
278         else:
279 1             secret_key = self._join_secret_key(salt)
280 1             cipher = AES.new(secret_key)
281 1             padded_private_msg = value + ("\0" * ((16 - len(value)) % 16))
282 1             encrypted_msg = cipher.encrypt(padded_private_msg)
283 1             encoded_encrypted_msg = b64encode(encrypted_msg)
284 1             return encoded_encrypted_msg.decode("ascii")
285
286 1     def decrypt(self, value, schema_version=None, salt=None):
287         """
288         Decrypt an encrypted value
289         :param value: value to be decrypted. It is a base64 string
290         :param schema_version: used for known encryption method used. If None or '1.0' no encryption has been done.
291                If '1.1' symmetric AES encryption has been done
292         :param salt: optional salt to be used
293         :return: Plain content of value
294         """
295 1         self.get_secret_key()
296 1         if not self.secret_key or not schema_version or schema_version == "1.0":
297 1             return value
298         else:
299 1             secret_key = self._join_secret_key(salt)
300 1             encrypted_msg = b64decode(value)
301 1             cipher = AES.new(secret_key)
302 1             decrypted_msg = cipher.decrypt(encrypted_msg)
303 1             try:
304 1                 unpadded_private_msg = decrypted_msg.decode().rstrip("\0")
305 1             except UnicodeDecodeError:
306 1                 raise DbException(
307                     "Cannot decrypt information. Are you using same COMMONKEY in all OSM components?",
308                     http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
309                 )
310 1             return unpadded_private_msg
311
312 1     def encrypt_decrypt_fields(
313         self, item, action, fields=None, flags=None, schema_version=None, salt=None
314     ):
315 0         if not fields:
316 0             return
317 0         self.get_secret_key()
318 0         actions = ["encrypt", "decrypt"]
319 0         if action.lower() not in actions:
320 0             raise DbException(
321                 "Unknown action ({}): Must be one of {}".format(action, actions),
322                 http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
323             )
324 0         method = self.encrypt if action.lower() == "encrypt" else self.decrypt
325 0         if flags is None:
326 0             flags = re.I
327
328 0         def process(_item):
329 0             if isinstance(_item, list):
330 0                 for elem in _item:
331 0                     process(elem)
332 0             elif isinstance(_item, dict):
333 0                 for key, val in _item.items():
334 0                     if isinstance(val, str):
335 0                         if any(re.search(f, key, flags) for f in fields):
336 0                             _item[key] = method(val, schema_version, salt)
337                     else:
338 0                         process(val)
339
340 0         process(item)
341
342
343 1 def deep_update_rfc7396(dict_to_change, dict_reference, key_list=None):
344     """
345     Modifies one dictionary with the information of the other following https://tools.ietf.org/html/rfc7396
346     Basically is a recursive python 'dict_to_change.update(dict_reference)', but a value of None is used to delete.
347     It implements an extra feature that allows modifying an array. RFC7396 only allows replacing the entire array.
348     For that, dict_reference should contains a dict with keys starting by "$" with the following meaning:
349         $[index]    <index> is an integer for targeting a concrete index from dict_to_change array. If the value is None
350                     the element of the array is deleted, otherwise it is edited.
351         $+[index]   The value is inserted at this <index>. A value of None has not sense and an exception is raised.
352         $+          The value is appended at the end. A value of None has not sense and an exception is raised.
353         $val        It looks for all the items in the array dict_to_change equal to <val>. <val> is evaluated as yaml,
354                     that is, numbers are taken as type int, true/false as boolean, etc. Use quotes to force string.
355                     Nothing happens if no match is found. If the value is None the matched elements are deleted.
356         $key: val   In case a dictionary is passed in yaml format, if looks for all items in the array dict_to_change
357                     that are dictionaries and contains this <key> equal to <val>. Several keys can be used by yaml
358                     format '{key: val, key: val, ...}'; and all of them must match. Nothing happens if no match is
359                     found. If value is None the matched items are deleted, otherwise they are edited.
360         $+val       If no match if found (see '$val'), the value is appended to the array. If any match is found nothing
361                     is changed. A value of None has not sense.
362         $+key: val  If no match if found (see '$key: val'), the value is appended to the array. If any match is found
363                     nothing is changed. A value of None has not sense.
364     If there are several editions, insertions and deletions; editions and deletions are done first in reverse index
365         order; then insertions also in reverse index order; and finally appends in any order. So indexes used at
366         insertions must take into account the deleted items.
367     :param dict_to_change:  Target dictionary to be changed.
368     :param dict_reference: Dictionary that contains changes to be applied.
369     :param key_list: This is used internally for recursive calls. Do not fill this parameter.
370     :return: none or raises and exception only at array modification when there is a bad format or conflict.
371     """
372
373 1     def _deep_update_array(array_to_change, _dict_reference, _key_list):
374 1         to_append = {}
375 1         to_insert_at_index = {}
376 1         values_to_edit_delete = {}
377 1         indexes_to_edit_delete = []
378 1         array_edition = None
379 1         _key_list.append("")
380 1         for k in _dict_reference:
381 1             _key_list[-1] = str(k)
382 1             if not isinstance(k, str) or not k.startswith("$"):
383 1                 if array_edition is True:
384 1                     raise DbException(
385                         "Found array edition (keys starting with '$') and pure dictionary edition in the"
386                         " same dict at '{}'".format(":".join(_key_list[:-1]))
387                     )
388 1                 array_edition = False
389 1                 continue
390             else:
391 1                 if array_edition is False:
392 0                     raise DbException(
393                         "Found array edition (keys starting with '$') and pure dictionary edition in the"
394                         " same dict at '{}'".format(":".join(_key_list[:-1]))
395                     )
396 1                 array_edition = True
397 1             insert = False
398 1             indexes = []  # indexes to edit or insert
399 1             kitem = k[1:]
400 1             if kitem.startswith("+"):
401 1                 insert = True
402 1                 kitem = kitem[1:]
403 1                 if _dict_reference[k] is None:
404 1                     raise DbException(
405                         "A value of None has not sense for insertions at '{}'".format(
406                             ":".join(_key_list)
407                         )
408                     )
409
410 1             if kitem.startswith("[") and kitem.endswith("]"):
411 1                 try:
412 1                     index = int(kitem[1:-1])
413 1                     if index < 0:
414 1                         index += len(array_to_change)
415 1                     if index < 0:
416 0                         index = 0  # skip outside index edition
417 1                     indexes.append(index)
418 0                 except Exception:
419 0                     raise DbException(
420                         "Wrong format at '{}'. Expecting integer index inside quotes".format(
421                             ":".join(_key_list)
422                         )
423                     )
424 1             elif kitem:
425                 # match_found_skip = False
426 1                 try:
427 1                     filter_in = yaml.safe_load(kitem)
428 1                 except Exception:
429 1                     raise DbException(
430                         "Wrong format at '{}'. Expecting '$<yaml-format>'".format(
431                             ":".join(_key_list)
432                         )
433                     )
434 1                 if isinstance(filter_in, dict):
435 1                     for index, item in enumerate(array_to_change):
436 1                         for filter_k, filter_v in filter_in.items():
437 1                             if (
438                                 not isinstance(item, dict)
439                                 or filter_k not in item
440                                 or item[filter_k] != filter_v
441                             ):
442 1                                 break
443                         else:  # match found
444 1                             if insert:
445                                 # match_found_skip = True
446 0                                 insert = False
447 0                                 break
448                             else:
449 1                                 indexes.append(index)
450                 else:
451 1                     index = 0
452 1                     try:
453                         while True:  # if not match a ValueError exception will be raise
454 1                             index = array_to_change.index(filter_in, index)
455 1                             if insert:
456                                 # match_found_skip = True
457 1                                 insert = False
458 1                                 break
459 1                             indexes.append(index)
460 1                             index += 1
461 1                     except ValueError:
462 1                         pass
463
464                 # if match_found_skip:
465                 #     continue
466 1             elif not insert:
467 1                 raise DbException(
468                     "Wrong format at '{}'. Expecting '$+', '$[<index]' or '$[<filter>]'".format(
469                         ":".join(_key_list)
470                     )
471                 )
472 1             for index in indexes:
473 1                 if insert:
474 1                     if (
475                         index in to_insert_at_index
476                         and to_insert_at_index[index] != _dict_reference[k]
477                     ):
478                         # Several different insertions on the same item of the array
479 0                         raise DbException(
480                             "Conflict at '{}'. Several insertions on same array index {}".format(
481                                 ":".join(_key_list), index
482                             )
483                         )
484 1                     to_insert_at_index[index] = _dict_reference[k]
485                 else:
486 1                     if (
487                         index in indexes_to_edit_delete
488                         and values_to_edit_delete[index] != _dict_reference[k]
489                     ):
490                         # Several different editions on the same item of the array
491 1                         raise DbException(
492                             "Conflict at '{}'. Several editions on array index {}".format(
493                                 ":".join(_key_list), index
494                             )
495                         )
496 1                     indexes_to_edit_delete.append(index)
497 1                     values_to_edit_delete[index] = _dict_reference[k]
498 1             if not indexes:
499 1                 if insert:
500 1                     to_append[k] = _dict_reference[k]
501                 # elif _dict_reference[k] is not None:
502                 #     raise DbException("Not found any match to edit in the array, or wrong format at '{}'".format(
503                 #         ":".join(_key_list)))
504
505         # edition/deletion is done before insertion
506 1         indexes_to_edit_delete.sort(reverse=True)
507 1         for index in indexes_to_edit_delete:
508 1             _key_list[-1] = str(index)
509 1             try:
510 1                 if values_to_edit_delete[index] is None:  # None->Anything
511 1                     try:
512 1                         del array_to_change[index]
513 1                     except IndexError:
514 1                         pass  # it is not consider an error if this index does not exist
515 1                 elif not isinstance(
516                     values_to_edit_delete[index], dict
517                 ):  # NotDict->Anything
518 1                     array_to_change[index] = deepcopy(values_to_edit_delete[index])
519 1                 elif isinstance(array_to_change[index], dict):  # Dict->Dict
520 1                     deep_update_rfc7396(
521                         array_to_change[index], values_to_edit_delete[index], _key_list
522                     )
523                 else:  # Dict->NotDict
524 1                     if isinstance(
525                         array_to_change[index], list
526                     ):  # Dict->List. Check extra array edition
527 1                         if _deep_update_array(
528                             array_to_change[index],
529                             values_to_edit_delete[index],
530                             _key_list,
531                         ):
532 1                             continue
533 1                     array_to_change[index] = deepcopy(values_to_edit_delete[index])
534                     # calling deep_update_rfc7396 to delete the None values
535 1                     deep_update_rfc7396(
536                         array_to_change[index], values_to_edit_delete[index], _key_list
537                     )
538 1             except IndexError:
539 1                 raise DbException(
540                     "Array edition index out of range at '{}'".format(
541                         ":".join(_key_list)
542                     )
543                 )
544
545         # insertion with indexes
546 1         to_insert_indexes = list(to_insert_at_index.keys())
547 1         to_insert_indexes.sort(reverse=True)
548 1         for index in to_insert_indexes:
549 1             array_to_change.insert(index, to_insert_at_index[index])
550
551         # append
552 1         for k, insert_value in to_append.items():
553 1             _key_list[-1] = str(k)
554 1             insert_value_copy = deepcopy(insert_value)
555 1             if isinstance(insert_value_copy, dict):
556                 # calling deep_update_rfc7396 to delete the None values
557 0                 deep_update_rfc7396(insert_value_copy, insert_value, _key_list)
558 1             array_to_change.append(insert_value_copy)
559
560 1         _key_list.pop()
561 1         if array_edition:
562 1             return True
563 1         return False
564
565 1     if key_list is None:
566 1         key_list = []
567 1     key_list.append("")
568 1     for k in dict_reference:
569 1         key_list[-1] = str(k)
570 1         if dict_reference[k] is None:  # None->Anything
571 1             if k in dict_to_change:
572 1                 del dict_to_change[k]
573 1         elif not isinstance(dict_reference[k], dict):  # NotDict->Anything
574 1             dict_to_change[k] = deepcopy(dict_reference[k])
575 1         elif k not in dict_to_change:  # Dict->Empty
576 1             dict_to_change[k] = deepcopy(dict_reference[k])
577             # calling deep_update_rfc7396 to delete the None values
578 1             deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
579 1         elif isinstance(dict_to_change[k], dict):  # Dict->Dict
580 1             deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
581         else:  # Dict->NotDict
582 1             if isinstance(
583                 dict_to_change[k], list
584             ):  # Dict->List. Check extra array edition
585 1                 if _deep_update_array(dict_to_change[k], dict_reference[k], key_list):
586 1                     continue
587 1             dict_to_change[k] = deepcopy(dict_reference[k])
588             # calling deep_update_rfc7396 to delete the None values
589 1             deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
590 1     key_list.pop()
591
592
593 1 def deep_update(dict_to_change, dict_reference):
594     """Maintained for backward compatibility. Use deep_update_rfc7396 instead"""
595 1     return deep_update_rfc7396(dict_to_change, dict_reference)