Add group support to KDS

Allow creating groups, requesting tickets for group keys and for
retrieving group keys.

SecImpact
blueprint: key-distribution-server
Change-Id: I92aefd2ac1d9c2fa65efa13384a9390cc107b048
This commit is contained in:
Jamie Lennox 2013-12-03 12:33:28 +10:00
parent 6b54c09bad
commit 8ac50d553e
14 changed files with 360 additions and 29 deletions

View File

@ -12,6 +12,7 @@
import pecan 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 key as key_controller
from kite.api.v1.controllers import ticket as ticket_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, 'href': '%s/v1/' % pecan.request.host_url,
'rel': 'self'}]} 'rel': 'self'}]}
groups = group_controller.GroupController()
keys = key_controller.KeyController() keys = key_controller.KeyController()
tickets = ticket_controller.TicketController() 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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
from kite.api.v1.models import key from kite.api.v1.models.group import * # noqa
from kite.api.v1.models import ticket from kite.api.v1.models.key import * # noqa
from kite.api.v1.models.ticket import * # noqa
KeyInput = key.KeyInput __all__ = ['Group',
KeyData = key.KeyData 'GroupKey',
Ticket = ticket.Ticket 'GroupKeyRequest',
TicketRequest = ticket.TicketRequest 'KeyInput',
'KeyData',
__all__ = [KeyInput, KeyData, Ticket, TicketRequest] 'Ticket',
'TicketRequest']

View File

@ -55,9 +55,10 @@ def malformed(msg):
class Endpoint(object): class Endpoint(object):
"""A source or destination for a ticket.""" """A source or destination for a ticket."""
def __init__(self, endpoint_str): def __init__(self, endpoint_str, group=None):
self._cache = dict() self._cache = dict()
self._set_endpoint(endpoint_str) self._set_endpoint(endpoint_str)
self._group = group
@malformed('endpoint') @malformed('endpoint')
def _set_endpoint(self, endpoint_str): def _set_endpoint(self, endpoint_str):
@ -66,7 +67,9 @@ class Endpoint(object):
@memoize @memoize
def key_data(self): def key_data(self):
try: 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: except exception.CryptoError:
pecan.abort(500, "Failed to decrypt key for '%s:%s'. " % pecan.abort(500, "Failed to decrypt key for '%s:%s'. " %
(self.host, self.generation)) (self.host, self.generation))
@ -77,6 +80,10 @@ class Endpoint(object):
def key(self): def key(self):
return self.key_data['key'] return self.key_data['key']
@property
def key_group(self):
return self.key_data['group']
@property @property
def key_generation(self): def key_generation(self):
return self.key_data['generation'] return self.key_data['generation']
@ -96,6 +103,10 @@ class BaseRequest(wsme.types.Base):
self._cache = dict() self._cache = dict()
self.now = timeutils.utcnow() self.now = timeutils.utcnow()
# NOTE(jamielennox): This is essentially a class variable, however
# that confuses WSME.
self.destination_is_group = None
@memoize @memoize
@malformed("metadata") @malformed("metadata")
def meta(self): def meta(self):
@ -104,12 +115,13 @@ class BaseRequest(wsme.types.Base):
@memoize @memoize
@malformed("source") @malformed("source")
def source(self): def source(self):
return Endpoint(self.meta['source']) return Endpoint(self.meta['source'], group=False)
@memoize @memoize
@malformed("destination") @malformed("destination")
def destination(self): def destination(self):
return Endpoint(self.meta['destination']) return Endpoint(self.meta['destination'],
group=self.destination_is_group)
@memoize @memoize
@malformed("timestamp") @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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
import datetime
from kite.common import crypto from kite.common import crypto
from kite.common import exception from kite.common import exception
from kite.common import utils from kite.common import utils
@ -22,30 +24,77 @@ class StorageManager(utils.SingletonManager):
def get_key(self, name, generation=None, group=None): def get_key(self, name, generation=None, group=None):
"""Retrieves a key from the driver and decrypts it for use. """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 string name: Key Identifier
:param int generation: Key generation to retrieve. Default latest :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) group=group)
crypto_manager = crypto.CryptoManager.get_instance()
if not key: if not key:
# host or group not found
raise exception.KeyNotFound(name=name, generation=generation) 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') expiration = key.get('expiration')
if expiration:
now = timeutils.utcnow() if key['group'] and expiration and generation is not None:
if expiration < now: # 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) raise exception.KeyNotFound(name=name, generation=generation)
crypto_manager = crypto.CryptoManager.get_instance() if 'key' in key:
return {'key': crypto_manager.decrypt_key(name, dec_key = crypto_manager.decrypt_key(name,
enc_key=key['key'], enc_key=key['key'],
signature=key['signature']), signature=key['signature'])
'generation': key['generation'], return {'key': dec_key,
'name': key['name'], 'generation': key['generation'],
'group': key['group']} '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): def set_key(self, name, key, expiration=None):
"""Encrypt a key and store it to the backend. """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, return dbapi.get_instance().set_key(name, key=enc_key,
signature=signature, signature=signature,
group=False, expiration=expiration) 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). - expiration: When the key expires (or None).
Expired keys can be returned. 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) response.update(key_data)
return response 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.common import exception
from kite.db import connection from kite.db import connection
from kite.db.sqlalchemy import models 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 from kite.openstack.common.db.sqlalchemy import session as db_session
CONF = cfg.CONF CONF = cfg.CONF
@ -76,6 +77,27 @@ class SqlalchemyDbImpl(connection.Connection):
return host.latest_generation 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): def get_key(self, name, generation=None, group=None):
session = get_session() session = get_session()
@ -95,7 +117,10 @@ class SqlalchemyDbImpl(connection.Connection):
try: try:
result = query.one() result = query.one()
except exc.NoResultFound: 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, return {'name': result.Host.name,
'group': result.Host.group, 'group': result.Host.group,
@ -103,3 +128,28 @@ class SqlalchemyDbImpl(connection.Connection):
'signature': result.Key.signature, 'signature': result.Key.signature,
'generation': result.Key.generation, 'generation': result.Key.generation,
'expiration': result.Key.expiration} '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.app = pecan.testing.load_test_app(self.app_config)
self.addCleanup(pecan.set_config, {}, overwrite=True) self.addCleanup(pecan.set_config, {}, overwrite=True)
def request(self, url, method, expected_status=None, **kwargs): def request(self, url, method, **kwargs):
try: try:
json = kwargs.pop('json') json = kwargs.pop('json')
except KeyError: except KeyError:

View File

@ -27,3 +27,6 @@ class BaseTestCase(base.BaseTestCase):
def put(self, url, *args, **kwargs): def put(self, url, *args, **kwargs):
return super(BaseTestCase, self).put(v1_url(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] [flake8]
# H803 skipped on purpose per list discussion. # H803 skipped on purpose per list discussion.
# E123, E125 skipped as they are invalid PEP-8. # 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 show-source = True
ignore = E123,E125,H803 ignore = E123,E125,H803,E712
builtins = _ 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