Merge "Add group support to KDS"
This commit is contained in:
commit
0656be8a73
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
|
@ -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']
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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).
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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))
|
5
tox.ini
5
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
|
||||
exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build
|
||||
|
|
Loading…
Reference in New Issue