You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

249 lines
7.6 KiB

8 years ago
8 years ago
8 years ago
  1. # -*- coding: utf-8 -*-
  2. # © 2016 Akretion Mourad EL HADJ MIMOUNE, David BEAL, Raphaël REVERDY
  3. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
  4. from functools import wraps
  5. import logging
  6. import json
  7. from odoo import models, fields, api
  8. from odoo.exceptions import ValidationError, UserError
  9. from odoo.tools.config import config
  10. from odoo.tools.translate import _
  11. _logger = logging.getLogger(__name__)
  12. try:
  13. from cryptography.fernet import Fernet, MultiFernet, InvalidToken
  14. except ImportError as err:
  15. _logger.debug(err)
  16. def implemented_by_keychain(func):
  17. """Call a prefixed function based on 'namespace'."""
  18. @wraps(func)
  19. def wrapper(cls, *args, **kwargs):
  20. fun_name = func.__name__
  21. fun = '_%s%s' % (cls.namespace, fun_name)
  22. if not hasattr(cls, fun):
  23. fun = '_default%s' % (fun_name)
  24. return getattr(cls, fun)(*args, **kwargs)
  25. return wrapper
  26. class KeychainAccount(models.Model):
  27. """Manage all accounts of external systems in one place."""
  28. _name = 'keychain.account'
  29. name = fields.Char(required=True, help="Humain readable label")
  30. technical_name = fields.Char(
  31. required=True,
  32. help="Technical name. Must be unique")
  33. namespace = fields.Selection(selection=[],
  34. help="Type of account",
  35. required=True)
  36. environment = fields.Char(
  37. required=False,
  38. help="'prod', 'dev', etc. or empty (for all)"
  39. )
  40. login = fields.Char(help="Login")
  41. clear_password = fields.Text(
  42. help="Password. Leave empty if no changes",
  43. inverse='_inverse_set_password',
  44. compute='_compute_password',
  45. store=False)
  46. password = fields.Text(
  47. help="Password is derived from clear_password",
  48. readonly=True)
  49. data = fields.Text(help="Additionnal data as json")
  50. def _compute_password(self):
  51. # Only needed in v8 for _description_searchable issues
  52. return True
  53. def _get_password(self):
  54. """Password in clear text."""
  55. try:
  56. return self._decode_password(self.password)
  57. except UserError as warn:
  58. raise UserError(_(
  59. "%s \n"
  60. "Account: %s %s %s " % (
  61. warn,
  62. self.login, self.name, self.technical_name
  63. )
  64. ))
  65. def get_data(self):
  66. """Data in dict form."""
  67. return self._parse_data(self.data)
  68. @api.constrains('data')
  69. def _check_data(self):
  70. """Ensure valid input in data field."""
  71. for account in self:
  72. if account.data:
  73. parsed = account._parse_data(account.data)
  74. if not account._validate_data(parsed):
  75. raise ValidationError(_("Data not valid"))
  76. def _inverse_set_password(self):
  77. """Encode password from clear text."""
  78. # inverse function
  79. for rec in self:
  80. rec.password = rec._encode_password(
  81. rec.clear_password, rec.environment)
  82. @api.model
  83. def retrieve(self, domain):
  84. """Search accounts for a given domain.
  85. Environment is added by this function.
  86. Use this instead of search() to benefit from environment filtering.
  87. Use user.has_group() and suspend_security() before
  88. calling this method.
  89. """
  90. domain.append(['environment', 'in', self._retrieve_env()])
  91. return self.search(domain)
  92. @api.multi
  93. def write(self, vals):
  94. """At this time there is no namespace set."""
  95. if not vals.get('data') and not self.data:
  96. vals['data'] = self._serialize_data(self._init_data())
  97. return super(KeychainAccount, self).write(vals)
  98. @implemented_by_keychain
  99. def _validate_data(self, data):
  100. """Ensure data is valid according to the namespace.
  101. How to use:
  102. - Create a method prefixed with your namespace
  103. - Put your validation logic inside
  104. - Return true if data is valid for your usage
  105. This method will be called on write().
  106. If false is returned an user error will be raised.
  107. Example:
  108. def _hereismynamspace_validate_data():
  109. return len(data.get('some_param', '') > 6)
  110. @params data dict
  111. @returns boolean
  112. """
  113. pass
  114. def _default_validate_data(self, data):
  115. """Default validation.
  116. By default says data is always valid.
  117. See _validata_data() for more information.
  118. """
  119. return True
  120. @implemented_by_keychain
  121. def _init_data(self):
  122. """Initialize data field.
  123. How to use:
  124. - Create a method prefixed with your namespace
  125. - Return a dict with the keys and may be default
  126. values your expect.
  127. This method will be called on write().
  128. Example:
  129. def _hereismynamspace_init_data():
  130. return { 'some_param': 'default_value' }
  131. @returns dict
  132. """
  133. pass
  134. def _default_init_data(self):
  135. """Default initialization.
  136. See _init_data() for more information.
  137. """
  138. return {}
  139. @staticmethod
  140. def _retrieve_env():
  141. """Return the current environments.
  142. You may override this function to fit your needs.
  143. returns: a tuple like:
  144. ('dev', 'test', False)
  145. Which means accounts for dev, test and blank (not set)
  146. Order is important: the first one is used for encryption.
  147. """
  148. current = config.get('running_env') or False
  149. envs = [current]
  150. if False not in envs:
  151. envs.append(False)
  152. return envs
  153. @staticmethod
  154. def _serialize_data(data):
  155. return json.dumps(data)
  156. @staticmethod
  157. def _parse_data(data):
  158. try:
  159. return json.loads(data)
  160. except ValueError:
  161. raise ValidationError(_("Data should be a valid JSON"))
  162. @classmethod
  163. def _encode_password(cls, data, env):
  164. cipher = cls._get_cipher(env)
  165. return cipher.encrypt(str((data or '').encode('UTF-8')))
  166. @classmethod
  167. def _decode_password(cls, data):
  168. cipher = cls._get_cipher()
  169. try:
  170. return unicode(cipher.decrypt(str(data)), 'UTF-8')
  171. except InvalidToken:
  172. raise UserError(_(
  173. "Password has been encrypted with a different "
  174. "key. Unless you can recover the previous key, "
  175. "this password is unreadable."
  176. ))
  177. @classmethod
  178. def _get_cipher(cls, force_env=None):
  179. """Return a cipher using the keys of environments.
  180. force_env = name of the env key.
  181. Useful for encoding against one precise env
  182. """
  183. def _get_keys(envs):
  184. suffixes = [
  185. '_%s' % env if env else ''
  186. for env in envs] # ('_dev', '')
  187. keys_name = [
  188. 'keychain_key%s' % suf
  189. for suf in suffixes] # prefix it
  190. keys_str = [
  191. config.get(key)
  192. for key in keys_name] # fetch from config
  193. return [
  194. Fernet(key) for key in keys_str # build Fernet object
  195. if key and len(key) > 0 # remove False values
  196. ]
  197. if force_env:
  198. envs = [force_env]
  199. else:
  200. envs = cls._retrieve_env() # ex: ('dev', False)
  201. keys = _get_keys(envs)
  202. if len(keys) == 0:
  203. raise UserError(_(
  204. "No 'keychain_key_%s' entries found in config file. "
  205. "Use a key similar to: %s" % (envs[0], Fernet.generate_key())
  206. ))
  207. return MultiFernet(keys)