diff --git a/kite/api/v1/controllers/controller.py b/kite/api/v1/controllers/controller.py index 4d1ef80..d878203 100644 --- a/kite/api/v1/controllers/controller.py +++ b/kite/api/v1/controllers/controller.py @@ -12,6 +12,7 @@ import pecan +from kite.api.v1.controllers import group as group_controller from kite.api.v1.controllers import key as key_controller from kite.api.v1.controllers import ticket as ticket_controller @@ -27,6 +28,7 @@ class Controller(object): 'href': '%s/v1/' % pecan.request.host_url, 'rel': 'self'}]} + groups = group_controller.GroupController() keys = key_controller.KeyController() tickets = ticket_controller.TicketController() diff --git a/kite/api/v1/controllers/group.py b/kite/api/v1/controllers/group.py new file mode 100644 index 0000000..e089eaa --- /dev/null +++ b/kite/api/v1/controllers/group.py @@ -0,0 +1,36 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import pecan +from pecan import rest +import wsme +import wsmeext.pecan as wsme_pecan + +from kite.api.v1 import models + + +class GroupController(rest.RestController): + + @wsme_pecan.wsexpose(models.Group, wsme.types.text) + def put(self, name): + pecan.request.storage.create_group(name) + return models.Group(name=name) + + @wsme_pecan.wsexpose(None, wsme.types.text, status_code=204) + def delete(self, name): + pecan.request.storage.delete_group(name) + + @wsme.validate(models.GroupKey) + @wsme_pecan.wsexpose(models.GroupKey, body=models.GroupKeyRequest) + def post(self, group_request): + group_request.verify() + return group_request.new_response() diff --git a/kite/api/v1/models/__init__.py b/kite/api/v1/models/__init__.py index ee2cf26..3236785 100644 --- a/kite/api/v1/models/__init__.py +++ b/kite/api/v1/models/__init__.py @@ -10,12 +10,14 @@ # License for the specific language governing permissions and limitations # under the License. -from kite.api.v1.models import key -from kite.api.v1.models import ticket +from kite.api.v1.models.group import * # noqa +from kite.api.v1.models.key import * # noqa +from kite.api.v1.models.ticket import * # noqa -KeyInput = key.KeyInput -KeyData = key.KeyData -Ticket = ticket.Ticket -TicketRequest = ticket.TicketRequest - -__all__ = [KeyInput, KeyData, Ticket, TicketRequest] +__all__ = ['Group', + 'GroupKey', + 'GroupKeyRequest', + 'KeyInput', + 'KeyData', + 'Ticket', + 'TicketRequest'] diff --git a/kite/api/v1/models/base.py b/kite/api/v1/models/base.py index e7d1859..f0e7598 100644 --- a/kite/api/v1/models/base.py +++ b/kite/api/v1/models/base.py @@ -55,9 +55,10 @@ def malformed(msg): class Endpoint(object): """A source or destination for a ticket.""" - def __init__(self, endpoint_str): + def __init__(self, endpoint_str, group=None): self._cache = dict() self._set_endpoint(endpoint_str) + self._group = group @malformed('endpoint') def _set_endpoint(self, endpoint_str): @@ -66,7 +67,9 @@ class Endpoint(object): @memoize def key_data(self): try: - return pecan.request.storage.get_key(self.host, self.generation) + return pecan.request.storage.get_key(self.host, + self.generation, + group=self._group) except exception.CryptoError: pecan.abort(500, "Failed to decrypt key for '%s:%s'. " % (self.host, self.generation)) @@ -77,6 +80,10 @@ class Endpoint(object): def key(self): return self.key_data['key'] + @property + def key_group(self): + return self.key_data['group'] + @property def key_generation(self): return self.key_data['generation'] @@ -96,6 +103,10 @@ class BaseRequest(wsme.types.Base): self._cache = dict() self.now = timeutils.utcnow() + # NOTE(jamielennox): This is essentially a class variable, however + # that confuses WSME. + self.destination_is_group = None + @memoize @malformed("metadata") def meta(self): @@ -104,12 +115,13 @@ class BaseRequest(wsme.types.Base): @memoize @malformed("source") def source(self): - return Endpoint(self.meta['source']) + return Endpoint(self.meta['source'], group=False) @memoize @malformed("destination") def destination(self): - return Endpoint(self.meta['destination']) + return Endpoint(self.meta['destination'], + group=self.destination_is_group) @memoize @malformed("timestamp") diff --git a/kite/api/v1/models/group.py b/kite/api/v1/models/group.py new file mode 100644 index 0000000..7b0b67e --- /dev/null +++ b/kite/api/v1/models/group.py @@ -0,0 +1,68 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime + +import pecan +import wsme + +from kite.api.v1.models import base +from kite.common import exception + + +class Group(wsme.types.Base): + name = wsme.wsattr(wsme.types.text, mandatory=True) + + +class GroupKey(base.BaseResponse): + + group_key = wsme.wsattr(wsme.types.text, mandatory=True) + + def sign(self, key): + super(GroupKey, self).sign(key, self.group_key) + + def set_group_key(self, rkey, group_key): + self.group_key = pecan.request.crypto.encrypt(rkey, group_key) + + +class GroupKeyRequest(base.BaseRequest): + + def __init__(self, **kwargs): + super(GroupKeyRequest, self).__init__(**kwargs) + + seconds = int(pecan.request.conf.ticket_lifetime) + self.ttl = datetime.timedelta(seconds=seconds) + self.destination_is_group = True + + def new_response(self): + response = GroupKey() + + response.set_metadata(source=self.source.key_str, + destination=self.destination.key_str, + expiration=self.now + self.ttl) + + response.set_group_key(self.source.key, self.destination.key) + response.sign(self.source.key) + + return response + + def verify(self): + super(GroupKeyRequest, self).verify() + + # check that we are a group member + if self.source.host.split('.')[0] != self.destination.host: + pecan.abort(401, 'Not a group member') + + # we can only request a group key for a group + if not self.destination.key_group: + raise exception.KeyNotFound(name=self.destination.host, + generation=self.destination.generation) diff --git a/kite/common/storage.py b/kite/common/storage.py index 5f4fd03..8d3d94e 100644 --- a/kite/common/storage.py +++ b/kite/common/storage.py @@ -10,6 +10,8 @@ # License for the specific language governing permissions and limitations # under the License. +import datetime + from kite.common import crypto from kite.common import exception from kite.common import utils @@ -22,30 +24,77 @@ class StorageManager(utils.SingletonManager): def get_key(self, name, generation=None, group=None): """Retrieves a key from the driver and decrypts it for use. + If it is a group key and it has expired or is not found then generate + a new one and return that for use. + :param string name: Key Identifier :param int generation: Key generation to retrieve. Default latest - - :return tuple (string, int): raw key data and the key generation """ - key = dbapi.get_instance().get_key(name, generation=generation, + key = dbapi.get_instance().get_key(name, + generation=generation, group=group) + crypto_manager = crypto.CryptoManager.get_instance() if not key: + # host or group not found raise exception.KeyNotFound(name=name, generation=generation) + if group is not None and group != key['group']: + raise exception.KeyNotFound(name=name, generation=generation) + + now = timeutils.utcnow() expiration = key.get('expiration') - if expiration: - now = timeutils.utcnow() - if expiration < now: + + if key['group'] and expiration and generation is not None: + # if you ask for a specific group key generation then you can + # retrieve it for a little while beyond it being expired + timeout = expiration + datetime.timedelta(minutes=10) + elif key['group'] and expiration: + # when we can generate a new key we don't want to use an older one + # that is just going to require refreshing soon + timeout = expiration - datetime.timedelta(minutes=2) + else: + # otherwise we either have an un-expiring group or host key which + # we just check against now + timeout = expiration + + if timeout and now >= timeout: + if key['group']: + # clear the key so it will generate a new group key + key = {'group': True} + else: raise exception.KeyNotFound(name=name, generation=generation) - crypto_manager = crypto.CryptoManager.get_instance() - return {'key': crypto_manager.decrypt_key(name, - enc_key=key['key'], - signature=key['signature']), - 'generation': key['generation'], - 'name': key['name'], - 'group': key['group']} + if 'key' in key: + dec_key = crypto_manager.decrypt_key(name, + enc_key=key['key'], + signature=key['signature']) + return {'key': dec_key, + 'generation': key['generation'], + 'name': key['name'], + 'group': key['group']} + + if generation is not None or not key['group']: + # A specific generation was asked for or it's not a group key + # so don't generate a new one + raise exception.KeyNotFound(name=name, generation=generation) + + # generate and return a new group key + new_key = crypto_manager.new_key() + enc_key, signature = crypto_manager.encrypt_key(name, new_key) + expiration = now + datetime.timedelta(minutes=15) + + new_gen = dbapi.get_instance().set_key(name, + key=enc_key, + signature=signature, + group=True, + expiration=expiration) + + return {'key': new_key, + 'generation': new_gen, + 'name': name, + 'group': True, + 'expiration': expiration} def set_key(self, name, key, expiration=None): """Encrypt a key and store it to the backend. @@ -58,3 +107,9 @@ class StorageManager(utils.SingletonManager): return dbapi.get_instance().set_key(name, key=enc_key, signature=signature, group=False, expiration=expiration) + + def create_group(self, name): + dbapi.get_instance().create_group(name) + + def delete_group(self, name): + dbapi.get_instance().delete_host(name, group=True) diff --git a/kite/db/connection.py b/kite/db/connection.py index df875c0..1f126e8 100644 --- a/kite/db/connection.py +++ b/kite/db/connection.py @@ -61,3 +61,25 @@ class Connection(object): - expiration: When the key expires (or None). Expired keys can be returned. """ + + @abc.abstractmethod + def create_group(self, name): + """Create a new group. + + :param string name: The group name. + + :returns bool: True if work was performed, False otherwise (eg if the + group already existed). + """ + + @abc.abstractmethod + def delete_host(self, name, group=None): + """Delete a host or group. + + :param string name: The host or group name. + :param bool group: (optional) If set only delete the host if it is (or + is not if False) a group. + + :returns bool: True if work was performed, False otherwise (eg deleting + a group/host that never existed). + """ diff --git a/kite/db/kvs/api.py b/kite/db/kvs/api.py index 6e0d967..515a301 100644 --- a/kite/db/kvs/api.py +++ b/kite/db/kvs/api.py @@ -68,3 +68,25 @@ class KvsDbImpl(connection.Connection): response.update(key_data) return response + + def create_group(self, name): + if name in self._data: + return False + + self._data[name] = {'name': name, + 'latest_generation': 0, + 'group': True} + return True + + def delete_host(self, name, group=None): + try: + host = self._data[name] + except KeyError: + return False + + if group is not None and host['group'] != group: + return False + + del self._data[name] + + return True diff --git a/kite/db/sqlalchemy/api.py b/kite/db/sqlalchemy/api.py index 8d03c61..e67001d 100644 --- a/kite/db/sqlalchemy/api.py +++ b/kite/db/sqlalchemy/api.py @@ -16,6 +16,7 @@ from sqlalchemy.orm import exc from kite.common import exception from kite.db import connection from kite.db.sqlalchemy import models +from kite.openstack.common.db import exception as db_exc from kite.openstack.common.db.sqlalchemy import session as db_session CONF = cfg.CONF @@ -76,6 +77,27 @@ class SqlalchemyDbImpl(connection.Connection): return host.latest_generation + def _get_group_data(self, session, name): + """Return data about a group. + + In the case of getting a key where there is a Group defined but no key + has yet been defined we are supposed to return the group data without + a key. This is a difficult query to write as an all in one. This + function is called when we fail to find a key, to return group data + if the request host is a group. + """ + query = session.query(models.Host.name) + query = query.filter(models.Host.group == True) + query = query.filter(models.Host.name == name) + + try: + result = query.one() + except exc.NoResultFound: + return None + else: + return {'name': result.name, + 'group': True} + def get_key(self, name, generation=None, group=None): session = get_session() @@ -95,7 +117,10 @@ class SqlalchemyDbImpl(connection.Connection): try: result = query.one() except exc.NoResultFound: - return None + if group is not False and generation is None: + return self._get_group_data(session, name) + else: + return None return {'name': result.Host.name, 'group': result.Host.group, @@ -103,3 +128,28 @@ class SqlalchemyDbImpl(connection.Connection): 'signature': result.Key.signature, 'generation': result.Key.generation, 'expiration': result.Key.expiration} + + def create_group(self, name): + session = get_session() + + try: + with session.begin(): + group = models.Host(name=name, latest_generation=0, group=True) + session.add(group) + except db_exc.DBDuplicateEntry: + # an existing group of this name already exists. + return False + + return True + + def delete_host(self, name, group=None): + session = get_session() + + with session.begin(): + query = session.query(models.Host).filter(models.Host.name == name) + if group is not None: + query = query.filter(models.Host.group == group) + + count = query.delete() + + return count > 0 diff --git a/kite/tests/api/base.py b/kite/tests/api/base.py index 2d22f27..3a3a5d1 100644 --- a/kite/tests/api/base.py +++ b/kite/tests/api/base.py @@ -64,7 +64,7 @@ class BaseTestCase(base.BaseTestCase): self.app = pecan.testing.load_test_app(self.app_config) self.addCleanup(pecan.set_config, {}, overwrite=True) - def request(self, url, method, expected_status=None, **kwargs): + def request(self, url, method, **kwargs): try: json = kwargs.pop('json') except KeyError: diff --git a/kite/tests/api/v1/base.py b/kite/tests/api/v1/base.py index bbf5492..6824b02 100644 --- a/kite/tests/api/v1/base.py +++ b/kite/tests/api/v1/base.py @@ -27,3 +27,6 @@ class BaseTestCase(base.BaseTestCase): def put(self, url, *args, **kwargs): return super(BaseTestCase, self).put(v1_url(url), *args, **kwargs) + + def delete(self, url, *args, **kwargs): + return super(BaseTestCase, self).delete(v1_url(url), *args, **kwargs) diff --git a/kite/tests/api/v1/test_group_crud.py b/kite/tests/api/v1/test_group_crud.py new file mode 100644 index 0000000..5a864d9 --- /dev/null +++ b/kite/tests/api/v1/test_group_crud.py @@ -0,0 +1,27 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from kite.tests.api.v1 import base + + +class GroupCrudTest(base.BaseTestCase): + + def test_create_group(self): + self.put('/groups/test-name', status=200) + self.delete('/groups/test-name', status=204) + + def test_double_create_group(self): + self.put('/groups/test-name', status=200) + self.put('/groups/test-name', status=200) + + def test_delete_without_create_group(self): + self.delete('/groups/test-name', status=204) diff --git a/kite/tests/db/test_groups.py b/kite/tests/db/test_groups.py new file mode 100644 index 0000000..a652029 --- /dev/null +++ b/kite/tests/db/test_groups.py @@ -0,0 +1,31 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from testscenarios import load_tests_apply_scenarios as load_tests # noqa + +from kite.tests.db import base + +TEST_NAME = 'test-name' + + +class TestDbGroups(base.BaseTestCase): + + def test_create_group(self): + self.assertTrue(self.DB.create_group(TEST_NAME)) + self.assertFalse(self.DB.create_group(TEST_NAME)) + + def test_delete_group(self): + self.assertTrue(self.DB.create_group(TEST_NAME)) + self.assertTrue(self.DB.delete_host(TEST_NAME)) + + def test_delete_without_create(self): + self.assertFalse(self.DB.delete_host(TEST_NAME)) diff --git a/tox.ini b/tox.ini index 167b013..cb4b2fa 100644 --- a/tox.ini +++ b/tox.ini @@ -24,8 +24,9 @@ commands = python setup.py testr --coverage --testr-args='{posargs}' [flake8] # H803 skipped on purpose per list discussion. # E123, E125 skipped as they are invalid PEP-8. +# E712 comparison to True should be 'if cond is True:' or 'if cond:', breaks some query generation show-source = True -ignore = E123,E125,H803 +ignore = E123,E125,H803,E712 builtins = _ -exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build \ No newline at end of file +exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build