allow dabase update with pushlist
[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 push_list=None):
156 """
157 Modifies an entry at database
158 :param table: collection or table
159 :param q_filter: Filter
160 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
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 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
164 ignored. If not exist, it is ignored
165 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
166 if exist in the array is removed. If not exist, it is ignored
167 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
168 is appended to the end of the array
169 :param push_list: Same as push but values are arrays where each item is and appended instead of appending the
170 whole array
171 :return: Dict with the number of entries modified. None if no matching is found.
172 """
173 raise DbException("Method 'set_one' not implemented")
174
175 def set_list(self, table, q_filter, update_dict, unset=None, pull=None, push=None, push_list=None):
176 """
177 Modifies al matching entries at database
178 :param table: collection or table
179 :param q_filter: Filter
180 :param update_dict: Plain dictionary with the content to be updated. It is a dot separated keys and a value
181 :param unset: Plain dictionary with the content to be removed if exist. It is a dot separated keys, value is
182 ignored. If not exist, it is ignored
183 :param pull: Plain dictionary with the content to be removed from an array. It is a dot separated keys and value
184 if exist in the array is removed. If not exist, it is ignored
185 :param push: Plain dictionary with the content to be appended to an array. It is a dot separated keys and value
186 is appended to the end of 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
190 """
191 raise DbException("Method 'set_list' not implemented")
192
193 def replace(self, table, _id, indata, fail_on_empty=True):
194 """
195 Replace the content of an entry
196 :param table: collection or table
197 :param _id: internal database id
198 :param indata: content to replace
199 :param fail_on_empty: If nothing matches filter it returns None unless this flag is set tu True, in which case
200 it raises a DbException
201 :return: Dict with the number of entries replaced
202 """
203 raise DbException("Method 'replace' not implemented")
204
205 def _join_secret_key(self, update_key):
206 """
207 Returns a xor byte combination of the internal secret_key and the provided update_key.
208 It does not modify the internal secret_key. Used for adding salt, join keys, etc.
209 :param update_key: Can be a string, byte or None. Recommended a long one (e.g. 32 byte length)
210 :return: joined key in bytes with a 32 bytes length. Can be None if both internal secret_key and update_key
211 are None
212 """
213 if not update_key:
214 return self.secret_key
215 elif isinstance(update_key, str):
216 update_key_bytes = update_key.encode()
217 else:
218 update_key_bytes = update_key
219
220 new_secret_key = bytearray(self.secret_key) if self.secret_key else bytearray(32)
221 for i, b in enumerate(update_key_bytes):
222 new_secret_key[i % 32] ^= b
223 return bytes(new_secret_key)
224
225 def set_secret_key(self, new_secret_key, replace=False):
226 """
227 Updates internal secret_key used for encryption, with a byte xor
228 :param new_secret_key: string or byte array. It is recommended a 32 byte length
229 :param replace: if True, old value of internal secret_key is ignored and replaced. If false, a byte xor is used
230 :return: None
231 """
232 if replace:
233 self.secret_key = None
234 self.secret_key = self._join_secret_key(new_secret_key)
235
236 def get_secret_key(self):
237 """
238 Get the database secret key in case it is not done when "connect" is called. It can happens when database is
239 empty after an initial install. It should skip if secret is already obtained.
240 """
241 pass
242
243 def encrypt(self, value, schema_version=None, salt=None):
244 """
245 Encrypt a value
246 :param value: value to be encrypted. It is string/unicode
247 :param schema_version: used for version control. If None or '1.0' no encryption is done.
248 If '1.1' symmetric AES encryption is done
249 :param salt: optional salt to be used. Must be str
250 :return: Encrypted content of value
251 """
252 self.get_secret_key()
253 if not self.secret_key or not schema_version or schema_version == '1.0':
254 return value
255 else:
256 secret_key = self._join_secret_key(salt)
257 cipher = AES.new(secret_key)
258 padded_private_msg = value + ('\0' * ((16-len(value)) % 16))
259 encrypted_msg = cipher.encrypt(padded_private_msg)
260 encoded_encrypted_msg = b64encode(encrypted_msg)
261 return encoded_encrypted_msg.decode("ascii")
262
263 def decrypt(self, value, schema_version=None, salt=None):
264 """
265 Decrypt an encrypted value
266 :param value: value to be decrypted. It is a base64 string
267 :param schema_version: used for known encryption method used. If None or '1.0' no encryption has been done.
268 If '1.1' symmetric AES encryption has been done
269 :param salt: optional salt to be used
270 :return: Plain content of value
271 """
272 self.get_secret_key()
273 if not self.secret_key or not schema_version or schema_version == '1.0':
274 return value
275 else:
276 secret_key = self._join_secret_key(salt)
277 encrypted_msg = b64decode(value)
278 cipher = AES.new(secret_key)
279 decrypted_msg = cipher.decrypt(encrypted_msg)
280 try:
281 unpadded_private_msg = decrypted_msg.decode().rstrip('\0')
282 except UnicodeDecodeError:
283 raise DbException("Cannot decrypt information. Are you using same COMMONKEY in all OSM components?",
284 http_code=HTTPStatus.INTERNAL_SERVER_ERROR)
285 return unpadded_private_msg
286
287 def encrypt_decrypt_fields(self, item, action, fields=None, flags=None, schema_version=None, salt=None):
288 if not fields:
289 return
290 self.get_secret_key()
291 actions = ['encrypt', 'decrypt']
292 if action.lower() not in actions:
293 raise DbException("Unknown action ({}): Must be one of {}".format(action, actions),
294 http_code=HTTPStatus.INTERNAL_SERVER_ERROR)
295 method = self.encrypt if action.lower() == 'encrypt' else self.decrypt
296 if flags is None:
297 flags = re.I
298
299 def process(_item):
300 if isinstance(_item, list):
301 for elem in _item:
302 process(elem)
303 elif isinstance(_item, dict):
304 for key, val in _item.items():
305 if isinstance(val, str):
306 if any(re.search(f, key, flags) for f in fields):
307 _item[key] = method(val, schema_version, salt)
308 else:
309 process(val)
310 process(item)
311
312
313 def deep_update_rfc7396(dict_to_change, dict_reference, key_list=None):
314 """
315 Modifies one dictionary with the information of the other following https://tools.ietf.org/html/rfc7396
316 Basically is a recursive python 'dict_to_change.update(dict_reference)', but a value of None is used to delete.
317 It implements an extra feature that allows modifying an array. RFC7396 only allows replacing the entire array.
318 For that, dict_reference should contains a dict with keys starting by "$" with the following meaning:
319 $[index] <index> is an integer for targeting a concrete index from dict_to_change array. If the value is None
320 the element of the array is deleted, otherwise it is edited.
321 $+[index] The value is inserted at this <index>. A value of None has not sense and an exception is raised.
322 $+ The value is appended at the end. A value of None has not sense and an exception is raised.
323 $val It looks for all the items in the array dict_to_change equal to <val>. <val> is evaluated as yaml,
324 that is, numbers are taken as type int, true/false as boolean, etc. Use quotes to force string.
325 Nothing happens if no match is found. If the value is None the matched elements are deleted.
326 $key: val In case a dictionary is passed in yaml format, if looks for all items in the array dict_to_change
327 that are dictionaries and contains this <key> equal to <val>. Several keys can be used by yaml
328 format '{key: val, key: val, ...}'; and all of them must match. Nothing happens if no match is
329 found. If value is None the matched items are deleted, otherwise they are edited.
330 $+val If no match if found (see '$val'), the value is appended to the array. If any match is found nothing
331 is changed. A value of None has not sense.
332 $+key: val If no match if found (see '$key: val'), the value is appended to the array. If any match is found
333 nothing is changed. A value of None has not sense.
334 If there are several editions, insertions and deletions; editions and deletions are done first in reverse index
335 order; then insertions also in reverse index order; and finally appends in any order. So indexes used at
336 insertions must take into account the deleted items.
337 :param dict_to_change: Target dictionary to be changed.
338 :param dict_reference: Dictionary that contains changes to be applied.
339 :param key_list: This is used internally for recursive calls. Do not fill this parameter.
340 :return: none or raises and exception only at array modification when there is a bad format or conflict.
341 """
342 def _deep_update_array(array_to_change, _dict_reference, _key_list):
343 to_append = {}
344 to_insert_at_index = {}
345 values_to_edit_delete = {}
346 indexes_to_edit_delete = []
347 array_edition = None
348 _key_list.append("")
349 for k in _dict_reference:
350 _key_list[-1] = str(k)
351 if not isinstance(k, str) or not k.startswith("$"):
352 if array_edition is True:
353 raise DbException("Found array edition (keys starting with '$') and pure dictionary edition in the"
354 " same dict at '{}'".format(":".join(_key_list[:-1])))
355 array_edition = False
356 continue
357 else:
358 if array_edition is False:
359 raise DbException("Found array edition (keys starting with '$') and pure dictionary edition in the"
360 " same dict at '{}'".format(":".join(_key_list[:-1])))
361 array_edition = True
362 insert = False
363 indexes = [] # indexes to edit or insert
364 kitem = k[1:]
365 if kitem.startswith('+'):
366 insert = True
367 kitem = kitem[1:]
368 if _dict_reference[k] is None:
369 raise DbException("A value of None has not sense for insertions at '{}'".format(
370 ":".join(_key_list)))
371
372 if kitem.startswith('[') and kitem.endswith(']'):
373 try:
374 index = int(kitem[1:-1])
375 if index < 0:
376 index += len(array_to_change)
377 if index < 0:
378 index = 0 # skip outside index edition
379 indexes.append(index)
380 except Exception:
381 raise DbException("Wrong format at '{}'. Expecting integer index inside quotes".format(
382 ":".join(_key_list)))
383 elif kitem:
384 # match_found_skip = False
385 try:
386 filter_in = yaml.safe_load(kitem)
387 except Exception:
388 raise DbException("Wrong format at '{}'. Expecting '$<yaml-format>'".format(":".join(_key_list)))
389 if isinstance(filter_in, dict):
390 for index, item in enumerate(array_to_change):
391 for filter_k, filter_v in filter_in.items():
392 if not isinstance(item, dict) or filter_k not in item or item[filter_k] != filter_v:
393 break
394 else: # match found
395 if insert:
396 # match_found_skip = True
397 insert = False
398 break
399 else:
400 indexes.append(index)
401 else:
402 index = 0
403 try:
404 while True: # if not match a ValueError exception will be raise
405 index = array_to_change.index(filter_in, index)
406 if insert:
407 # match_found_skip = True
408 insert = False
409 break
410 indexes.append(index)
411 index += 1
412 except ValueError:
413 pass
414
415 # if match_found_skip:
416 # continue
417 elif not insert:
418 raise DbException("Wrong format at '{}'. Expecting '$+', '$[<index]' or '$[<filter>]'".format(
419 ":".join(_key_list)))
420 for index in indexes:
421 if insert:
422 if index in to_insert_at_index and to_insert_at_index[index] != _dict_reference[k]:
423 # Several different insertions on the same item of the array
424 raise DbException("Conflict at '{}'. Several insertions on same array index {}".format(
425 ":".join(_key_list), index))
426 to_insert_at_index[index] = _dict_reference[k]
427 else:
428 if index in indexes_to_edit_delete and values_to_edit_delete[index] != _dict_reference[k]:
429 # Several different editions on the same item of the array
430 raise DbException("Conflict at '{}'. Several editions on array index {}".format(
431 ":".join(_key_list), index))
432 indexes_to_edit_delete.append(index)
433 values_to_edit_delete[index] = _dict_reference[k]
434 if not indexes:
435 if insert:
436 to_append[k] = _dict_reference[k]
437 # elif _dict_reference[k] is not None:
438 # raise DbException("Not found any match to edit in the array, or wrong format at '{}'".format(
439 # ":".join(_key_list)))
440
441 # edition/deletion is done before insertion
442 indexes_to_edit_delete.sort(reverse=True)
443 for index in indexes_to_edit_delete:
444 _key_list[-1] = str(index)
445 try:
446 if values_to_edit_delete[index] is None: # None->Anything
447 try:
448 del (array_to_change[index])
449 except IndexError:
450 pass # it is not consider an error if this index does not exist
451 elif not isinstance(values_to_edit_delete[index], dict): # NotDict->Anything
452 array_to_change[index] = deepcopy(values_to_edit_delete[index])
453 elif isinstance(array_to_change[index], dict): # Dict->Dict
454 deep_update_rfc7396(array_to_change[index], values_to_edit_delete[index], _key_list)
455 else: # Dict->NotDict
456 if isinstance(array_to_change[index], list): # Dict->List. Check extra array edition
457 if _deep_update_array(array_to_change[index], values_to_edit_delete[index], _key_list):
458 continue
459 array_to_change[index] = deepcopy(values_to_edit_delete[index])
460 # calling deep_update_rfc7396 to delete the None values
461 deep_update_rfc7396(array_to_change[index], values_to_edit_delete[index], _key_list)
462 except IndexError:
463 raise DbException("Array edition index out of range at '{}'".format(":".join(_key_list)))
464
465 # insertion with indexes
466 to_insert_indexes = list(to_insert_at_index.keys())
467 to_insert_indexes.sort(reverse=True)
468 for index in to_insert_indexes:
469 array_to_change.insert(index, to_insert_at_index[index])
470
471 # append
472 for k, insert_value in to_append.items():
473 _key_list[-1] = str(k)
474 insert_value_copy = deepcopy(insert_value)
475 if isinstance(insert_value_copy, dict):
476 # calling deep_update_rfc7396 to delete the None values
477 deep_update_rfc7396(insert_value_copy, insert_value, _key_list)
478 array_to_change.append(insert_value_copy)
479
480 _key_list.pop()
481 if array_edition:
482 return True
483 return False
484
485 if key_list is None:
486 key_list = []
487 key_list.append("")
488 for k in dict_reference:
489 key_list[-1] = str(k)
490 if dict_reference[k] is None: # None->Anything
491 if k in dict_to_change:
492 del dict_to_change[k]
493 elif not isinstance(dict_reference[k], dict): # NotDict->Anything
494 dict_to_change[k] = deepcopy(dict_reference[k])
495 elif k not in dict_to_change: # Dict->Empty
496 dict_to_change[k] = deepcopy(dict_reference[k])
497 # calling deep_update_rfc7396 to delete the None values
498 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
499 elif isinstance(dict_to_change[k], dict): # Dict->Dict
500 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
501 else: # Dict->NotDict
502 if isinstance(dict_to_change[k], list): # Dict->List. Check extra array edition
503 if _deep_update_array(dict_to_change[k], dict_reference[k], key_list):
504 continue
505 dict_to_change[k] = deepcopy(dict_reference[k])
506 # calling deep_update_rfc7396 to delete the None values
507 deep_update_rfc7396(dict_to_change[k], dict_reference[k], key_list)
508 key_list.pop()
509
510
511 def deep_update(dict_to_change, dict_reference):
512 """ Maintained for backward compatibility. Use deep_update_rfc7396 instead"""
513 return deep_update_rfc7396(dict_to_change, dict_reference)