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