summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimo Sorce <simo@redhat.com>2013-05-06 12:29:34 -0400
committerSimo Sorce <simo@redhat.com>2013-08-20 11:54:38 -0400
commitffa55f7a8cbc824b03cec8cbfbb380b42f9c3e70 (patch)
tree4e4f4967a79e3b57235a0ee1e1008033024f72ad
parent9c92d27937f733645631eb43a1ad48bae78d630c (diff)
downloadkeystone-ffa55f7a8cbc824b03cec8cbfbb380b42f9c3e70.tar.gz
keystone-ffa55f7a8cbc824b03cec8cbfbb380b42f9c3e70.tar.xz
keystone-ffa55f7a8cbc824b03cec8cbfbb380b42f9c3e70.zip
Initial KDS service
The Key Distribution Service is used to register keys for services and distribute tickets to contact othe services. The KDS is used to digitally sign and optionally encrypt messages sent over the message queue by the rpc modules. It implements the service described in this document: https://wiki.openstack.org/wiki/MessageSecurity#A_Key_Distribution_Server_in_Keystone blueprint key-distribution-server Change-Id: Ib47aca8f72623a07ff18f23d46d0af520e463fc9 Signed-off-by: Simo Sorce <simo@redhat.com>
-rw-r--r--etc/keystone-paste.ini5
-rw-r--r--etc/keystone.conf.sample7
-rw-r--r--keystone/contrib/kds/__init__.py19
-rw-r--r--keystone/contrib/kds/backends/__init__.py0
-rw-r--r--keystone/contrib/kds/backends/sql.py68
-rw-r--r--keystone/contrib/kds/controllers.py31
-rw-r--r--keystone/contrib/kds/core.py234
-rw-r--r--keystone/contrib/kds/migrate_repo/__init__.py0
-rw-r--r--keystone/contrib/kds/migrate_repo/migrate.cfg20
-rw-r--r--keystone/contrib/kds/migrate_repo/versions/001_kds_table.py37
-rw-r--r--keystone/contrib/kds/migrate_repo/versions/__init__.py0
-rw-r--r--keystone/contrib/kds/routers.py39
-rw-r--r--keystone/service.py2
-rw-r--r--keystone/tests/core.py1
-rw-r--r--keystone/tests/test_sql_migrate_extensions.py17
-rw-r--r--requirements.txt1
16 files changed, 480 insertions, 1 deletions
diff --git a/etc/keystone-paste.ini b/etc/keystone-paste.ini
index 9c5545db..fb66397e 100644
--- a/etc/keystone-paste.ini
+++ b/etc/keystone-paste.ini
@@ -45,6 +45,9 @@ paste.filter_factory = keystone.contrib.stats:StatsExtension.factory
[filter:access_log]
paste.filter_factory = keystone.contrib.access:AccessLogMiddleware.factory
+[filter:kds_extension]
+paste.filter_factory = keystone.contrib.kds.routers:KDSExtension.factory
+
[app:public_service]
paste.app_factory = keystone.service:public_app_factory
@@ -61,7 +64,7 @@ pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_bo
pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_body json_body ec2_extension s3_extension crud_extension admin_service
[pipeline:api_v3]
-pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_body json_body ec2_extension s3_extension service_v3
+pipeline = access_log sizelimit url_normalize token_auth admin_token_auth xml_body json_body ec2_extension s3_extension kds_extension service_v3
[app:public_version_service]
paste.app_factory = keystone.service:public_version_app_factory
diff --git a/etc/keystone.conf.sample b/etc/keystone.conf.sample
index 13d14317..68af3f59 100644
--- a/etc/keystone.conf.sample
+++ b/etc/keystone.conf.sample
@@ -160,6 +160,13 @@
[ec2]
# driver = keystone.contrib.ec2.backends.kvs.Ec2
+[kds]
+# driver = keystone.contrib.kds.backends.sql.KDS
+# master_key_file = /etc/keystone/kds.mkey
+# enctype = AES
+# hashtype = SHA256
+# ticket_lifetime = 3600
+
[assignment]
# driver =
diff --git a/keystone/contrib/kds/__init__.py b/keystone/contrib/kds/__init__.py
new file mode 100644
index 00000000..e5faf9e5
--- /dev/null
+++ b/keystone/contrib/kds/__init__.py
@@ -0,0 +1,19 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+# flake8: noqa
+
+# Copyright 2012 OpenStack LLC
+#
+# 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 keystone.contrib.kds.routers import *
+from keystone.contrib.kds.core import *
diff --git a/keystone/contrib/kds/backends/__init__.py b/keystone/contrib/kds/backends/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/keystone/contrib/kds/backends/__init__.py
diff --git a/keystone/contrib/kds/backends/sql.py b/keystone/contrib/kds/backends/sql.py
new file mode 100644
index 00000000..b7878f2a
--- /dev/null
+++ b/keystone/contrib/kds/backends/sql.py
@@ -0,0 +1,68 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 Red Hat, Inc.
+#
+# 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 base64
+import hashlib
+
+from keystone.common import sql
+
+
+class Keys(sql.ModelBase, sql.DictBase):
+ __tablename__ = 'kds_keys'
+ attributes = ['id', 'name', 'key']
+ id = sql.Column(sql.String(64), primary_key=True)
+ name = sql.Column(sql.Text())
+ key = sql.Column(sql.JsonBlob())
+ extra = sql.Column(sql.JsonBlob())
+
+
+class KDS(sql.Base):
+
+ def _id_from_name(self, name):
+ return hashlib.sha256(name).hexdigest()
+
+ @sql.handle_conflicts(type='kds_keys')
+ def set_shared_key(self, kds_id, key):
+ session = self.get_session()
+
+ #try to remove existing entry first if any
+ try:
+ with session.begin():
+ id = self._id_from_name(kds_id)
+ key_ref = session.query(Keys).filter_by(id=id).first()
+ session.delete(key_ref)
+ session.flush()
+ except Exception:
+ # if the entry does not exist we'll create it later
+ pass
+
+ with session.begin():
+ d = dict()
+ d['id'] = self._id_from_name(kds_id)
+ d['name'] = kds_id
+ d['key'] = {'key_v1': base64.b64encode(key)}
+ key_ref = Keys.from_dict(d)
+ session.add(key_ref)
+ session.flush()
+
+ def get_shared_key(self, kds_id):
+ session = self.get_session()
+ id = self._id_from_name(kds_id)
+ key_ref = session.query(Keys).filter_by(id=id).first()
+ if not key_ref:
+ return None
+ d = key_ref.to_dict()
+ return base64.b64decode(d['key']['key_v1'])
diff --git a/keystone/contrib/kds/controllers.py b/keystone/contrib/kds/controllers.py
new file mode 100644
index 00000000..5af913d6
--- /dev/null
+++ b/keystone/contrib/kds/controllers.py
@@ -0,0 +1,31 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 Red Hat, Inc.
+#
+# 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 keystone.common import dependency
+from keystone.common import wsgi
+
+
+@dependency.requires('identity_api', 'kds_api', 'policy_api', 'token_api')
+class KDSController(wsgi.Application):
+ def get_info(self, context):
+ return {'version': '0.0.1'}
+
+ def get_ticket(self, context, signature, request):
+ return self.kds_api.get_ticket(signature, request)
+
+ def set_key(self, context, name, request):
+ self.assert_admin(context)
+ return self.kds_api.set_key(name, request)
diff --git a/keystone/contrib/kds/core.py b/keystone/contrib/kds/core.py
new file mode 100644
index 00000000..d707482d
--- /dev/null
+++ b/keystone/contrib/kds/core.py
@@ -0,0 +1,234 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 Red Hat, Inc.
+#
+# 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.
+
+"""Main entry point into the Key distribution Server service."""
+
+import base64
+import errno
+import os
+import time
+
+from keystone.openstack.common.crypto import utils as cryptoutils
+from keystone.openstack.common import jsonutils
+from oslo.config import cfg
+
+from keystone.common import dependency
+from keystone.common import logging
+from keystone.common import manager
+from keystone import config
+from keystone import exception
+
+
+CONF = config.CONF
+kds_opts = [
+ cfg.StrOpt('driver', default='keystone.contrib.kds.backends.sql.KDS'),
+ cfg.StrOpt('master_key_file', default='/etc/keystone/kds.mkey'),
+ cfg.StrOpt('enctype', default='AES'),
+ cfg.StrOpt('hashtype', default='SHA256'),
+ cfg.IntOpt('ticket_lifetime', default='3600')
+]
+CONF.register_group(cfg.OptGroup(name='kds',
+ title='Key Distribution Server opts'))
+CONF.register_opts(kds_opts, group='kds')
+
+LOG = logging.getLogger(__name__)
+
+KEY_SIZE = 16
+
+
+@dependency.provider('kds_api')
+class Manager(manager.Manager):
+ """Default pivot point for the KDS backend.
+
+ See :mod:`keystone.common.manager.Manager` for more details on how this
+ dynamically calls the backend.
+
+ """
+
+ def __init__(self):
+ self.crypto = cryptoutils.SymmetricCrypto(enctype=CONF.kds.enctype,
+ hashtype=CONF.kds.hashtype)
+ self.hkdf = cryptoutils.HKDF(hashtype=CONF.kds.hashtype)
+ self.ttl = CONF.kds.ticket_lifetime
+
+ super(Manager, self).__init__(CONF.kds.driver)
+
+ try:
+ with open(CONF.kds.master_key_file, 'r') as f:
+ self.mkey = base64.b64decode(f.read())
+ except IOError as e:
+ if e.errno == errno.ENOENT:
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
+ try:
+ f = os.open(CONF.kds.master_key_file, flags, 0600)
+ self.mkey = self.crypto.new_key(KEY_SIZE)
+ os.write(f, base64.b64encode(self.mkey))
+ except Exception:
+ os.remove(CONF.kds.master_key_file)
+ finally:
+ if f > 0:
+ os.close(f)
+ else:
+ # the file could be unreadbale due to bad permissions
+ # so just pop up whatever error comes
+ raise
+
+ if len(self.mkey) != KEY_SIZE:
+ raise Exception('Invalid Master Key Size')
+
+ def _split_key(self, key, size):
+ sig_key = key[:size]
+ enc_key = key[size:]
+ return sig_key, enc_key
+
+ def _get_master_keys(self, key_id):
+ if not self.mkey:
+ raise exception.UnexpectedError('Failed to find mkey')
+
+ km = self.hkdf.expand(self.mkey, key_id, 2 * KEY_SIZE)
+ return self._split_key(km, KEY_SIZE)
+
+ def _encrypt_keyblock(self, key_id, keyblock):
+ skey, ekey = self._get_master_keys(key_id)
+ try:
+ enc = self.crypto.encrypt(ekey, keyblock, b64encode=False)
+ except Exception:
+ raise exception.UnexpectedError('Failed to encrypt key')
+
+ try:
+ sig = self.crypto.sign(skey, enc, b64encode=False)
+ except Exception:
+ raise exception.UnexpectedError('Failed to sign key')
+
+ return sig, enc
+
+ def _decrypt_keyblock(self, key_id, keyblock):
+ sig, enc = self._split_key(keyblock, self.crypto.hashfn.digest_size)
+ skey, ekey = self._get_master_keys(key_id)
+
+ # signature check
+ try:
+ sigc = self.crypto.sign(skey, enc, b64encode=False)
+ if not sigc == sig:
+ raise exception.UnexpectedError('Signature check failed')
+ except Exception:
+ raise exception.UnexpectedError('Failed to verify key')
+
+ try:
+ plain = self.crypto.decrypt(ekey, enc, b64decode=False)
+ except Exception:
+ raise exception.UnexpectedError('Failed to decrypt key')
+
+ return plain
+
+ def _get_key(self, key_id):
+ """Decrypts the provided encoded key and returns
+ the clear text key.
+
+ :param key_id: Key Identifier
+ """
+ # Get Requestor's encypted key
+ key = self.driver.get_shared_key(key_id)
+ if not key:
+ raise exception.Unauthorized('Invalid Requestor')
+
+ return self._decrypt_keyblock(key_id, key)
+
+ def _set_key(self, key_id, keyblock):
+ """Encrypts the provided key and stores it.
+
+ :param keyblock: The key to encrypt
+ """
+ sig, enc = self._encrypt_keyblock(key_id, keyblock)
+ self.driver.set_shared_key(key_id, sig + enc)
+
+ def get_ticket(self, sig, req):
+ if not ('metadata' in req):
+ raise exception.Forbidden('Invalid Request format')
+ try:
+ meta = jsonutils.loads(base64.b64decode(req['metadata']))
+ except Exception:
+ raise exception.Forbidden('Invalid Request format')
+
+ if not ('requestor' in meta):
+ raise exception.Forbidden('Invalid Request format')
+
+ rkey = self._get_key(meta['requestor'])
+
+ try:
+ signature = self.crypto.sign(rkey, req['metadata'])
+ except Exception:
+ raise exception.Unauthorized('Invalid Request')
+
+ if signature != sig:
+ raise exception.Unauthorized('Invalid Request')
+
+ timestamp = time.time()
+
+ if meta['timestamp'] < (timestamp - self.ttl):
+ raise exception.Unauthorized('Invalid Request (expired)')
+
+ #TODO(simo): check and store signature for replay attack detection
+
+ tkey = self._get_key(meta['target'])
+ if not tkey:
+ raise exception.Unauthorized('Invalid Target')
+
+ # use new_key to get a random salt
+ rndkey = self.hkdf.extract(rkey, self.crypto.new_key(KEY_SIZE))
+
+ info = '%s,%s,%s' % (meta['requestor'], meta['target'], str(timestamp))
+
+ sek = self.hkdf.expand(rndkey, info, KEY_SIZE * 2)
+ skey, ekey = self._split_key(sek, KEY_SIZE)
+ keydata = {'key': base64.b64encode(rndkey),
+ 'timestamp': timestamp, 'ttl': self.ttl}
+ esek = self.crypto.encrypt(tkey, jsonutils.dumps(keydata))
+ ticket = jsonutils.dumps({'skey': base64.b64encode(skey),
+ 'ekey': base64.b64encode(ekey),
+ 'esek': esek})
+
+ rep = dict()
+ metadata = jsonutils.dumps({'source': meta['requestor'],
+ 'destination': meta['target'],
+ 'expiration': (keydata['timestamp']
+ + keydata['ttl']),
+ 'encryption': True})
+ rep['metadata'] = base64.b64encode(metadata)
+ rep['ticket'] = self.crypto.encrypt(rkey, ticket)
+ rep['signature'] = self.crypto.sign(rkey,
+ (rep['metadata'] + rep['ticket']))
+
+ return {'reply': rep}
+
+ def set_key(self, name, req):
+ self._set_key(name, base64.b64decode(req['key']))
+
+
+class Driver(object):
+ """Interface description for a KDS driver."""
+
+ def set_shared_key(self, kds_id, key):
+ """Set key related to kds_id."""
+ raise exception.NotImplemented()
+
+ def get_shared_key(self, kds_id):
+ """Get key related to kds_id.
+
+ :returns: key
+ :raises: keystone.exception.ServiceNotFound
+ """
+ raise exception.NotImplemented()
diff --git a/keystone/contrib/kds/migrate_repo/__init__.py b/keystone/contrib/kds/migrate_repo/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/keystone/contrib/kds/migrate_repo/__init__.py
diff --git a/keystone/contrib/kds/migrate_repo/migrate.cfg b/keystone/contrib/kds/migrate_repo/migrate.cfg
new file mode 100644
index 00000000..016cd278
--- /dev/null
+++ b/keystone/contrib/kds/migrate_repo/migrate.cfg
@@ -0,0 +1,20 @@
+[db_settings]
+# Used to identify which repository this database is versioned under.
+# You can use the name of your project.
+repository_id=kds
+
+# The name of the database table used to track the schema version.
+# This name shouldn't already be used by your project.
+# If this is changed once a database is under version control, you'll need to
+# change the table name in each database too.
+version_table=migrate_version
+
+# When committing a change script, Migrate will attempt to generate the
+# sql for all supported databases; normally, if one of them fails - probably
+# because you don't have that database installed - it is ignored and the
+# commit continues, perhaps ending successfully.
+# Databases in this list MUST compile successfully during a commit, or the
+# entire commit will fail. List the databases your application will actually
+# be using to ensure your updates to that database work properly.
+# This must be a list; example: ['postgres','sqlite']
+required_dbs=[]
diff --git a/keystone/contrib/kds/migrate_repo/versions/001_kds_table.py b/keystone/contrib/kds/migrate_repo/versions/001_kds_table.py
new file mode 100644
index 00000000..10e6e604
--- /dev/null
+++ b/keystone/contrib/kds/migrate_repo/versions/001_kds_table.py
@@ -0,0 +1,37 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 Red Hat, Inc
+#
+# 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 sqlalchemy as sql
+
+
+def upgrade(migrate_engine):
+ meta = sql.MetaData()
+ meta.bind = migrate_engine
+
+ kds_table = sql.Table('kds_keys', meta,
+ sql.Column('id', sql.String(64), primary_key=True),
+ sql.Column('name', sql.Text()),
+ sql.Column('key', sql.Text()),
+ sql.Column('extra', sql.Text()))
+ kds_table.create(migrate_engine, checkfirst=True)
+
+
+def downgrade(migrate_engine):
+ meta = sql.MetaData()
+ meta.bind = migrate_engine
+
+ table = sql.Table('kds_keys', meta, autoload=True)
+ table.drop(migrate_engine, checkfirst=True)
diff --git a/keystone/contrib/kds/migrate_repo/versions/__init__.py b/keystone/contrib/kds/migrate_repo/versions/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/keystone/contrib/kds/migrate_repo/versions/__init__.py
diff --git a/keystone/contrib/kds/routers.py b/keystone/contrib/kds/routers.py
new file mode 100644
index 00000000..49b77796
--- /dev/null
+++ b/keystone/contrib/kds/routers.py
@@ -0,0 +1,39 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 Red Hat, Inc.
+#
+# 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 keystone.common import wsgi
+from keystone.contrib.kds import controllers
+
+
+class KDSExtension(wsgi.ExtensionRouter):
+ def add_routes(self, mapper):
+ kds_controller = controllers.KDSController()
+
+ # crud
+ mapper.connect('/kds',
+ controller=kds_controller,
+ action='get_info',
+ conditions=dict(method=['GET']))
+
+ mapper.connect('/kds/ticket/{signature}',
+ controller=kds_controller,
+ action='get_ticket',
+ conditions=dict(method=['POST']))
+
+ mapper.connect('/kds/key/{name}',
+ controller=kds_controller,
+ action='set_key',
+ conditions=dict(method=['PUT']))
diff --git a/keystone/service.py b/keystone/service.py
index e3633865..45ba501e 100644
--- a/keystone/service.py
+++ b/keystone/service.py
@@ -25,6 +25,7 @@ from keystone.common import wsgi
from keystone import config
from keystone.contrib import ec2
from keystone.contrib import oauth1
+from keystone.contrib import kds
from keystone import controllers
from keystone import credential
from keystone import identity
@@ -49,6 +50,7 @@ DRIVERS = dict(
catalog_api=catalog.Manager(),
credentials_api=credential.Manager(),
ec2_api=ec2.Manager(),
+ kds_api=kds.Manager(),
identity_api=_IDENTITY_API,
oauth1_api=oauth1.Manager(),
policy_api=policy.Manager(),
diff --git a/keystone/tests/core.py b/keystone/tests/core.py
index cba6cbf8..336d2eb5 100644
--- a/keystone/tests/core.py
+++ b/keystone/tests/core.py
@@ -222,6 +222,7 @@ class TestCase(NoModule, unittest.TestCase):
testsdir('test_overrides.conf')])
self.mox = mox.Mox()
self.opt(policy_file=etcdir('policy.json'))
+ self.opt_in_group('kds', master_key_file=tmpdir('kds.mkey'))
self.stubs = stubout.StubOutForTesting()
self.stubs.Set(exception, '_FATAL_EXCEPTION_FORMAT_ERRORS', True)
diff --git a/keystone/tests/test_sql_migrate_extensions.py b/keystone/tests/test_sql_migrate_extensions.py
index f9393cbe..cd4ffec3 100644
--- a/keystone/tests/test_sql_migrate_extensions.py
+++ b/keystone/tests/test_sql_migrate_extensions.py
@@ -28,6 +28,7 @@ To run these tests against a live database:
from keystone.contrib import example
from keystone.contrib import oauth1
+from keystone.contrib import kds
import test_sql_upgrade
@@ -108,3 +109,19 @@ class SqlUpgradeOAuth1Extension(test_sql_upgrade.SqlMigrateBase):
self.assertTableDoesNotExist('consumer')
self.assertTableDoesNotExist('request_token')
self.assertTableDoesNotExist('access_token')
+
+
+class SqlUpgradeKdsExtension(test_sql_upgrade.SqlMigrateBase):
+ def repo_package(self):
+ return kds
+
+ def test_upgrade(self):
+ self.assertTableDoesNotExist('kds_keys')
+ self.upgrade(1, repository=self.repo_path)
+ self.assertTableColumns('kds_keys', ['id', 'name', 'key', 'extra'])
+
+ def test_downgrade(self):
+ self.upgrade(1, repository=self.repo_path)
+ self.assertTableColumns('kds_keys', ['id', 'name', 'key', 'extra'])
+ self.downgrade(0, repository=self.repo_path)
+ self.assertTableDoesNotExist('kds_keys')
diff --git a/requirements.txt b/requirements.txt
index 7b6190d8..e767b7e6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -18,3 +18,4 @@ python-keystoneclient>=0.3.0
oslo.config>=1.1.0
Babel>=0.9.6
oauth2
+pycrypto>=2.6