summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorZiad Sawalha <github@highbridgellc.com>2011-12-06 16:26:32 -0600
committerZiad Sawalha <github@highbridgellc.com>2011-12-14 07:27:55 -0600
commit7b5e804b76591a99100a2d12d1f9ef4976da94cc (patch)
treedacbc1910d72f975f53d6f2b682a19cf14308d1b
parenta09a66ce91fbe7e4f5526eb68f174c4d698e5880 (diff)
D5 Compatibility Support
Fixes bug 900916 Added a D5-compat front-end for Keystone which responds in D5 syntax if it receives a D5-formatted request. It also formats responses to requests that can't be identified as D5 or Diablo final in dual/compatible format (ugly, but works). This is intended to be around until Essex (maybe we deprecate sooner) Change-Id: I050d77ee3acc9d91732b5099774d82d6492ec1ca
-rw-r--r--doc/source/keystone.conf.rst6
-rw-r--r--etc/keystone.conf15
-rw-r--r--etc/memcache.conf5
-rw-r--r--etc/ssl.conf5
-rw-r--r--keystone/frontends/d5_compat.py418
-rw-r--r--keystone/test/etc/ldap.conf.template5
-rw-r--r--keystone/test/etc/memcache.conf.template5
-rw-r--r--keystone/test/etc/sql.conf.template5
-rw-r--r--keystone/test/etc/ssl.conf.template5
-rw-r--r--keystone/test/functional/common.py13
-rw-r--r--keystone/test/functional/test_d5_compat_calls.py114
-rw-r--r--keystone/test/unit/test_d5_compat.py159
12 files changed, 749 insertions, 6 deletions
diff --git a/doc/source/keystone.conf.rst b/doc/source/keystone.conf.rst
index 64798902..d2cdc1d5 100644
--- a/doc/source/keystone.conf.rst
+++ b/doc/source/keystone.conf.rst
@@ -85,13 +85,14 @@ keystone.conf example
[pipeline:admin]
pipeline =
urlrewritefilter
+ d5_compat
admin_api
[pipeline:keystone-legacy-auth]
pipeline =
urlrewritefilter
legacy_auth
- RAX-KEY-extension
+ d5_compat
service_api
[app:service_api]
@@ -106,6 +107,9 @@ keystone.conf example
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
+ [filter:d5_compat]
+ paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:RAX-KEY-extension]
paste.filter_factory = keystone.contrib.extensions.service.raxkey.frontend:filter_factory
diff --git a/etc/keystone.conf b/etc/keystone.conf
index c7109a15..55d09378 100644
--- a/etc/keystone.conf
+++ b/etc/keystone.conf
@@ -89,14 +89,16 @@ sql_idle_timeout = 30
[pipeline:admin]
pipeline =
- urlrewritefilter
- admin_api
+ urlrewritefilter
+ d5_compat
+ admin_api
[pipeline:keystone-legacy-auth]
pipeline =
- urlrewritefilter
- legacy_auth
- service_api
+ urlrewritefilter
+ legacy_auth
+ d5_compat
+ service_api
[app:service_api]
paste.app_factory = keystone.server:service_app_factory
@@ -110,5 +112,8 @@ paste.filter_factory = keystone.middleware.url:filter_factory
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
+[filter:d5_compat]
+paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:debug]
paste.filter_factory = keystone.common.wsgi:debug_filter_factory
diff --git a/etc/memcache.conf b/etc/memcache.conf
index ade2523e..1780b1b5 100644
--- a/etc/memcache.conf
+++ b/etc/memcache.conf
@@ -50,12 +50,14 @@ cache_time = 86400
[pipeline:admin]
pipeline =
urlrewritefilter
+ d5_compat
admin_api
[pipeline:keystone-legacy-auth]
pipeline =
urlrewritefilter
legacy_auth
+ d5_compat
service_api
[app:service_api]
@@ -67,5 +69,8 @@ paste.app_factory = keystone.server:admin_app_factory
[filter:urlrewritefilter]
paste.filter_factory = keystone.middleware.url:filter_factory
+[filter:d5_compat]
+paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
diff --git a/etc/ssl.conf b/etc/ssl.conf
index 9bf212f5..829cac8f 100644
--- a/etc/ssl.conf
+++ b/etc/ssl.conf
@@ -47,12 +47,14 @@ backend_entities = ['Endpoints', 'Credentials', 'EndpointTemplates', 'Tenant',
[pipeline:admin]
pipeline =
urlrewritefilter
+ d5_compat
admin_api
[pipeline:keystone-legacy-auth]
pipeline =
urlrewritefilter
legacy_auth
+ d5_compat
service_api
[app:service_api]
@@ -64,5 +66,8 @@ paste.app_factory = keystone.server:admin_app_factory
[filter:urlrewritefilter]
paste.filter_factory = keystone.middleware.url:filter_factory
+[filter:d5_compat]
+paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
diff --git a/keystone/frontends/d5_compat.py b/keystone/frontends/d5_compat.py
new file mode 100644
index 00000000..6dd08e9e
--- /dev/null
+++ b/keystone/frontends/d5_compat.py
@@ -0,0 +1,418 @@
+#!/usr/bin/env python
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright (c) 2010-2011 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.
+
+"""
+D5 API Compatibility Module
+
+This WSGI component adds support for the D5 API contract. That contract was
+an unofficial contract that made it into live deployments in the wild, so
+this middleware is an attempt to support production deployemnts of that
+code and allow them to interoperate with Keystone trunk while gradually moving
+to updated Keystone code.
+
+The middleware transforms responses in this way:
+- POST /tokens that come in D5 format (not wrapped in "auth":{}) will receive
+ a D5 formatted response (wrapped in "auth":{} instead of "access":{})
+- GET /tokens/{id} will respond with both an "auth" and an "access" wrapper
+ (since we can't tell if the caller is expecting a D5 or Diablo final
+ response)
+
+Notes:
+- GET /tokens will not repond in D5 syntax in XML (because only one root
+ can exist in XML and I chose not to break Diablo)
+- This relies on the URL normalizer (middlewre/url.py) to set
+ KEYSTONE_API_VERSION. Without that set to '2.0', this middleware does
+ nothing
+"""
+
+import copy
+import json
+from lxml import etree
+import os
+import sys
+
+from webob.exc import Request
+
+POSSIBLE_TOPDIR = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
+ os.pardir,
+ os.pardir,
+ os.pardir))
+if os.path.exists(os.path.join(POSSIBLE_TOPDIR, 'keystone', '__init__.py')):
+ sys.path.insert(0, POSSIBLE_TOPDIR)
+
+from keystone.logic.types import fault
+import keystone.utils as utils
+
+PROTOCOL_NAME = "D5 API Compatibility"
+
+
+class D5AuthBase(object):
+ """ Handles validating json and XML syntax of auth requests """
+
+ def __init__(self, tenant_id=None, tenant_name=None):
+ self.tenant_id = tenant_id
+ self.tenant_name = tenant_name
+
+ @staticmethod
+ def _validate_auth(obj, *valid_keys):
+ root = obj.keys()[0]
+
+ for key in root:
+ if not key in valid_keys:
+ raise fault.BadRequestFault('Invalid attribute(s): %s' % key)
+
+ if root.get('tenantId') and root.get('tenantName'):
+ raise fault.BadRequestFault(
+ 'Expecting either Tenant ID or Tenant Name, but not both')
+
+ return root
+
+ @staticmethod
+ def _validate_key(obj, key, required_keys, optional_keys):
+ if not key in obj:
+ raise fault.BadRequestFault('Expecting %s' % key)
+
+ ret = obj[key]
+
+ for skey in ret:
+ if not skey in required_keys and not skey in optional_keys:
+ raise fault.BadRequestFault('Invalid attribute(s): %s' % skey)
+
+ for required_key in required_keys:
+ if not ret.get(required_key):
+ raise fault.BadRequestFault('Expecting %s:%s' %
+ (key, required_key))
+ return ret
+
+
+class D5AuthWithPasswordCredentials(D5AuthBase):
+ def __init__(self, username, password, tenant_id=None, tenant_name=None):
+ super(D5AuthWithPasswordCredentials, self).__init__(tenant_id,
+ tenant_name)
+ self.username = username
+ self.password = password
+
+ @staticmethod
+ def from_xml(xml_str):
+ try:
+ dom = etree.Element("root")
+ dom.append(etree.fromstring(xml_str))
+ password_credentials = \
+ dom.find("{http://docs.openstack.org/identity/api/v2.0}"
+ "passwordCredentials")
+ if password_credentials is None:
+ raise fault.BadRequestFault("Expecting passwordCredentials")
+ tenant_id = password_credentials.get("tenantId")
+ tenant_name = password_credentials.get("tenantName")
+ username = password_credentials.get("username")
+ utils.check_empty_string(username, "Expecting a username")
+ password = password_credentials.get("password")
+ utils.check_empty_string(password, "Expecting a password")
+
+ if tenant_id and tenant_name:
+ raise fault.BadRequestFault(
+ "Expecting either Tenant ID or Tenant Name, but not both")
+
+ return D5AuthWithPasswordCredentials(username, password,
+ tenant_id, tenant_name)
+ except etree.LxmlError as e:
+ raise fault.BadRequestFault("Cannot parse passwordCredentials",
+ str(e))
+
+ @staticmethod
+ def from_json(json_str):
+ try:
+ obj = json.loads(json_str)
+
+ cred = D5AuthBase._validate_key(obj, 'passwordCredentials',
+ required_keys=['username', 'password'],
+ optional_keys=['tenantId', 'tenantName'])
+
+ return D5AuthWithPasswordCredentials(cred['username'],
+ cred['password'],
+ cred.get('tenantId'),
+ cred.get('tenantName'))
+ except (ValueError, TypeError) as e:
+ raise fault.BadRequestFault("Cannot parse passwordCredentials",
+ str(e))
+
+ def to_json(self):
+ """ Format the response in Diablo/Stable contract format """
+ data = {"auth": {"passwordCredentials": {
+ "username": self.username,
+ "password": self.password}}}
+ if self.tenant_id:
+ data["auth"]["tenantId"] = self.tenant_id
+ else:
+ if self.tenant_name:
+ data["auth"]["tenant_name"] = self.tenant_name
+ return json.dumps(data)
+
+ def to_xml(self):
+ """ Format the response in Diablo/Stable contract format """
+ dom = etree.Element("auth",
+ xmlns="http://docs.openstack.org/identity/api/v2.0")
+
+ password_credentials = etree.Element("passwordCredentials",
+ username=self.username,
+ password=self.password)
+
+ if self.tenant_id:
+ dom.set("tenantId", self.tenant_id)
+ else:
+ if self.tenant_name:
+ dom.set("tenant_name", self.tenant_name)
+
+ dom.append(password_credentials)
+
+ return etree.tostring(dom)
+
+
+class D5toDiabloAuthData(object):
+ """Authentation Information returned upon successful login.
+
+ This class handles rendering to JSON and XML. It renders
+ the token, the user data, the roles, and the service catalog.
+ """
+ xml = None
+ json = None
+
+ def __init__(self, init_json=None, init_xml=None):
+ if init_json:
+ self.json = init_json
+ if init_xml is not None:
+ self.xml = init_xml
+
+ @staticmethod
+ def from_xml(xml_str):
+ """ Verify Diablo syntax and return class initialized with data"""
+ try:
+ dom = etree.Element("root")
+ dom.append(etree.fromstring(xml_str))
+ root = \
+ dom.find("{http://docs.openstack.org/identity/api/v2.0}"
+ "access")
+ if root is None:
+ raise fault.BadRequestFault("Expecting access")
+ return D5toDiabloAuthData(init_xml=root)
+ except etree.LxmlError as e:
+ raise fault.BadRequestFault("Cannot parse Diablo response",
+ str(e))
+
+ @staticmethod
+ def from_json(json_str):
+ """ Verify Diablo syntax and return class initialized with data"""
+ try:
+ obj = json.loads(json_str)
+ auth = obj["access"]
+ return D5toDiabloAuthData(init_json=auth)
+ except (ValueError, TypeError) as e:
+ raise fault.BadRequestFault("Cannot parse auth response",
+ str(e))
+
+ def to_xml(self):
+ """ Convert to D5 syntax from Diablo"""
+ if self.xml is None:
+ if self.json is None:
+ raise NotImplementedError
+ else:
+ raise fault.IdentityFault("%s not initialized with data" % \
+ self.__class__.__str__)
+ dom = etree.Element("auth",
+ xmlns="http://docs.openstack.org/identity/api/v2.0")
+ for element in self.xml:
+ dom.append(element)
+ return etree.tostring(dom)
+
+ def to_json(self):
+ """ Convert to D5 syntax from Diablo"""
+ if self.json is None:
+ if self.xml is None:
+ raise NotImplementedError
+ else:
+ raise fault.IdentityFault("%s not initialized with data" % \
+ self.__class__.__str__)
+ d5_data = {"auth": {}}
+ for key, value in self.json.iteritems():
+ d5_data["auth"][key] = value
+
+ return json.dumps(d5_data)
+
+
+class D5ValidateData(object):
+ """Authentation Information returned upon successful token validation."""
+ xml = None
+ json = None
+
+ def __init__(self, init_json=None, init_xml=None):
+ if init_json:
+ self.json = init_json
+ if init_xml is not None:
+ self.xml = init_xml
+
+ @staticmethod
+ def from_xml(xml_str):
+ """ Verify Diablo syntax and return class initialized with data"""
+ try:
+ dom = etree.Element("root")
+ dom.append(etree.fromstring(xml_str))
+ root = \
+ dom.find("{http://docs.openstack.org/identity/api/v2.0}"
+ "access")
+ if root is None:
+ raise fault.BadRequestFault("Expecting access")
+ return D5ValidateData(init_xml=root)
+ except etree.LxmlError as e:
+ raise fault.BadRequestFault("Cannot parse Diablo response",
+ str(e))
+
+ @staticmethod
+ def from_json(json_str):
+ """ Verify Diablo syntax and return class initialized with data"""
+ try:
+ obj = json.loads(json_str)
+ return D5ValidateData(init_json=obj)
+ except (ValueError, TypeError) as e:
+ raise fault.BadRequestFault("Cannot parse auth response",
+ str(e))
+
+ def to_xml(self):
+ """ Returns only Diablo syntax (can only have one root in XML)
+
+ This middleware is designed to provide D5 compatibility but NOT
+ at the expense of breaking the Diablo contract."""
+ if self.xml is None:
+ if self.json is None:
+ raise NotImplementedError
+ else:
+ raise fault.IdentityFault("%s not initialized with data" % \
+ self.__class__.__str__)
+ return etree.tostring(self.xml)
+
+ def to_json(self):
+ """ Returns both Diablo and D5 syntax ("access" and "auth")"""
+ if self.json is None:
+ if self.xml is None:
+ raise NotImplementedError
+ else:
+ raise fault.IdentityFault("%s not initialized with data" % \
+ self.__class__.__str__)
+ d5_data = self.json.copy()
+ auth = {}
+ for key, value in self.json["access"].iteritems():
+ auth[key] = copy.copy(value)
+ if "user" in auth:
+ # D5 returns 'username' only
+ user = auth["user"]
+ user["username"] = user["name"]
+ del user["name"]
+ del user["id"]
+
+ # D5 has 'tenantId' under token
+ token = auth["token"]
+ tenant = token["tenant"]
+ token["tenantId"] = tenant["id"]
+
+ if "roles" in auth["user"]:
+ auth["user"]["roleRefs"] = []
+ rolerefs = auth["user"]["roleRefs"]
+ for role in auth["user"]["roles"]:
+ ref = {}
+ ref["id"] = role["id"]
+ ref["roleId"] = role["name"]
+ if "tenantId" in role:
+ ref["tenantId"] = role["tenantId"]
+ rolerefs.append(ref)
+ del auth["user"]["roles"]
+ d5_data["auth"] = auth
+
+ return json.dumps(d5_data)
+
+
+class D5AuthProtocol(object):
+ """D5 Cmpatibility Middleware that transforms client calls and responses"""
+
+ def __init__(self, app, conf):
+ """ Common initialization code """
+ print "Starting the %s component" % PROTOCOL_NAME
+ self.conf = conf
+ self.app = app
+
+ def __call__(self, env, start_response):
+ """ Handle incoming request. Transform. And send downstream. """
+ request = Request(env)
+ if 'KEYSTONE_API_VERSION' in env and \
+ env['KEYSTONE_API_VERSION'] == '2.0':
+ if request.path.startswith("/tokens"):
+ is_d5_request = False
+ if request.method == "POST":
+ try:
+ auth_with_credentials = \
+ utils.get_normalized_request_content(
+ D5AuthWithPasswordCredentials, request)
+ # Convert request body to Diablo syntax
+ if request.content_type == "application/xml":
+ request.body = auth_with_credentials.to_xml()
+ else:
+ request.body = auth_with_credentials.to_json()
+ is_d5_request = True
+ except:
+ pass
+
+ if is_d5_request:
+ response = request.get_response(self.app)
+ #Handle failures.
+ if not str(response.status).startswith('20'):
+ return response(env, start_response)
+ auth_data = utils.get_normalized_request_content(
+ D5toDiabloAuthData, response)
+ resp = utils.send_result(response.status_int, request,
+ auth_data)
+ return resp(env, start_response)
+ else:
+ # Pass through
+ return self.app(env, start_response)
+
+ elif request.method == "GET":
+ if request.path.endswith("/endpoints"):
+ # Pass through
+ return self.app(env, start_response)
+ else:
+ response = request.get_response(self.app)
+ #Handle failures.
+ if not str(response.status).startswith('20'):
+ return response(env, start_response)
+ validate_data = utils.get_normalized_request_content(
+ D5ValidateData, response)
+ resp = utils.send_result(response.status_int, request,
+ validate_data)
+ return resp(env, start_response)
+
+ # All other calls pass to downstream WSGI component
+ return self.app(env, start_response)
+
+
+def filter_factory(global_conf, **local_conf):
+ """Returns a WSGI filter app for use with paste.deploy."""
+ conf = global_conf.copy()
+ conf.update(local_conf)
+
+ def auth_filter(wsgiapp):
+ """Closure to return"""
+ return D5AuthProtocol(wsgiapp, conf)
+ return auth_filter
diff --git a/keystone/test/etc/ldap.conf.template b/keystone/test/etc/ldap.conf.template
index 43c993dd..be733ff6 100644
--- a/keystone/test/etc/ldap.conf.template
+++ b/keystone/test/etc/ldap.conf.template
@@ -34,12 +34,14 @@ backend_entities = ['Tenant', 'User', 'UserRoleAssociation', 'Role']
[pipeline:admin]
pipeline =
urlrewritefilter
+ d5_compat
admin_api
[pipeline:keystone-legacy-auth]
pipeline =
urlrewritefilter
legacy_auth
+ d5_compat
service_api
[app:service_api]
@@ -51,5 +53,8 @@ paste.app_factory = keystone.server:admin_app_factory
[filter:urlrewritefilter]
paste.filter_factory = keystone.middleware.url:filter_factory
+[filter:d5_compat]
+paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
diff --git a/keystone/test/etc/memcache.conf.template b/keystone/test/etc/memcache.conf.template
index 3fb47f54..62fa7670 100644
--- a/keystone/test/etc/memcache.conf.template
+++ b/keystone/test/etc/memcache.conf.template
@@ -32,12 +32,14 @@ cache_time = 86400
[pipeline:admin]
pipeline =
urlrewritefilter
+ d5_compat
admin_api
[pipeline:keystone-legacy-auth]
pipeline =
urlrewritefilter
legacy_auth
+ d5_compat
service_api
[app:service_api]
@@ -49,5 +51,8 @@ paste.app_factory = keystone.server:admin_app_factory
[filter:urlrewritefilter]
paste.filter_factory = keystone.middleware.url:filter_factory
+[filter:d5_compat]
+paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
diff --git a/keystone/test/etc/sql.conf.template b/keystone/test/etc/sql.conf.template
index 93e72637..e3910da6 100644
--- a/keystone/test/etc/sql.conf.template
+++ b/keystone/test/etc/sql.conf.template
@@ -28,12 +28,14 @@ backend_entities = ['Endpoints', 'Credentials', 'EndpointTemplates', 'Tenant',
[pipeline:admin]
pipeline =
urlrewritefilter
+ d5_compat
admin_api
[pipeline:keystone-legacy-auth]
pipeline =
urlrewritefilter
legacy_auth
+ d5_compat
service_api
[app:service_api]
@@ -45,5 +47,8 @@ paste.app_factory = keystone.server:admin_app_factory
[filter:urlrewritefilter]
paste.filter_factory = keystone.middleware.url:filter_factory
+[filter:d5_compat]
+paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
diff --git a/keystone/test/etc/ssl.conf.template b/keystone/test/etc/ssl.conf.template
index 5c398965..5c309521 100644
--- a/keystone/test/etc/ssl.conf.template
+++ b/keystone/test/etc/ssl.conf.template
@@ -31,12 +31,14 @@ backend_entities = ['Endpoints', 'Credentials', 'EndpointTemplates', 'Tenant',
[pipeline:admin]
pipeline =
urlrewritefilter
+ d5_compat
admin_api
[pipeline:keystone-legacy-auth]
pipeline =
urlrewritefilter
legacy_auth
+ d5_compat
service_api
[app:service_api]
@@ -48,5 +50,8 @@ paste.app_factory = keystone.server:admin_app_factory
[filter:urlrewritefilter]
paste.filter_factory = keystone.middleware.url:filter_factory
+[filter:d5_compat]
+paste.filter_factory = keystone.frontends.d5_compat:filter_factory
+
[filter:legacy_auth]
paste.filter_factory = keystone.frontends.legacy_token_auth:filter_factory
diff --git a/keystone/test/functional/common.py b/keystone/test/functional/common.py
index f2892cab..d9338512 100644
--- a/keystone/test/functional/common.py
+++ b/keystone/test/functional/common.py
@@ -642,6 +642,19 @@ class FunctionalTestCase(ApiTestCase):
return self.post_token(as_json=data, **kwargs)
+ def authenticate_D5(self, user_name=None, user_password=None,
+ tenant_id=None, **kwargs):
+ user_name = optional_str(user_name)
+ user_password = optional_str(user_password)
+
+ data = {"passwordCredentials": {
+ "username": user_name,
+ "password": user_password}}
+ if tenant_id:
+ data["passwordCredentials"]["tenantId"] = tenant_id
+
+ return self.post_token(as_json=data, **kwargs)
+
def authenticate_using_token(self, token, tenant_id=None,
**kwargs):
diff --git a/keystone/test/functional/test_d5_compat_calls.py b/keystone/test/functional/test_d5_compat_calls.py
new file mode 100644
index 00000000..dd6131c1
--- /dev/null
+++ b/keystone/test/functional/test_d5_compat_calls.py
@@ -0,0 +1,114 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+# Copyright (c) 2010-2011 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.
+
+
+import unittest2 as unittest
+
+from keystone.test.functional import common
+
+
+class D5_AuthenticationTest(common.FunctionalTestCase):
+ """ Tests the functionality of the D5 compat module """
+ def setUp(self, *args, **kwargs):
+ super(D5_AuthenticationTest, self).setUp(*args, **kwargs)
+
+ password = common.unique_str()
+ self.tenant = self.create_tenant().json['tenant']
+ self.user = self.create_user(user_password=password,
+ tenant_id=self.tenant['id']).json['user']
+ self.user['password'] = password
+
+ self.services = {}
+ self.endpoint_templates = {}
+ self.services = self.create_service().json['OS-KSADM:service']
+ self.endpoint_templates = self.create_endpoint_template(
+ name=self.services['name'], \
+ type=self.services['type']).\
+ json['OS-KSCATALOG:endpointTemplate']
+ self.create_endpoint_for_tenant(self.tenant['id'],
+ self.endpoint_templates['id'])
+
+ def test_authenticate_for_a_tenant(self):
+ r = self.authenticate_D5(self.user['name'], self.user['password'],
+ self.tenant['id'], assert_status=200)
+
+ self.assertIsNotNone(r.json['auth']['token'])
+ service_catalog = r.json['auth']['serviceCatalog']
+ self.assertIsNotNone(service_catalog)
+ self.check_urls_for_regular_user(service_catalog)
+
+ def test_authenticate_for_a_tenant_xml(self):
+ data = ('<?xml version="1.0" encoding="UTF-8"?> '
+ '<passwordCredentials xmlns="%s" tenantId="%s"'
+ ' username="%s" password="%s" '
+ '/>') % (
+ self.xmlns, self.tenant['id'],
+ self.user['name'], self.user['password'])
+ r = self.post_token(as_xml=data, assert_status=200)
+
+ self.assertEquals(r.xml.tag, '{%s}auth' % self.xmlns)
+ service_catalog = r.xml.find('{%s}serviceCatalog' % self.xmlns)
+ self.check_urls_for_regular_user_xml(service_catalog)
+
+ def test_authenticate_for_a_tenant_on_admin_api(self):
+ r = self.authenticate_D5(self.user['name'], self.user['password'],
+ self.tenant['id'], assert_status=200, request_type='admin')
+
+ self.assertIsNotNone(r.json['auth']['token'])
+ self.assertIsNotNone(r.json['auth']['serviceCatalog'])
+ service_catalog = r.json['auth']['serviceCatalog']
+ self.check_urls_for_regular_user(service_catalog)
+
+ def test_authenticate_for_a_tenant_xml_on_admin_api(self):
+ data = ('<?xml version="1.0" encoding="UTF-8"?> '
+ '<passwordCredentials xmlns="%s" tenantId="%s"'
+ ' username="%s" password="%s" '
+ '/>') % (
+ self.xmlns, self.tenant['id'],
+ self.user['name'], self.user['password'])
+ r = self.post_token(as_xml=data, assert_status=200,
+ request_type='admin')
+
+ self.assertEquals(r.xml.tag, '{%s}auth' % self.xmlns)
+ service_catalog = r.xml.find('{%s}serviceCatalog' % self.xmlns)
+ self.check_urls_for_regular_user_xml(service_catalog)
+
+ def test_authenticate_user_disabled(self):
+ self.disable_user(self.user['id'])
+ self.authenticate_D5(self.user['name'], self.user['password'],
+ self.tenant['id'], assert_status=403)
+
+ def test_authenticate_user_wrong(self):
+ data = {"passwordCredentials": {
+ "username-field-completely-wrong": self.user['name'],
+ "password": self.user['password'],
+ "tenantId": self.tenant['id']}}
+ self.post_token(as_json=data, assert_status=400)
+
+ def test_authenticate_user_wrong_xml(self):
+ data = ('<?xml version="1.0" encoding="UTF-8"?> '
+ '<passwordCredentials '
+ 'xmlns="http://docs.openstack.org/identity/api/v2.0" '
+ 'usernamefieldcompletelywrong="%s" '
+ 'password="%s" '
+ 'tenantId="%s"/>') % (
+ self.user['name'], self.user['password'], self.tenant['id'])
+
+ self.post_token(as_xml=data, assert_status=400)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/keystone/test/unit/test_d5_compat.py b/keystone/test/unit/test_d5_compat.py
new file mode 100644
index 00000000..6b33b0a3
--- /dev/null
+++ b/keystone/test/unit/test_d5_compat.py
@@ -0,0 +1,159 @@
+import json
+import unittest2 as unittest
+from keystone.frontends import d5_compat
+import keystone.logic.types.fault as fault
+
+
+class TestD5Auth(unittest.TestCase):
+ """Test to make sure Keystone honors the 'unofficial' D5 API contract.
+
+ The main differences were:
+ - POST /v2.0/tokens without the "auth" wrapper
+ - POST /v2.0/tokens with tenantId in the passwordCredentials object
+ (instead of the auth wrapper)
+ - Response for validate token was wrapped in "auth"
+
+ TODO(zns): deprecate this once we move to the next version of the API
+ """
+
+ pwd_xml = '<?xml version="1.0" encoding="UTF-8"?>\
+ <passwordCredentials\
+ xmlns="http://docs.openstack.org/identity/api/v2.0" \
+ password="secret" username="disabled" \
+ />'
+
+ def test_pwd_cred_marshall(self):
+ creds = d5_compat.D5AuthWithPasswordCredentials.from_xml(self.pwd_xml)
+ self.assertEqual(creds.password, "secret")
+ self.assertEqual(creds.username, "disabled")
+
+ def test_pwd_creds_from_json(self):
+ data = json.dumps({"passwordCredentials":
+ {"username": "foo", "password": "bar"}})
+ creds = d5_compat.D5AuthWithPasswordCredentials.from_json(data)
+ self.assertEqual(creds.username, "foo")
+ self.assertEqual(creds.password, "bar")
+ self.assertIsNone(creds.tenant_id)
+ self.assertIsNone(creds.tenant_name)
+
+ def test_pwd_creds_with_tenant_from_json(self):
+ data = json.dumps({"passwordCredentials":
+ {"tenantName": "blaa", "username": "foo",
+ "password": "bar"}})
+ creds = d5_compat.D5AuthWithPasswordCredentials.from_json(data)
+ self.assertEqual(creds.username, "foo")
+ self.assertEqual(creds.password, "bar")
+ self.assertIsNone(creds.tenant_id)
+ self.assertEqual(creds.tenant_name, "blaa")
+
+ def test_pwd_creds_with_tenant_from_json(self):
+ data = json.dumps({"passwordCredentials":
+ {"tenantId": "blaa", "username": "foo",
+ "password": "bar"}})
+ creds = d5_compat.D5AuthWithPasswordCredentials.from_json(data)
+ self.assertEqual(creds.username, "foo")
+ self.assertEqual(creds.password, "bar")
+ self.assertEqual(creds.tenant_id, "blaa")
+ self.assertIsNone(creds.tenant_name)
+
+ def test_pwd_not_both_tenant_from_json(self):
+ data = json.dumps({"tenantId": "blaa", "tenantName": "aalb"})
+ self.assertRaisesRegexp(fault.BadRequestFault,
+ "Expecting passwordCredentials",
+ d5_compat.D5AuthWithPasswordCredentials.from_json,
+ data)
+
+ def test_pwd_no_creds_from_json(self):
+ data = json.dumps({"auth": {}})
+ self.assertRaisesRegexp(fault.BadRequestFault,
+ "Expecting passwordCredentials",
+ d5_compat.D5AuthWithPasswordCredentials.from_json,
+ data)
+
+ def test_pwd_invalid_attribute_from_json(self):
+ data = json.dumps({"passwordCredentials": {"foo": "bar"}})
+ self.assertRaisesRegexp(fault.BadRequestFault,
+ "Invalid",
+ d5_compat.D5AuthWithPasswordCredentials.from_json,
+ data)
+
+ def test_pwd_no_username_from_json(self):
+ data = json.dumps({"passwordCredentials": {}})
+ self.assertRaisesRegexp(fault.BadRequestFault,
+ "Expecting passwordCredentials:username",
+ d5_compat.D5AuthWithPasswordCredentials.from_json,
+ data)
+
+ def test_pwd_no_password_from_json(self):
+ data = json.dumps({"passwordCredentials":
+ {"username": "foo"}})
+ self.assertRaisesRegexp(fault.BadRequestFault,
+ "Expecting passwordCredentials:password",
+ d5_compat.D5AuthWithPasswordCredentials.from_json,
+ data)
+
+ def test_pwd_invalid_creds_attribute_from_json(self):
+ data = json.dumps({"passwordCredentials": {"bar": "foo"}})
+ self.assertRaisesRegexp(fault.BadRequestFault,
+ "Invalid",
+ d5_compat.D5AuthWithPasswordCredentials.from_json,
+ data)
+
+ def test_json_pwd_creds_from_D5(self):
+ D5_data = json.dumps({"passwordCredentials":
+ {"username": "foo", "password": "bar"}})
+ diablo_data = json.dumps({"auth": {"passwordCredentials":
+ {"username": "foo", "password": "bar"}}})
+ creds = d5_compat.D5AuthWithPasswordCredentials.from_json(D5_data)
+ diablo = creds.to_json()
+ self.assertEquals(diablo, diablo_data)
+
+ def test_json_authdata_from_D5(self):
+ pass
+
+ def test_json_validatedata_from_D5(self):
+ diablo_data = {
+ "access": {
+ "token": {
+ "expires": "2011-12-07T21:31:49.215675",
+ "id": "92c8962a-7e9b-40d1-83eb-a2f3b6eb45c3"
+ },
+ "user": {
+ "id": "3",
+ "name": "admin",
+ "roles": [
+ {
+ "id": "1",
+ "name": "Admin"
+ }
+ ],
+ "username": "admin"
+ }
+ }
+ }
+ D5_data = {"auth": {
+ "token": {
+ "expires": "2011-12-07T21:31:49.215675",
+ "id": "92c8962a-7e9b-40d1-83eb-a2f3b6eb45c3"
+ },
+ "user": {
+ "roleRefs": [
+ {
+ "id": "1",
+ "roleId": "Admin"
+ }
+ ],
+ "username": "admin"
+ }
+ }
+ }
+ creds = d5_compat.D5ValidateData.from_json(json.dumps(diablo_data))
+ D5 = json.loads(creds.to_json())
+ self.assertEquals(diablo_data['access'], D5['access'],
+ "D5 compat response must contain Diablo format")
+ self.assertEquals(D5_data['auth'], D5['auth'],
+ "D5 compat response must contain D5 format")
+
+
+if __name__ == '__main__':
+ unittest.main()