summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGiuseppe Lavagetto <lavagetto@gmail.com>2015-11-28 16:48:36 +0100
committerGiuseppe Lavagetto <lavagetto@gmail.com>2015-11-28 16:53:52 +0100
commit1857e763de136f296700f07a58524c9f790206ce (patch)
tree1a936331d3fbbfb4c4996a43bcc035eecf9f2792
parentdd38063e371eec384907c8220366fc836bde6a00 (diff)
Add error handling for ACLs (use and management)
Also removed auth.py; in its current form it's wrong and unusable
-rw-r--r--src/etcd/__init__.py15
-rw-r--r--src/etcd/auth.py369
-rw-r--r--src/etcd/client.py1
-rw-r--r--src/etcd/tests/integration/test_authentication.py195
4 files changed, 16 insertions, 564 deletions
diff --git a/src/etcd/__init__.py b/src/etcd/__init__.py
index 2032be3..f52852c 100644
--- a/src/etcd/__init__.py
+++ b/src/etcd/__init__.py
@@ -200,6 +200,13 @@ class EtcdConnectionFailed(EtcdException):
self.cause = cause
+class EtcdInsufficientPermissions(EtcdException):
+ """
+ Request failed because of insufficient permissions.
+ """
+ pass
+
+
class EtcdWatchTimedOut(EtcdConnectionFailed):
"""
A watch timed out without returning a result.
@@ -253,6 +260,7 @@ class EtcdError(object):
107: EtcdRootReadOnly,
108: EtcdDirNotEmpty,
# 109: Non-public: existing peer addr.
+ 110: EtcdInsufficientPermissions,
200: EtcdValueError,
201: EtcdValueError,
@@ -284,6 +292,13 @@ class EtcdError(object):
message = payload.get("message")
cause = payload.get("cause")
msg = '{} : {}'.format(message, cause)
+ status = payload.get("status")
+ # Some general status handling, as
+ # not all endpoints return coherent error messages
+ if status == 404:
+ error_code = 100
+ elif status == 401:
+ error_code = 110
exc = cls.error_exceptions.get(error_code, EtcdException)
if issubclass(exc, EtcdException):
raise exc(msg, payload)
diff --git a/src/etcd/auth.py b/src/etcd/auth.py
deleted file mode 100644
index 0f8a2dd..0000000
--- a/src/etcd/auth.py
+++ /dev/null
@@ -1,369 +0,0 @@
-import json
-
-import logging
-
-try:
- # Python 3
- from http.client import HTTPException
-except ImportError:
- # Python 2
- from httplib import HTTPException
-import socket
-import urllib3
-
-from .client import Client
-import etcd
-
-_log = logging.getLogger(__name__)
-
-
-class AuthClient(Client):
- """
- Extended etcd client that supports authentication primitives added in 2.1.
- """
-
- def __init__(self, *args, **kwargs):
- super(AuthClient, self).__init__(*args, **kwargs)
-
- def create_user(self, username, password, roles=[], role_action='roles'):
- """
- Add a user.
-
- Args:
- username (str): Username to create.
- password (str): Password for username.
- roles (list): List of roles as strings.
-
- Returns:
- EtcdUser
-
- Raises:
- etcd.EtcdException: If user can't be created.
- """
- try:
- uri = self.version_prefix + '/auth/users/' + username
- params = {'user': username}
- if password:
- params['password'] = password
- if roles:
- params[role_action] = roles
-
- response = self.json_api_execute(uri, self._MPUT, params=params)
- res = json.loads(response.data.decode('utf-8'))
- return EtcdUser(self, res)
- except Exception as e:
- _log.error("Failed to create user in %s%s: %r",
- self._base_uri, self.version_prefix, e)
- raise etcd.EtcdException("Could not create user")
-
- def get_user(self, username):
- """
- Look up a user.
-
- Args:
- username (str): Username to lookup.
-
- Returns:
- EtcdUser
-
- Raises:
- etcd.EtcdException: If user can't be found.
- """
- try:
- uri = self.version_prefix + '/auth/users/' + username
- response = self.api_execute(uri, self._MGET)
- res = json.loads(response.data.decode('utf-8'))
- return EtcdUser(self, res)
- except Exception as e:
- _log.error("Failed to fetch user in %s%s: %r",
- self._base_uri, self.version_prefix, e)
- raise etcd.EtcdException("Could not fetch user")
-
- @property
- def usernames(self):
- """List user names."""
- try:
- uri = self.version_prefix + '/auth/users'
- response = self.api_execute(uri, self._MGET)
- res = json.loads(response.data.decode('utf-8'))
- return res['users']
- except Exception as e:
- _log.error("Failed to list users in %s%s: %r",
- self._base_uri, self.version_prefix, e)
- raise etcd.EtcdException("Could not list users")
-
- @property
- def users(self):
- """List users in detail."""
- return [self.get_user(x) for x in self.usernames]
-
- def create_role(self, role_name):
- """
- Create a role.
-
- Args:
- role_name (str): Name of role
-
- Returns:
- EtcdRole
- """
- return self.modify_role(role_name)
-
- def get_role(self, role_name):
- """
- Look up a role.
-
- Args:
- role_name (str): Name of role.
-
- Returns:
- EtcdRole
- """
- try:
- uri = self.version_prefix + '/auth/roles/' + role_name
- response = self.api_execute(uri, self._MGET)
- res = json.loads(response.data.decode('utf-8'))
- return EtcdRole(self, res)
- except Exception as e:
- _log.error("Failed to fetch user in %s%s: %r",
- self._base_uri, self.version_prefix, e)
- raise etcd.EtcdException("Could not fetch users")
-
- @property
- def role_names(self):
- """List role names."""
- try:
- uri = self.version_prefix + '/auth/roles'
- response = self.api_execute(uri, self._MGET)
- res = json.loads(response.data.decode('utf-8'))
- return res['roles']
- except Exception as e:
- _log.error("Failed to list roles in %s%s: %r",
- self._base_uri, self.version_prefix, e)
- raise etcd.EtcdException("Could not list roles")
-
- @property
- def roles(self):
- """List roles in detail."""
- return [self.get_role(x) for x in self.role_names]
-
- def toggle_auth(self, auth_enabled=True):
- """
- Toggle authentication.
-
- Args:
- auth_enabled (bool): Should auth be enabled or disabled
- """
- try:
- uri = self.version_prefix + '/auth/enable'
- action = auth_enabled and self._MPUT or self._MDELETE
-
- self.api_execute(uri, action)
- except Exception as e:
- _log.error("Failed enable authentication in %s%s: %r",
- self._base_uri, self.version_prefix, e)
- raise etcd.EtcdException("Could not toggle authentication")
-
- def modify_role(self, role_name, permissions=None, perm_key=None):
- """Modifies role."""
- try:
- uri = self.version_prefix + '/auth/roles/' + role_name
- params = {
- 'role': role_name,
- }
- if permissions:
- params[perm_key] = {
- 'kv': {
- 'read': [k for k, v in permissions.items() if
- 'R' in v.upper()],
- 'write': [k for k, v in permissions.items() if
- 'W' in v.upper()]
- }
- }
- response = self.json_api_execute(uri, self._MPUT, params=params)
- res = json.loads(response.data.decode('utf-8'))
- return EtcdRole(self, res)
- except Exception as e:
- _log.error("Failed to modify role in %s%s: %r",
- self._base_uri, self.version_prefix, e)
- raise etcd.EtcdException("Could not modify role")
-
- def json_api_execute(self, path, method, params=None, timeout=None):
- """ Executes the query. """
-
- some_request_failed = False
- response = False
-
- if timeout is None:
- timeout = self.read_timeout
-
- if timeout == 0:
- timeout = None
-
- if not path.startswith('/'):
- raise ValueError('Path does not start with /')
-
- while not response:
- try:
- url = self._base_uri + path
- json_payload = json.dumps(params)
- headers = self._get_headers()
- headers['Content-Type'] = 'application/json'
- response = self.http.urlopen(method,
- url,
- body=json_payload,
- timeout=timeout,
- redirect=self.allow_redirect,
- headers=headers,
- preload_content=False)
- # urllib3 doesn't wrap all httplib exceptions and earlier versions
- # don't wrap socket errors either.
- except (urllib3.exceptions.HTTPError,
- HTTPException,
- socket.error) as e:
- _log.error("Request to server %s failed: %r",
- self._base_uri, e)
- if self._allow_reconnect:
- _log.info("Reconnection allowed, looking for another "
- "server.")
- # _next_server() raises EtcdException if there are no
- # machines left to try, breaking out of the loop.
- self._base_uri = self._next_server()
- some_request_failed = True
- else:
- _log.debug("Reconnection disabled, giving up.")
- raise etcd.EtcdConnectionFailed(
- "Connection to etcd failed due to %r" % e)
- except:
- _log.exception("Unexpected request failure, re-raising.")
- raise
-
- else:
- # Check the cluster ID hasn't changed under us. We use
- # preload_content=False above so we can read the headers
- # before we wait for the content of a long poll.
- cluster_id = response.getheader("x-etcd-cluster-id")
- id_changed = (self.expected_cluster_id
- and cluster_id is not None and
- cluster_id != self.expected_cluster_id)
- # Update the ID so we only raise the exception once.
- old_expected_cluster_id = self.expected_cluster_id
- self.expected_cluster_id = cluster_id
- if id_changed:
- # Defensive: clear the pool so that we connect afresh next
- # time.
- self.http.clear()
- raise etcd.EtcdClusterIdChanged(
- 'The UUID of the cluster changed from {} to '
- '{}.'.format(old_expected_cluster_id, cluster_id))
-
- if some_request_failed:
- if not self._use_proxies:
- # The cluster may have changed since last invocation
- self._machines_cache = self.machines
- self._machines_cache.remove(self._base_uri)
- return self._handle_server_response(response)
-
-
-class EtcdUser(object):
- def __init__(self, auth_client, json_user):
- self.client = auth_client
- self.name = json_user.get('user')
- self._roles = json_user.get('roles') or []
-
- @property
- def password(self):
- """Empty property for password."""
- return None
-
- @password.setter
- def password(self, new_password):
- """Change user's password."""
- self.client.create_user(self.name, new_password)
-
- @property
- def roles(self):
- return tuple(self._roles)
-
- @roles.setter
- def roles(self, roles):
- existing_roles = set(self._roles)
- new_roles = set(roles)
-
- if existing_roles == new_roles:
- _log.debug('User %s already belongs to %s', self.name, self._roles)
- return
-
- to_revoke = existing_roles - new_roles
- to_grant = new_roles - existing_roles
-
- if to_revoke:
- self.client.create_user(self.name, None, roles=list(to_revoke),
- role_action='revoke')
- if to_grant:
- self.client.create_user(self.name, None, roles=list(to_grant),
- role_action='grant')
- self._roles = new_roles
-
-
-class EtcdRole(object):
- def __init__(self, auth_client, role_json):
- self.client = auth_client
- self.name = role_json.get('role')
- self.permissions = RolePermissionsDict(self, role_json)
-
-
-class RolePermissionsDict(dict):
- _PERMISSIONS = {'R', 'W'}
-
- def __init__(self, etcd_role, role_json, *args, **kwargs):
- super(RolePermissionsDict, self).__init__(*args, **kwargs)
- self.role = etcd_role
- permissions = role_json.get('permissions')
- if permissions and 'kv' in permissions:
- self.__add_permissions(permissions, 'read', 'R')
- self.__add_permissions(permissions, 'write', 'W')
-
- def __add_permissions(self, permissions, label, symbol):
- if label in permissions['kv'] and permissions['kv'][label]:
- for path in permissions['kv'][label]:
- existing_perms = dict.get(self, path)
- if existing_perms:
- dict.__setitem__(self, path,
- existing_perms + symbol)
- else:
- dict.__setitem__(self, path, symbol)
-
- def __setitem__(self, key, value):
- if not value:
- raise ValueError('Permissions may only be (R)ead or (W)ite')
- perms = set(x.upper() for x in value)
- if not perms <= RolePermissionsDict._PERMISSIONS:
- raise ValueError('Permissions may only be (R)ead or (W)ite')
-
- role_name = self.role.name
- perm_dict = {key: value}
- existing_value = dict.get(self, key)
-
- if existing_value:
- existing_perms = set(x.upper() for x in existing_value)
- if perms != existing_perms:
- to_grant = perms - existing_perms
- to_revoke = existing_perms - perms
-
- if to_revoke:
- perm_dict = {key: ''.join(to_revoke)}
- self.role.client.modify_role(role_name, perm_dict, 'revoke')
- if to_grant:
- perm_dict = {key: ''.join(to_grant)}
- self.role.client.modify_role(role_name, perm_dict, 'grant')
- else:
- _log.debug('Permission %s=%s already granted', key, value)
- else:
- self.role.client.modify_role(role_name, perm_dict, 'grant')
-
- dict.__setitem__(self, key, value)
-
- def __delitem__(self, key):
- self.role.client.modify_role(self.role.name, {key: 'RW'}, 'revoke')
- dict.__delitem__(self, key)
diff --git a/src/etcd/client.py b/src/etcd/client.py
index 16eb4c8..a5c656d 100644
--- a/src/etcd/client.py
+++ b/src/etcd/client.py
@@ -911,6 +911,7 @@ class Client(object):
# throw the appropriate exception
try:
r = json.loads(resp)
+ r['status'] = response.status
except (TypeError, ValueError):
# Bad JSON, make a response locally.
r = {"message": "Bad response",
diff --git a/src/etcd/tests/integration/test_authentication.py b/src/etcd/tests/integration/test_authentication.py
deleted file mode 100644
index 52ba001..0000000
--- a/src/etcd/tests/integration/test_authentication.py
+++ /dev/null
@@ -1,195 +0,0 @@
-import unittest
-import shutil
-import tempfile
-
-import time
-
-import etcd
-import etcd.auth
-from etcd.tests.integration.test_simple import EtcdIntegrationTest
-from etcd.tests.integration import helpers
-
-
-class TestAuthentication(unittest.TestCase):
- def setUp(self):
- # Restart etcd for each test (since some tests will lock others out)
- program = EtcdIntegrationTest._get_exe()
- self.directory = tempfile.mkdtemp(prefix='python-etcd')
- self.processHelper = helpers.EtcdProcessHelper(
- self.directory,
- proc_name=program,
- port_range_start=6001,
- internal_port_range_start=8001)
- self.processHelper.run(number=1)
- self.client = etcd.auth.AuthClient(port=6001)
-
- # Wait for sync, to avoid:
- # "Not capable of accessing auth feature during rolling upgrades."
- time.sleep(0.5)
-
- def tearDown(self):
- self.processHelper.stop()
- shutil.rmtree(self.directory)
-
- def test_create_user(self):
- user = self.client.create_user('username', 'password')
- assert user.name == 'username'
- assert len(user.roles) == 0
-
- def test_create_user_with_role(self):
- user = self.client.create_user('username', 'password', roles=['root'])
- assert user.name == 'username'
- assert user.roles == ('root',)
-
- def test_create_user_add_role(self):
- user = self.client.create_user('username', 'password')
- self.client.create_role('role')
-
- # Empty to [root]
- user.roles = ['root']
- user = self.client.get_user('username')
- assert user.roles == ('root',)
-
- # [root] to [root,role]
- user.roles = ['root', 'role']
- user = self.client.get_user('username')
- assert user.roles == ('role', 'root')
-
- # [root,role] to [role]
- user.roles = ['role']
- user = self.client.get_user('username')
- assert user.roles == ('role',)
-
- def test_usernames_empty(self):
- assert len(self.client.usernames) == 0
-
- def test_usernames(self):
- self.client.create_user('username', 'password', roles=['root'])
- assert self.client.usernames == ['username']
-
- def test_users(self):
- self.client.create_user('username', 'password', roles=['root'])
- users = self.client.users
- assert len(users) == 1
- assert users[0].name == 'username'
-
- def test_get_user(self):
- self.client.create_user('username', 'password', roles=['root'])
- user = self.client.get_user('username')
- assert user.roles == ('root',)
-
- def test_get_user_not_found(self):
- self.assertRaises(etcd.EtcdException, self.client.get_user, 'username')
-
- def test_set_user_password(self):
- self.client.create_user('username', 'password', roles=['root'])
- user = self.client.get_user('username')
- assert not user.password
- user.password = 'new_password'
- assert not user.password
-
- def test_create_role(self):
- role = self.client.create_role('role')
- assert role.name == 'role'
- assert len(role.permissions) == 0
-
- def test_grant_role(self):
- role = self.client.create_role('role')
-
- # Read access to keys under /foo
- role.permissions['/foo/*'] = 'R'
- assert len(role.permissions) == 1
- assert role.permissions['/foo/*'] == 'R'
-
- # Write access to the key at /foo/bar
- role.permissions['/foo/bar'] = 'W'
- assert len(role.permissions) == 2
-
- # Full access to keys under /pub
- role.permissions['/pub/*'] = 'RW'
- assert len(role.permissions) == 3
-
- # Fresh fetch to bust cache:
- role = self.client.get_role('role')
- assert len(role.permissions) == 3
-
- def test_get_role(self):
- role = self.client.create_role('role')
- role.permissions['/foo/*'] = 'R'
-
- role = self.client.get_role('role')
- assert len(role.permissions) == 1
-
- def test_revoke_role(self):
- role = self.client.create_role('role')
- role.permissions['/foo/*'] = 'R'
-
- del role.permissions['/foo/*']
-
- role = self.client.get_role('role')
- assert len(role.permissions) == 0
-
- def test_modify_role_invalid(self):
- role = self.client.create_role('role')
- self.assertRaises(ValueError, role.permissions.__setitem__, '/foo/*',
- '')
-
- def test_modify_role_permissions(self):
- role = self.client.create_role('role')
- role.permissions['/foo/*'] = 'R'
-
- # Replace R with W
- role.permissions['/foo/*'] = 'W'
- assert role.permissions['/foo/*'] == 'W'
- role = self.client.get_role('role')
- assert role.permissions['/foo/*'] == 'W'
-
- # Extend W to RW
- role.permissions['/foo/*'] = 'WR'
- role = self.client.get_role('role')
- assert role.permissions['/foo/*'] == 'RW'
-
- # NO-OP RW to RW
- role.permissions['/foo/*'] = 'RW'
- role = self.client.get_role('role')
- assert role.permissions['/foo/*'] == 'RW'
-
- # Reduce RW to W
- role.permissions['/foo/*'] = 'W'
- role = self.client.get_role('role')
- assert role.permissions['/foo/*'] == 'W'
-
- def test_role_names_empty(self):
- assert self.client.role_names == ['root']
-
- def test_role_names(self):
- self.client.create_role('role')
- assert self.client.role_names == ['role', 'root']
-
- def test_roles(self):
- self.client.create_role('role')
- assert len(self.client.roles) == 2
-
- def test_enable_auth(self):
- # Store a value, lock out guests
- self.client.write('/foo', 'bar')
- self.client.create_user('root', 'rootpassword')
- # Creating role before auth is enabled prevents default permissions
- self.client.create_role('guest')
- self.client.toggle_auth(True)
-
- # Now we can't access key:
- try:
- self.client.get('/foo')
- self.fail('Expected exception')
- except etcd.EtcdException as e:
- assert 'Insufficient credentials' in str(e)
-
- # But an authenticated client can:
- root_client = etcd.Client(port=6001,
- username='root',
- password='rootpassword')
- assert root_client.get('/foo').value == 'bar'
-
- def test_enable_auth_before_root_created(self):
- self.assertRaises(etcd.EtcdException, self.client.toggle_auth, True)