Merge "Add group support to KDS"

This commit is contained in:
Jenkins 2014-07-22 20:59:28 +00:00 committed by Gerrit Code Review
commit 0656be8a73
14 changed files with 360 additions and 29 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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']

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -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).
"""

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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)

View File

@ -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))

View File

@ -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
exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build