Code Coverage

Cobertura Coverage Report > osm_common >

dbbase.py

Trend

File Coverage summary

NameClassesLinesConditionals
dbbase.py
100%
1/1
86%
276/322
100%
0/0

Coverage Breakdown by Class

NameLinesConditionals
dbbase.py
86%
276/322
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 1 import typing
25
26
27 1 from Crypto.Cipher import AES
28 1 from motor.motor_asyncio import AsyncIOMotorClient
29 1 from osm_common.common_utils import FakeLock
30 1 import yaml
31
32 1 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
33
34
35 1 DB_NAME = "osm"
36
37
38 1 class DbException(Exception):
39 1     def __init__(self, message, http_code=HTTPStatus.NOT_FOUND):
40 1         self.http_code = http_code
41 1         Exception.__init__(self, "database exception " + str(message))
42
43
44 1 class DbBase(object):
45 1     def __init__(self, encoding_type="ascii", logger_name="db", lock=False):
46         """
47         Constructor of dbBase
48         :param logger_name: logging name
49         :param lock: Used to protect simultaneous access to the same instance class by several threads:
50             False, None: Do not protect, this object will only be accessed by one thread
51             True: This object needs to be protected by several threads accessing.
52             Lock object. Use thi Lock for the threads access protection
53         """
54 1         self.logger = logging.getLogger(logger_name)
55 1         self.secret_key = None  # 32 bytes length array used for encrypt/decrypt
56 1         self.encrypt_mode = AES.MODE_ECB
57 1         self.encoding_type = encoding_type
58 1         if not lock:
59 1             self.lock = FakeLock()
60 1         elif lock is True:
61 1             self.lock = Lock()
62 0         elif isinstance(lock, Lock):
63 0             self.lock = lock
64         else:
65 0             raise ValueError("lock parameter must be a Lock classclass or boolean")
66
67 1     def db_connect(self, config, target_version=None):
68         """
69         Connect to database
70         :param config: Configuration of database. Contains among others:
71             host:   database host (mandatory)
72             port:   database port (mandatory)
73             name:   database name (mandatory)
74             user:   database username
75             password:   database password
76             commonkey: common OSM key used for sensible information encryption
77             materpassword: same as commonkey, for backward compatibility. Deprecated, to be removed in the future
78         :param target_version: if provided it checks if database contains required version, raising exception otherwise.
79         :return: None or raises DbException on error
80         """
81 0         raise DbException("Method 'db_connect' not implemented")
82
83 1     def db_disconnect(self):
84         """
85         Disconnect from database
86         :return: None
87         """
88 0         pass
89
90 1     def get_list(self, table, q_filter=None):
91         """
92         Obtain a list of entries matching q_filter
93         :param table: collection or table
94         :param q_filter: Filter
95         :return: a list (can be empty) with the found entries. Raises DbException on error
96         """
97 0         raise DbException("Method 'get_list' not implemented")
98
99 1     def count(self, table, q_filter=None):
100         """
101         Count the number of entries matching q_filter
102         :param table: collection or table
103         :param q_filter: Filter
104         :return: number of entries found (can be zero)
105         :raise: DbException on error
106         """
107 0         raise DbException("Method 'count' not implemented")
108
109 1     def get_one(self, table, q_filter=None, fail_on_empty=True, fail_on_more=True):
110         """
111         Obtain one entry matching q_filter
112         :param table: collection or table
113         :param q_filter: Filter
114         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
115         it raises a DbException
116         :param fail_on_more: If more than one matches filter it returns one of then unless this flag is set tu True, so
117         that it raises a DbException
118         :return: The requested element, or None
119         """
120 0         raise DbException("Method 'get_one' not implemented")
121
122 1     def del_list(self, table, q_filter=None):
123         """
124         Deletes all entries that match q_filter
125         :param table: collection or table
126         :param q_filter: Filter
127         :return: Dict with the number of entries deleted
128         """
129 0         raise DbException("Method 'del_list' not implemented")
130
131 1     def del_one(self, table, q_filter=None, fail_on_empty=True):
132         """
133         Deletes one entry that matches q_filter
134         :param table: collection or table
135         :param q_filter: Filter
136         :param fail_on_empty: If nothing matches filter it returns '0' deleted unless this flag is set tu True, in
137         which case it raises a DbException
138         :return: Dict with the number of entries deleted
139         """
140 0         raise DbException("Method 'del_one' not implemented")
141
142 1     def create(self, table, indata):
143         """
144         Add a new entry at database
145         :param table: collection or table
146         :param indata: content to be added
147         :return: database '_id' of the inserted element. Raises a DbException on error
148         """
149 0         raise DbException("Method 'create' not implemented")
150
151 1     def create_list(self, table, indata_list):
152         """
153         Add several entries at once
154         :param table: collection or table
155         :param indata_list: list of elements to insert. Each element must be a dictionary.
156             An '_id' key based on random uuid is added at each element if missing
157         :return: list of inserted '_id's. Exception on error
158         """
159 0         raise DbException("Method 'create_list' not implemented")
160
161 1     def set_one(
162         self,
163         table,
164         q_filter,
165         update_dict,
166         fail_on_empty=True,
167         unset=None,
168         pull=None,
169         push=None,
170         push_list=None,
171         pull_list=None,
172     ):
173         """
174         Modifies an entry at database
175         :param table: collection or table
176         :param q_filter: Filter
177         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
178         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
179         it raises a DbException
180         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
181                       ignored. If not exist, it is ignored
182         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
183                      if exist in the array is removed. If not exist, it is ignored
184         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
185                      is appended to the end of the array
186         :param pull_list: Same as pull but values are arrays where each item is removed from the array
187         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
188                           whole array
189         :return: Dict with the number of entries modified. None if no matching is found.
190         """
191 0         raise DbException("Method 'set_one' not implemented")
192
193 1     def set_list(
194         self,
195         table,
196         q_filter,
197         update_dict,
198         unset=None,
199         pull=None,
200         push=None,
201         push_list=None,
202         pull_list=None,
203     ):
204         """
205         Modifies al matching entries at database
206         :param table: collection or table
207         :param q_filter: Filter
208         :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
209         :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
210                       ignored. If not exist, it is ignored
211         :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
212                      if exist in the array is removed. If not exist, it is ignored
213         :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
214                      is appended to the end of the array
215         :param pull_list: Same as pull but values are arrays where each item is removed from the array
216         :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
217                           whole array
218         :return: Dict with the number of entries modified
219         """
220 0         raise DbException("Method 'set_list' not implemented")
221
222 1     def replace(self, table, _id, indata, fail_on_empty=True):
223         """
224         Replace the content of an entry
225         :param table: collection or table
226         :param _id: internal database id
227         :param indata: content to replace
228         :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
229         it raises a DbException
230         :return: Dict with the number of entries replaced
231         """
232 0         raise DbException("Method 'replace' not implemented")
233
234 1     def _join_secret_key(self, update_key):
235         """
236         Returns a xor byte combination of the internal secret_key and the provided update_key.
237         It does not modify the internal secret_key. Used for adding salt, join keys, etc.
238         :param update_key: Can be a string, byte or None. Recommended a long one (e.g. 32 byte length)
239         :return: joined key in bytes with a 32 bytes length. Can be None if both internal secret_key and update_key
240                  are None
241         """
242 1         if not update_key:
243 1             return self.secret_key
244 1         elif isinstance(update_key, str):
245 1             update_key_bytes = update_key.encode()
246         else:
247 1             update_key_bytes = update_key
248
249 1         new_secret_key = (
250             bytearray(self.secret_key) if self.secret_key else bytearray(32)
251         )
252 1         for i, b in enumerate(update_key_bytes):
253 1             new_secret_key[i % 32] ^= b
254 1         return bytes(new_secret_key)
255
256 1     def set_secret_key(self, new_secret_key, replace=False):
257         """
258         Updates internal secret_key used for encryption, with a byte xor
259         :param new_secret_key: string or byte array. It is recommended a 32 byte length
260         :param replace: if True, old value of internal secret_key is ignored and replaced. If false, a byte xor is used
261         :return: None
262         """
263 1         if replace:
264 1             self.secret_key = None
265 1         self.secret_key = self._join_secret_key(new_secret_key)
266
267 1     def get_secret_key(self):
268         """
269         Get the database secret key in case it is not done when "connect" is called. It can happens when database is
270         empty after an initial install. It should skip if secret is already obtained.
271         """
272 1         pass
273
274 1     @staticmethod
275 1     def pad_data(value: str) -> str:
276 1         if not isinstance(value, str):
277 1             raise DbException(
278                 f"Incorrect data type: type({value}), string is expected."
279             )
280 1         return value + ("\0" * ((16 - len(value)) % 16))
281
282 1     @staticmethod
283 1     def unpad_data(value: str) -> str:
284 1         if not isinstance(value, str):
285 1             raise DbException(
286                 f"Incorrect data type: type({value}), string is expected."
287             )
288 1         return value.rstrip("\0")
289
290 1     def _encrypt_value(self, value: str, schema_version: str, salt: str):
291         """Encrypt a value.
292
293         Args:
294             value   (str):                  value to be encrypted. It is string/unicode
295             schema_version  (str):          used for version control. If None or '1.0' no encryption is done.
296                                             If '1.1' symmetric AES encryption is done
297             salt    (str):                  optional salt to be used. Must be str
298
299         Returns:
300             Encrypted content of value  (str)
301
302         """
303 1         if not self.secret_key or not schema_version or schema_version == "1.0":
304 1             return value
305
306         else:
307             # Secret key as bytes
308 1             secret_key = self._join_secret_key(salt)
309 1             cipher = AES.new(secret_key, self.encrypt_mode)
310             # Padded data as string
311 1             padded_private_msg = self.pad_data(value)
312             # Padded data as bytes
313 1             padded_private_msg_bytes = padded_private_msg.encode(self.encoding_type)
314             # Encrypt padded data
315 1             encrypted_msg = cipher.encrypt(padded_private_msg_bytes)
316             # Base64 encoded encrypted data
317 1             encoded_encrypted_msg = b64encode(encrypted_msg)
318             # Converting to string
319 1             return encoded_encrypted_msg.decode(self.encoding_type)
320
321 1     def encrypt(self, value: str, schema_version: str = None, salt: str = None) -> str:
322         """Encrypt a value.
323
324         Args:
325             value   (str):                  value to be encrypted. It is string/unicode
326             schema_version  (str):          used for version control. If None or '1.0' no encryption is done.
327                                             If '1.1' symmetric AES encryption is done
328             salt    (str):                  optional salt to be used. Must be str
329
330         Returns:
331             Encrypted content of value  (str)
332
333         """
334 1         self.get_secret_key()
335 1         return self._encrypt_value(value, schema_version, salt)
336
337 1     def _decrypt_value(self, value: str, schema_version: str, salt: str) -> str:
338         """Decrypt an encrypted value.
339         Args:
340
341             value   (str):                      value to be decrypted. It is a base64 string
342             schema_version  (str):              used for known encryption method used.
343                                                 If None or '1.0' no encryption has been done.
344                                                 If '1.1' symmetric AES encryption has been done
345             salt    (str):                      optional salt to be used
346
347         Returns:
348             Plain content of value  (str)
349
350         """
351 1         if not self.secret_key or not schema_version or schema_version == "1.0":
352 1             return value
353
354         else:
355 1             secret_key = self._join_secret_key(salt)
356             # Decoding encrypted data, output bytes
357 1             encrypted_msg = b64decode(value)
358 1             cipher = AES.new(secret_key, self.encrypt_mode)
359             # Decrypted data, output bytes
360 1             decrypted_msg = cipher.decrypt(encrypted_msg)
361 1             try:
362                 # Converting to string
363 1                 private_msg = decrypted_msg.decode(self.encoding_type)
364 1             except UnicodeDecodeError:
365 1                 raise DbException(
366                     "Cannot decrypt information. Are you using same COMMONKEY in all OSM components?",
367                     http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
368                 )
369             # Unpadded data as string
370 1             return self.unpad_data(private_msg)
371
372 1     def decrypt(self, value: str, schema_version: str = None, salt: str = None) -> str:
373         """Decrypt an encrypted value.
374         Args:
375
376             value   (str):                      value to be decrypted. It is a base64 string
377             schema_version  (str):              used for known encryption method used.
378                                                 If None or '1.0' no encryption has been done.
379                                                 If '1.1' symmetric AES encryption has been done
380             salt    (str):                      optional salt to be used
381
382         Returns:
383             Plain content of value  (str)
384
385         """
386 1         self.get_secret_key()
387 1         return self._decrypt_value(value, schema_version, salt)
388
389 1     def encrypt_decrypt_fields(
390         self, item, action, fields=None, flags=None, schema_version=None, salt=None
391     ):
392 0         if not fields:
393 0             return
394 0         self.get_secret_key()
395 0         actions = ["encrypt", "decrypt"]
396 0         if action.lower() not in actions:
397 0             raise DbException(
398                 "Unknown action ({}): Must be one of {}".format(action, actions),
399                 http_code=HTTPStatus.INTERNAL_SERVER_ERROR,
400             )
401 0         method = self.encrypt if action.lower() == "encrypt" else self.decrypt
402 0         if flags is None:
403 0             flags = re.I
404
405 0         def process(_item):
406 0             if isinstance(_item, list):
407 0                 for elem in _item:
408 0                     process(elem)
409 0             elif isinstance(_item, dict):
410 0                 for key, val in _item.items():
411 0                     if isinstance(val, str):
412 0                         if any(re.search(f, key, flags) for f in fields):
413 0                             _item[key] = method(val, schema_version, salt)
414                     else:
415 0                         process(val)
416
417 0         process(item)
418
419
420 1 def deep_update_rfc7396(dict_to_change, dict_reference, key_list=None):
421     """
422     Modifies one dictionary with the information of the other following https://tools.ietf.org/html/rfc7396
423     Basically is a recursive python 'dict_to_change.update(dict_reference)', but a value of None is used to delete.
424     It implements an extra feature that allows modifying an array. RFC7396 only allows replacing the entire array.
425     For that, dict_reference should contains a dict with keys starting by "$" with the following meaning:
426         $[index]    <index> is an integer for targeting a concrete index from dict_to_change array. If the value is None
427                     the element of the array is deleted, otherwise it is edited.
428         $+[index]   The value is inserted at this <index>. A value of None has not sense and an exception is raised.
429         $+          The value is appended at the end. A value of None has not sense and an exception is raised.
430         $val        It looks for all the items in the array dict_to_change equal to <val>. <val> is evaluated as yaml,
431                     that is, numbers are taken as type int, true/false as boolean, etc. Use quotes to force string.
432                     Nothing happens if no match is found. If the value is None the matched elements are deleted.
433         $key: val   In case a dictionary is passed in yaml format, if looks for all items in the array dict_to_change
434                     that are dictionaries and contains this <key> equal to <val>. Several keys can be used by yaml
435                     format '{key: val, key: val, ...}'; and all of them must match. Nothing happens if no match is
436                     found. If value is None the matched items are deleted, otherwise they are edited.
437         $+val       If no match if found (see '$val'), the value is appended to the array. If any match is found nothing
438                     is changed. A value of None has not sense.
439         $+key: val  If no match if found (see '$key: val'), the value is appended to the array. If any match is found
440                     nothing is changed. A value of None has not sense.
441     If there are several editions, insertions and deletions; editions and deletions are done first in reverse index
442         order; then insertions also in reverse index order; and finally appends in any order. So indexes used at
443         insertions must take into account the deleted items.
444     :param dict_to_change:  Target dictionary to be changed.
445     :param dict_reference: Dictionary that contains changes to be applied.
446     :param key_list: This is used internally for recursive calls. Do not fill this parameter.
447     :return: none or raises and exception only at array modification when there is a bad format or conflict.
448     """
449
450 1     def _deep_update_array(array_to_change, _dict_reference, _key_list):
451 1         to_append = {}
452 1         to_insert_at_index = {}
453 1         values_to_edit_delete = {}
454 1         indexes_to_edit_delete = []
455 1         array_edition = None
456 1         _key_list.append("")
457 1         for k in _dict_reference:
458 1             _key_list[-1] = str(k)
459 1             if not isinstance(k, str) or not k.startswith("$"):
460 1                 if array_edition is True:
461 1                     raise DbException(
462                         "Found array edition (keys starting with '$') and pure dictionary edition in the"
463                         " same dict at '{}'".format(":".join(_key_list[:-1]))
464                     )
465 1                 array_edition = False
466 1                 continue
467             else:
468 1                 if array_edition is False:
469 0                     raise DbException(
470                         "Found array edition (keys starting with '$') and pure dictionary edition in the"
471                         " same dict at '{}'".format(":".join(_key_list[:-1]))
472                     )
473 1                 array_edition = True
474 1             insert = False
475 1             indexes = []  # indexes to edit or insert
476 1             kitem = k[1:]
477 1             if kitem.startswith("+"):
478 1                 insert = True
479 1                 kitem = kitem[1:]
480 1                 if _dict_reference[k] is None:
481 1                     raise DbException(
482                         "A value of None has not sense for insertions at '{}'".format(
483                             ":".join(_key_list)
484                         )
485                     )
486
487 1             if kitem.startswith("[") and kitem.endswith("]"):
488 1                 try:
489 1                     index = int(kitem[1:-1])
490 1                     if index < 0:
491 1                         index += len(array_to_change)
492 1                     if index < 0:
493 0                         index = 0  # skip outside index edition
494 1                     indexes.append(index)
495 0                 except Exception:
496 0                     raise DbException(
497                         "Wrong format at '{}'. Expecting integer index inside quotes".format(
498                             ":".join(_key_list)
499                         )
500                     )
501 1             elif kitem:
502                 # match_found_skip = False
503 1                 try:
504 1                     filter_in = yaml.safe_load(kitem)
505 1                 except Exception:
506 1                     raise DbException(
507                         "Wrong format at '{}'. Expecting '$<yaml-format>'".format(
508                             ":".join(_key_list)
509                         )
510                     )
511 1                 if isinstance(filter_in, dict):
512 1                     for index, item in enumerate(array_to_change):
513 1                         for filter_k, filter_v in filter_in.items():
514 1                             if (
515                                 not isinstance(item, dict)
516                                 or filter_k not in item
517                                 or item[filter_k] != filter_v
518                             ):
519 1                                 break
520                         else:  # match found
521 1                             if insert:
522                                 # match_found_skip = True
523 0                                 insert = False
524 0                                 break
525                             else:
526 1                                 indexes.append(index)
527                 else:
528 1                     index = 0
529 1                     try:
530 1                         while True:  # if not match a ValueError exception will be raise
531 1                             index = array_to_change.index(filter_in, index)
532 1                             if insert:
533                                 # match_found_skip = True
534 1                                 insert = False
535 1                                 break
536 1                             indexes.append(index)
537 1                             index += 1
538 1                     except ValueError:
539 1                         pass
540
541                 # if match_found_skip:
542                 #     continue
543 1             elif not insert:
544 1                 raise DbException(
545                     "Wrong format at '{}'. Expecting '$+', '$[<index]' or '$[<filter>]'".format(
546                         ":".join(_key_list)
547                     )
548                 )
549 1             for index in indexes:
550 1                 if insert:
551 1                     if (
552                         index in to_insert_at_index
553                         and to_insert_at_index[index] != _dict_reference[k]
554                     ):
555                         # Several different insertions on the same item of the array
556 0                         raise DbException(
557                             "Conflict at '{}'. Several insertions on same array index {}".format(
558                                 ":".join(_key_list), index
559                             )
560                         )
561 1                     to_insert_at_index[index] = _dict_reference[k]
562                 else:
563 1                     if (
564                         index in indexes_to_edit_delete
565                         and values_to_edit_delete[index] != _dict_reference[k]
566                     ):
567                         # Several different editions on the same item of the array
568 1                         raise DbException(
569                             "Conflict at '{}'. Several editions on array index {}".format(
570                                 ":".join(_key_list), index
571                             )
572                         )
573 1                     indexes_to_edit_delete.append(index)
574 1                     values_to_edit_delete[index] = _dict_reference[k]
575 1             if not indexes:
576 1                 if insert:
577 1                     to_append[k] = _dict_reference[k]
578                 # elif _dict_reference[k] is not None:
579                 #     raise DbException("Not found any match to edit in the array, or wrong format at '{}'".format(
580                 #         ":".join(_key_list)))
581
582         # edition/deletion is done before insertion
583 1         indexes_to_edit_delete.sort(reverse=True)
584 1         for index in indexes_to_edit_delete:
585 1             _key_list[-1] = str(index)
586 1             try:
587 1                 if values_to_edit_delete[index] is None:  # None->Anything
588 1                     try:
589 1                         del array_to_change[index]
590 1                     except IndexError:
591 1                         pass  # it is not consider an error if this index does not exist
592 1                 elif not isinstance(
593                     values_to_edit_delete[index], dict
594                 ):  # NotDict->Anything
595 1                     array_to_change[index] = deepcopy(values_to_edit_delete[index])
596 1                 elif isinstance(array_to_change[index], dict):  # Dict->Dict
597 1                     deep_update_rfc7396(
598                         array_to_change[index], values_to_edit_delete[index], _key_list
599                     )
600                 else:  # Dict->NotDict
601 1                     if isinstance(
602                         array_to_change[index], list
603                     ):  # Dict->List. Check extra array edition
604 1                         if _deep_update_array(
605                             array_to_change[index],
606                             values_to_edit_delete[index],
607                             _key_list,
608                         ):
609 1                             continue
610 1                     array_to_change[index] = deepcopy(values_to_edit_delete[index])
611                     # calling deep_update_rfc7396 to delete the None values
612 1                     deep_update_rfc7396(
613                         array_to_change[index], values_to_edit_delete[index], _key_list
614                     )
615 1             except IndexError:
616 1                 raise DbException(
617                     "Array edition index out of range at '{}'".format(
618                         ":".join(_key_list)
619                     )
620                 )
621
622         # insertion with indexes
623 1         to_insert_indexes = list(to_insert_at_index.keys())
624 1         to_insert_indexes.sort(reverse=True)
625 1         for index in to_insert_indexes:
626 1             array_to_change.insert(index, to_insert_at_index[index])
627
628         # append
629 1         for k, insert_value in to_append.items():
630 1             _key_list[-1] = str(k)
631 1             insert_value_copy = deepcopy(insert_value)
632 1             if isinstance(insert_value_copy, dict):
633                 # calling deep_update_rfc7396 to delete the None values
634 0                 deep_update_rfc7396(insert_value_copy, insert_value, _key_list)
635 1             array_to_change.append(insert_value_copy)
636
637 1         _key_list.pop()
638 1         if array_edition:
639 1             return True
640 1         return False
641
642 1     if key_list is None:
643 1         key_list = []
644 1     key_list.append("")
645 1     for k in dict_reference:
646 1         key_list[-1] = str(k)
647 1         if dict_reference[k] is None:  # None->Anything
648 1             if k in dict_to_change:
649 1                 del dict_to_change[k]
650 1         elif not isinstance(dict_reference[k], dict):  # NotDict->Anything
651 1             dict_to_change[k] = deepcopy(dict_reference[k])
652 1         elif k not in dict_to_change:  # Dict->Empty
653 1             dict_to_change[k] = deepcopy(dict_reference[k])
654             # calling deep_update_rfc7396 to delete the None values
655 1             deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
656 1         elif isinstance(dict_to_change[k], dict):  # Dict->Dict
657 1             deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
658         else:  # Dict->NotDict
659 1             if isinstance(
660                 dict_to_change[k], list
661             ):  # Dict->List. Check extra array edition
662 1                 if _deep_update_array(dict_to_change[k], dict_reference[k], key_list):
663 1                     continue
664 1             dict_to_change[k] = deepcopy(dict_reference[k])
665             # calling deep_update_rfc7396 to delete the None values
666 1             deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
667 1     key_list.pop()
668
669
670 1 def deep_update(dict_to_change, dict_reference):
671     """Maintained for backward compatibility. Use deep_update_rfc7396 instead"""
672 1     return deep_update_rfc7396(dict_to_change, dict_reference)
673
674
675 1 class Encryption(DbBase):
676 1     def __init__(self, uri, config, encoding_type="ascii", logger_name="db"):
677         """Constructor.
678
679         Args:
680             uri (str):              Connection string to connect to the database.
681             config  (dict):         Additional database info
682             encoding_type   (str):  ascii, utf-8 etc.
683             logger_name (str):      Logger name
684
685         """
686 1         self._secret_key = None  # 32 bytes length array used for encrypt/decrypt
687 1         self.encrypt_mode = AES.MODE_ECB
688 1         super(Encryption, self).__init__(
689             encoding_type=encoding_type, logger_name=logger_name
690         )
691 1         self._client = AsyncIOMotorClient(uri)
692 1         self._config = config
693
694 1     @property
695 1     def secret_key(self):
696 1         return self._secret_key
697
698 1     @secret_key.setter
699 1     def secret_key(self, value):
700 1         self._secret_key = value
701
702 1     @property
703 1     def _database(self):
704 1         return self._client[DB_NAME]
705
706 1     @property
707 1     def _admin_collection(self):
708 1         return self._database["admin"]
709
710 1     @property
711 1     def database_key(self):
712 1         return self._config.get("database_commonkey")
713
714 1     async def decrypt_fields(
715         self,
716         item: dict,
717         fields: typing.List[str],
718         schema_version: str = None,
719         salt: str = None,
720     ) -> None:
721         """Decrypt fields from a dictionary. Follows the same logic as in osm_common.
722
723         Args:
724
725             item    (dict):         Dictionary with the keys to be decrypted
726             fields  (list):          List of keys to decrypt
727             schema version  (str):  Schema version. (i.e. 1.11)
728             salt    (str):          Salt for the decryption
729
730         """
731 1         flags = re.I
732
733 1         async def process(_item):
734 1             if isinstance(_item, list):
735 0                 for elem in _item:
736 0                     await process(elem)
737 1             elif isinstance(_item, dict):
738 1                 for key, val in _item.items():
739 1                     if isinstance(val, str):
740 1                         if any(re.search(f, key, flags) for f in fields):
741 1                             _item[key] = await self.decrypt(val, schema_version, salt)
742                     else:
743 0                         await process(val)
744
745 1         await process(item)
746
747 1     async def encrypt(
748         self, value: str, schema_version: str = None, salt: str = None
749     ) -> str:
750         """Encrypt a value.
751
752         Args:
753             value   (str):                  value to be encrypted. It is string/unicode
754             schema_version  (str):          used for version control. If None or '1.0' no encryption is done.
755                                             If '1.1' symmetric AES encryption is done
756             salt    (str):                  optional salt to be used. Must be str
757
758         Returns:
759             Encrypted content of value  (str)
760
761         """
762 1         await self.get_secret_key()
763 1         return self._encrypt_value(value, schema_version, salt)
764
765 1     async def decrypt(
766         self, value: str, schema_version: str = None, salt: str = None
767     ) -> str:
768         """Decrypt an encrypted value.
769         Args:
770
771             value   (str):                      value to be decrypted. It is a base64 string
772             schema_version  (str):              used for known encryption method used.
773                                                 If None or '1.0' no encryption has been done.
774                                                 If '1.1' symmetric AES encryption has been done
775             salt    (str):                      optional salt to be used
776
777         Returns:
778             Plain content of value  (str)
779
780         """
781 1         await self.get_secret_key()
782 1         return self._decrypt_value(value, schema_version, salt)
783
784 1     def _join_secret_key(self, update_key: typing.Any) -> bytes:
785         """Join key with secret key.
786
787         Args:
788
789         update_key  (str or bytes):    str or bytes with the to update
790
791         Returns:
792
793             Joined key  (bytes)
794         """
795 1         return self._join_keys(update_key, self.secret_key)
796
797 1     def _join_keys(self, key: typing.Any, secret_key: bytes) -> bytes:
798         """Join key with secret_key.
799
800         Args:
801
802             key (str or bytes):         str or bytes of the key to update
803             secret_key  (bytes):         bytes of the secret key
804
805         Returns:
806
807             Joined key  (bytes)
808         """
809 1         if isinstance(key, str):
810 1             update_key_bytes = key.encode(self.encoding_type)
811         else:
812 1             update_key_bytes = key
813 1         new_secret_key = bytearray(secret_key) if secret_key else bytearray(32)
814 1         for i, b in enumerate(update_key_bytes):
815 1             new_secret_key[i % 32] ^= b
816 1         return bytes(new_secret_key)
817
818 1     async def get_secret_key(self):
819         """Get secret key using the database key and the serial key in the DB.
820         The key is populated in the property self.secret_key.
821         """
822 1         if self.secret_key:
823 1             return
824 1         secret_key = None
825 1         if self.database_key:
826 1             secret_key = self._join_keys(self.database_key, None)
827 1         version_data = await self._admin_collection.find_one({"_id": "version"})
828 1         if version_data and version_data.get("serial"):
829 1             secret_key = self._join_keys(b64decode(version_data["serial"]), secret_key)
830 1         self._secret_key = secret_key