''' This module provide an abstraction to the clients configuration directory. The client configuration directory contains a list of ``.json`` files, each file contains the configuration for one client. The username of the client is the filename (excluding the extension). The schema of the json file is described below:: { 'password': '', 'role': '', 'tags': {'tag1': 'value'}, 'perms': Null } Usage example: >>> conf = CCConf('/etc/cloudcontrol/clients') >>> conf.create_account(login='rms', password='secret', role='cli') >>> conf.create_account(login='server42', password='secret', role='node') >>> print conf.authentify('server42', 'pouet') None >>> print conf.authentify('server42', 'secret') u'node' >>> conf.add_tag('rms', 'admin') >>> conf.show('rms') {'password': 'secret' 'role': 'cli', 'tags': {}, 'perms': None} >>> conf.remove_account('rms') >>> ''' import hashlib import base64 import random import threading import logging import json import os import re from functools import wraps def writer(func): ''' Decorator used to threadsafize methods that made write operations on client configuration tree. ''' @wraps(func) def f(self, *args, **kwargs): with self._lock: return func(self, *args, **kwargs) return f class CCConf(object): ''' Create a new configuration interface. :param path_directory: the directory to store the configuration files ''' CONF_TEMPLATE = {'password': None, 'role': None, 'tags': {}, 'rights': []} RE_SALTPW = re.compile(r'{(?P[A-Z]+)}(?P.+)') def __init__(self, path_directory): self._path = path_directory self._lock = threading.RLock() def __enter__(self): return self._lock.__enter__() def __exit__(self, *args, **kwargs): return self._lock.__exit__(*args, **kwargs) def _get_conf(self, login): ''' Return the configuration of a client by its login. :param login: login of the client :return: the configuration of the client :raise CCConf.UnknownAccount: if user login is unknown ''' filename = os.path.join(self._path, '%s.json' % login) if os.path.isfile(filename): conf = json.load(open(filename, 'r')) logging.debug('Getting configuration %s: %s', filename, conf) return conf else: raise CCConf.UnknownAccount('%s is not a file' % filename) def _set_conf(self, login, conf, create=False): ''' Update the configuration of a client by its login. :param login: login of the client :param conf: configuration to set for the client :raise CCConf.UnknownAccount: if user login is unknown ''' filename = os.path.join(self._path, '%s.json' % login) logging.debug('Writing configuration %s: %s', filename, conf) if os.path.isfile(filename) ^ create: json.dump(conf, open(filename, 'w')) else: raise CCConf.UnknownAccount('%s is not a file' % filename) def acquire(self): ''' Acquire the configuration writing lock for non-atomic configuration changes. .. warning:: Don't forget to call the :meth:`release` method after your changes for each :meth:`acquire` you made. ''' self._lock.acquire() def release(self): ''' Release the configuration writing lock. ''' self._lock.release() def show(self, login): ''' Show the configuration for specified account. :param login: the login of the client :return: configuration of user ''' return self._get_conf(login) def _unsaltify(self, string, digest_size): string = base64.decodestring(string) password = string[0:digest_size] salt = string[digest_size:] return password, salt def _get_randsalt(self, size=10): salt = '' for _ in xrange(size): salt += chr(random.randint(1, 255)) return salt def _auth_ssha(self, provided_passwd, configured_passwd=None): if configured_passwd is not None: salt = self._unsaltify(configured_passwd, 20)[1] else: salt = self._get_randsalt() digest = hashlib.sha1(str(provided_passwd) + salt).digest() return '{SSHA}%s' % base64.b64encode(digest + salt) def _auth_sha(self, provided_passwd, configured_passwd=None): digest = hashlib.sha1(str(provided_passwd)).digest() return '{SHA}%s' % base64.b64encode(digest) def _auth_smd5(self, provided_passwd, configured_passwd=None): if configured_passwd is not None: salt = self._unsaltify(configured_passwd, 16)[1] else: salt = self._get_randsalt() digest = hashlib.sha1(str(provided_passwd) + salt).digest() return '{SMD5}%s' % base64.b64encode(digest + salt) def _auth_md5(self, provided_passwd, configured_passwd=None): digest = hashlib.sha1(str(provided_passwd)).digest() return '{MD5}%s' % base64.b64encode(digest) def _auth_plain(self, provided_passwd, configured_passwd=None): return provided_passwd def _hash_password(self, password, method='ssha'): ''' Hash a password using given method and return it. :param password: the password to hash :param method: the hashing method :return: hashed password ''' meth = '_auth_%s' % method.lower() if hasattr(self, meth): auth = getattr(self, meth) return auth(password) else: raise CCConf.BadMethodError('Bad hashing method: %s' % repr(method)) def authentify(self, login, password): ''' Authentify the client providing its login and password. The function return the role of the client on success, or ``None``. :param login: the login of the client :param password: the password of the client :return: the client's role or None on failed authentication :raise CCConf.UnknownAccount: if user login is unknown ''' conf = self._get_conf(login) passwd_conf = conf['password'] # Check if account password is disabled or if account is disabled: if passwd_conf is None: return None is_valid = False m = CCConf.RE_SALTPW.match(passwd_conf) if m is None: meth = '_auth_plain' password_wo_method = passwd_conf else: meth = '_auth_%s' % m.group('method').lower() password_wo_method = m.group('password') if hasattr(self, meth): auth = getattr(self, meth) is_valid = auth(password, password_wo_method) == passwd_conf else: logging.warning('Bad authentication method for %s: ' '%s', login, m.group('method')) if is_valid: return conf['role'] else: return None @writer def set_password(self, login, password, method='ssha'): ''' Update the client's password in the configuration. :param login: login of the user :param password: new password :param method: the hashing method to use :raise CCConf.UnknownAccount: if user login is unknown ''' conf = self._get_conf(login) password = self._hash_password(password, method) conf['password'] = password self._set_conf(login, conf) @writer def add_tag(self, login, tag_name, tag_value): ''' Add the tag to the user. :param login: login of the user :param tag_name: tag name to add to the user :param tag_value: the tag value :raise CCConf.UnknownAccount: if user login is unknown ''' logging.debug('Added tag %s:%s for %s account', tag_name, tag_value, login) conf = self._get_conf(login) conf['tags'][tag_name] = tag_value self._set_conf(login, conf) @writer def remove_tag(self, login, tag): ''' Remove the tag to the user. :param login: login of the user :param tag: tag to remove to the user :raise CCConf.UnknownAccount: if user login is unknown ''' logging.debug('Removed tag %s for %s account', login, tag) conf = self._get_conf(login) if tag in conf['tags']: del conf['tags'][tag] self._set_conf(login, conf) @writer def remove_account(self, login): ''' Remove the configuration of the account. :param login: login of the account to remove :raise CCConf.UnknownAccount: if user login is unknown ''' logging.debug('Removed %s account', login) filename = os.path.join(self._path, '%s.json' % login) if os.path.exists(filename): os.remove(filename) else: raise CCConf.UnknownAccount('%s is not a file' % filename) @writer def create_account(self, login, role, password): ''' Create a new account. :param login: login of the new user :param password: password of the new user :param role: the role of the new user :raise CCConf.AlreadyExistingAccount: if the login is already ''' logging.debug('Creating %s account with role %s', login, role) filename = os.path.join(self._path, '%s.json' % login) if os.path.exists(filename): raise CCConf.AlreadyExistingAccount('%s found' % filename) else: conf = CCConf.CONF_TEMPLATE.copy() conf['role'] = role if password is not None: conf['password'] = self._hash_password(password) self._set_conf(login, conf, create=True) @writer def copy_account(self, copy_login, login, password): ''' Create a new account based on another. :param copy_login: the login of the account to copy. :param password: password of the new user :param role: the role of the new user :raise CCConf.AlreadyExistingAccount: if the login is already :raise CCConf.UnknownAccount: if the copy login doesn't exist ''' conf_copy = self._get_conf(copy_login) self.create_account(login, conf_copy['role'], password) password = self._get_conf(login)['password'] conf_copy['password'] = password self._set_conf(login, conf_copy) @writer def add_right(self, login, tql, method=None, target='allow', index=None): ''' Add a right rule to the provided account. :param login: the login of the account :param tql: the TQL request to allow :param method: the method to which apply the right, None if all :param allow: True if the rules allow the call, or False if it deny. :param index: the index of the new rule, set None if the rule is appended to the end of the ruleset. .. note:: If the index is out of range, the rule will be added to the end of the ruleset. ''' conf = self._get_conf(login) rights = conf['rights'] rule = {'tql': tql, 'method': method, 'target': target} if index is None: index = len(rights) rights.insert(index, rule) self._set_conf(login, conf) @writer def remove_right(self, login, index): ''' Remove a right rule from the provided account. :param login: the login of the account :param index: the index of the rule to delete or None for all rules ''' conf = self._get_conf(login) if index is None: conf['rights'] = [] else: try: conf['rights'].pop(index) except IndexError: raise CCConf.OutOfRangeIndexError('Bad rule index ' '%s' % repr(index)) self._set_conf(login, conf) def list_accounts(self): ''' List all registered accounts. :return: :class:`tuple` of :class:`str`, each item being an account login ''' logins = [] for filename in os.listdir(self._path): login, ext = os.path.splitext(filename) if ext == '.json': logins.append(login) return tuple(logins) class UnknownAccount(Exception): pass class AlreadyExistingAccount(Exception): pass class BadMethodError(Exception): pass class OutOfRangeIndexError(Exception): pass