diff options
author | Giuseppe Lavagetto <lavagetto@gmail.com> | 2015-11-28 16:56:33 +0100 |
---|---|---|
committer | Giuseppe Lavagetto <lavagetto@gmail.com> | 2015-11-29 18:18:41 +0100 |
commit | c8f9a159a8b9d9f29ada877c03205cbfc1e81bae (patch) | |
tree | e6c21e56601f07dedfee85b35435ecebee82c98b | |
parent | 1857e763de136f296700f07a58524c9f790206ce (diff) |
Re-Adding the auth module.
This new, reworked version of auth guarantees:
- A simple, ORM-like interface, centered on Users and Roles and not on
the client
- No useless repetition of code
- Fixes some shortcomings of the old interface (deleting objects is now
possible, more than one ACL is allowed per role(!!!))
- Doesn't write/read without explicit authorization from the user
- Better error handling
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | src/etcd/auth.py | 255 | ||||
-rw-r--r-- | src/etcd/tests/integration/test_simple.py | 3 | ||||
-rw-r--r-- | src/etcd/tests/test_auth.py | 161 |
4 files changed, 418 insertions, 2 deletions
@@ -12,5 +12,4 @@ tmp build
dist
docs
-etcd
.coverage
diff --git a/src/etcd/auth.py b/src/etcd/auth.py new file mode 100644 index 0000000..796772d --- /dev/null +++ b/src/etcd/auth.py @@ -0,0 +1,255 @@ +import json + +import logging +import etcd + +_log = logging.getLogger(__name__) + + +class EtcdAuthBase(object): + entity = 'example' + + def __init__(self, client, name): + self.client = client + self.name = name + self.uri = "{}/auth/{}s/{}".format(self.client.version_prefix, + self.entity, self.name) + + @property + def names(self): + key = "{}s".format(self.entity) + uri = "{}/auth/{}".format(self.client.version_prefix, key) + response = self.client.api_execute(uri, self.client._MGET) + return json.loads(response.data.decode('utf-8'))[key] + + def read(self): + try: + response = self.client.api_execute(self.uri, self.client._MGET) + except etcd.EtcdInsufficientPermissions as e: + _log.error("Any action on the authorization requires the root role") + raise + except etcd.EtcdKeyNotFound: + _log.info("%s '%s' not found", self.entity, self.name) + raise + except Exception as e: + _log.error("Failed to fetch %s in %s%s: %r", + self.entity, self.client._base_uri, + self.client.version_prefix, e) + raise etcd.EtcdException( + "Could not fetch {} '{}'".format(self.entity, self.name)) + + self._from_net(response.data) + + def write(self): + try: + r = self.__class__(self.client, self.name) + r.read() + except etcd.EtcdKeyNotFound: + r = None + try: + for payload in self._to_net(r): + response = self.client.api_execute_json(self.uri, + self.client._MPUT, + params=payload) + # This will fail if the response is an error + self._from_net(response.data) + except etcd.EtcdInsufficientPermissions as e: + _log.error("Any action on the authorization requires the root role") + raise + except Exception as e: + _log.error("Failed to write %s '%s'", self.entity, self.name) + # TODO: fine-grained exception handling + raise etcd.EtcdException( + "Could not write {} '{}': {}".format(self.entity, + self.name, e)) + + def delete(self): + try: + _ = self.client.api_execute(self.uri, self.client._MDELETE) + except etcd.EtcdInsufficientPermissions as e: + _log.error("Any action on the authorization requires the root role") + raise + except etcd.EtcdKeyNotFound: + _log.info("%s '%s' not found", self.entity, self.name) + raise + except Exception as e: + _log.error("Failed to delete %s in %s%s: %r", + self.entity, self._base_uri, self.version_prefix, e) + raise etcd.EtcdException( + "Could not delete {} '{}'".format(self.entity, self.name)) + + def _from_net(self, data): + raise NotImplementedError() + + def _to_net(self, old=None): + raise NotImplementedError() + + @classmethod + def new(cls, client, data): + c = cls(client, data[cls.entity]) + c._from_net(data) + return c + + +class EtcdUser(EtcdAuthBase): + """Class to manage in a orm-like way etcd users""" + entity = 'user' + + def __init__(self, client, name): + super(EtcdUser, self).__init__(client, name) + self._roles = set() + self._password = None + + def _from_net(self, data): + d = json.loads(data.decode('utf-8')) + self.roles = d.get('roles', []) + self.name = d.get('user') + + def _to_net(self, prevobj=None): + if prevobj is None: + retval = [{"user": self.name, "password": self._password, + "roles": list(self.roles)}] + else: + retval = [] + if self._password: + retval.append({"user": self.name, "password": self._password}) + to_grant = list(self.roles - prevobj.roles) + to_revoke = list(prevobj.roles - self.roles) + if to_grant: + retval.append({"user": self.name, "grant": to_grant}) + if to_revoke: + retval.append({"user": self.name, "revoke": to_revoke}) + # Let's blank the password now + # Even if the user can't be written we don't want it to leak anymore. + self._password = None + return retval + + @property + def roles(self): + return self._roles + + @roles.setter + def roles(self, val): + self._roles = set(val) + + @property + def password(self): + """Empty property for password.""" + return None + + @password.setter + def password(self, new_password): + """Change user's password.""" + self._password = new_password + + def __str__(self): + return json.dumps(self._to_net()[0]) + + + +class EtcdRole(EtcdAuthBase): + entity = 'role' + + def __init__(self, client, name): + super(EtcdRole, self).__init__(client, name) + self._read_paths = set() + self._write_paths = set() + + def _from_net(self, data): + d = json.loads(data.decode('utf-8')) + self.name = d.get('role') + + try: + kv = d["permissions"]["kv"] + except: + self._read_paths = set() + self._write_paths = set() + return + + self._read_paths = set(kv.get('read', [])) + self._write_paths = set(kv.get('write', [])) + + def _to_net(self, prevobj=None): + retval = [] + if prevobj is None: + retval.append({ + "role": self.name, + "permissions": + { + "kv": + { + "read": list(self._read_paths), + "write": list(self._write_paths) + } + } + }) + else: + to_grant = { + 'read': list(self._read_paths - prevobj._read_paths), + 'write': list(self._write_paths - prevobj._write_paths) + } + to_revoke = { + 'read': list(prevobj._read_paths - self._read_paths), + 'write': list(prevobj._write_paths - self._write_paths) + } + if [path for sublist in to_revoke.values() for path in sublist]: + retval.append({'role': self.name, 'revoke': {'kv': to_revoke}}) + if [path for sublist in to_grant.values() for path in sublist]: + retval.append({'role': self.name, 'grant': {'kv': to_grant}}) + return retval + + def grant(self, path, permission): + if permission.upper().find('R') >= 0: + self._read_paths.add(path) + if permission.upper().find('W') >= 0: + self._write_paths.add(path) + + def revoke(self, path, permission): + if permission.upper().find('R') >= 0 and \ + path in self._read_paths: + self._read_paths.remove(path) + if permission.upper().find('W') >= 0 and \ + path in self._write_paths: + self._write_paths.remove(path) + + @property + def acls(self): + perms = {} + try: + for path in self._read_paths: + perms[path] = 'R' + for path in self._write_paths: + if path in perms: + perms[path] += 'W' + else: + perms[path] = 'W' + except: + pass + return perms + + @acls.setter + def acls(self, acls): + self._read_paths = set() + self._write_paths = set() + for path, permission in acls.items(): + self.grant(path, permission) + + def __str__(self): + return json.dumps({"role": self.name, 'acls': self.acls}) + + +class Auth(object): + def __init__(self, client): + self.client = client + self.uri = "{}/auth/enable".format(self.client.version_prefix) + + @property + def active(self): + resp = self.client.api_execute(self.uri, self.client._MGET) + return json.loads(resp.data.decode('utf-8'))['enabled'] + + @active.setter + def active(self, value): + if value != self.active: + method = value and self.client._MPUT or self.client._MDELETE + self.client.api_execute(self.uri, method) diff --git a/src/etcd/tests/integration/test_simple.py b/src/etcd/tests/integration/test_simple.py index da0954d..660caa8 100644 --- a/src/etcd/tests/integration/test_simple.py +++ b/src/etcd/tests/integration/test_simple.py @@ -18,6 +18,7 @@ log = logging.getLogger() class EtcdIntegrationTest(unittest.TestCase): + cl_size = 3 @classmethod def setUpClass(cls): @@ -28,7 +29,7 @@ class EtcdIntegrationTest(unittest.TestCase): proc_name=program, port_range_start=6001, internal_port_range_start=8001) - cls.processHelper.run(number=3) + cls.processHelper.run(number=cls.cl_size) cls.client = etcd.Client(port=6001) @classmethod diff --git a/src/etcd/tests/test_auth.py b/src/etcd/tests/test_auth.py new file mode 100644 index 0000000..fc6ce70 --- /dev/null +++ b/src/etcd/tests/test_auth.py @@ -0,0 +1,161 @@ +from etcd.tests.integration.test_simple import EtcdIntegrationTest +from etcd import auth +import etcd + + +class TestEtcdAuthBase(EtcdIntegrationTest): + cl_size = 1 + + def setUp(self): + # Sets up the root user, toggles auth + u = auth.EtcdUser(self.client, 'root') + u.password = 'testpass' + u.write() + self.client = etcd.Client(port=6001, username='root', + password='testpass') + self.unauth_client = etcd.Client(port=6001) + a = auth.Auth(self.client) + a.active = True + + def tearDown(self): + u = auth.EtcdUser(self.client, 'test_user') + r = auth.EtcdRole(self.client, 'test_role') + try: + u.delete() + except: + pass + try: + r.delete() + except: + pass + a = auth.Auth(self.client) + a.active = False + + +class EtcdUserTest(TestEtcdAuthBase): + def test_names(self): + u = auth.EtcdUser(self.client, 'test_user') + self.assertEquals(u.names, ['root']) + + def test_read(self): + u = auth.EtcdUser(self.client, 'root') + # Reading an existing user succeeds + try: + u.read() + except Exception: + self.fail("reading the root user raised an exception") + + # roles for said user are fetched + self.assertEquals(u.roles, set(['root'])) + + # The user is correctly rendered out + self.assertEquals(u._to_net(), [{'user': 'root', 'password': None, + 'roles': ['root']}]) + + # An inexistent user raises the appropriate exception + u = auth.EtcdUser(self.client, 'user.does.not.exist') + self.assertRaises(etcd.EtcdKeyNotFound, u.read) + + # Reading with an unauthenticated client raises an exception + u = auth.EtcdUser(self.unauth_client, 'root') + self.assertRaises(etcd.EtcdInsufficientPermissions, u.read) + + # Generic errors are caught + c = etcd.Client(port=9999) + u = auth.EtcdUser(c, 'root') + self.assertRaises(etcd.EtcdException, u.read) + + def test_write_and_delete(self): + # Create an user + u = auth.EtcdUser(self.client, 'test_user') + u.roles.add('guest') + u.roles.add('root') + # directly from my suitcase + u.password = '123456' + try: + u.write() + except: + self.fail("creating a user doesn't work") + # Password gets wiped + self.assertEquals(u.password, None) + u.read() + # Verify we can log in as this user and access the auth (it has the + # root role) + cl = etcd.Client(port=6001, username='test_user', + password='123456') + ul = auth.EtcdUser(cl, 'root') + try: + ul.read() + except etcd.EtcdInsufficientPermissions: + self.fail("Reading auth with the new user is not possible") + + self.assertEquals(u.name, "test_user") + self.assertEquals(u.roles, set(['guest', 'root'])) + # set roles as a list, it works! + u.roles = ['guest', 'test_group'] + try: + u.write() + except: + self.fail("updating a user you previously created fails") + u.read() + self.assertIn('test_group', u.roles) + + # Unauthorized access is properly handled + ua = auth.EtcdUser(self.unauth_client, 'test_user') + self.assertRaises(etcd.EtcdInsufficientPermissions, ua.write) + + # now let's test deletion + du = auth.EtcdUser(self.client, 'user.does.not.exist') + self.assertRaises(etcd.EtcdKeyNotFound, du.delete) + + # Delete test_user + u.delete() + self.assertRaises(etcd.EtcdKeyNotFound, u.read) + # Permissions are properly handled + self.assertRaises(etcd.EtcdInsufficientPermissions, ua.delete) + + +class EtcdRoleTest(TestEtcdAuthBase): + def test_names(self): + r = auth.EtcdRole(self.client, 'guest') + self.assertListEqual(r.names, [u'guest', u'root']) + + def test_read(self): + r = auth.EtcdRole(self.client, 'guest') + try: + r.read() + except: + self.fail('Reading an existing role failed') + + self.assertEquals(r.acls, {'*': 'RW'}) + # We can actually skip most other read tests as they are common + # with EtcdUser + + def test_write_and_delete(self): + r = auth.EtcdRole(self.client, 'test_role') + r.acls = {'*': 'R', '/test/*': 'RW'} + try: + r.write() + except: + self.fail("Writing a simple groups should not fail") + + r1 = auth.EtcdRole(self.client, 'test_role') + r1.read() + self.assertEquals(r1.acls, r.acls) + r.revoke('/test/*', 'W') + r.write() + r1.read() + self.assertEquals(r1.acls, {'*': 'R', '/test/*': 'R'}) + r.grant('/pub/*', 'RW') + r.write() + r1.read() + self.assertEquals(r1.acls['/pub/*'], 'RW') + # All other exceptions are tested by the user tests + r1.name = None + self.assertRaises(etcd.EtcdException, r1.write) + # ditto for delete + try: + r.delete() + except: + self.fail("A normal delete should not fail") + self.assertRaises(etcd.EtcdKeyNotFound, r.read) |