summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGiuseppe Lavagetto <lavagetto@gmail.com>2015-11-28 16:56:33 +0100
committerGiuseppe Lavagetto <lavagetto@gmail.com>2015-11-29 18:18:41 +0100
commitc8f9a159a8b9d9f29ada877c03205cbfc1e81bae (patch)
treee6c21e56601f07dedfee85b35435ecebee82c98b
parent1857e763de136f296700f07a58524c9f790206ce (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--.gitignore1
-rw-r--r--src/etcd/auth.py255
-rw-r--r--src/etcd/tests/integration/test_simple.py3
-rw-r--r--src/etcd/tests/test_auth.py161
4 files changed, 418 insertions, 2 deletions
diff --git a/.gitignore b/.gitignore
index 765321b..3f90b7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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)