Add mapping function to keystone

Add a mapping implementation for the federation extension.
This will allow a set of rules to be added, that map
relationships between identity providers and keystone

api spec: https://review.openstack.org/#/c/59848/

Implements: bp mapping-distributed-admin
Change-Id: Iab93dc6759f195fecd88dcdf1c635c81ad59165a
This commit is contained in:
Steve Martinelli 2013-12-03 23:48:00 -06:00
parent 20840199e6
commit e6db2cf978
14 changed files with 714 additions and 11 deletions

View File

@ -118,5 +118,11 @@
"identity:update_protocol": "rule:admin_required",
"identity:get_protocol": "rule:admin_required",
"identity:list_protocols": "rule:admin_required",
"identity:delete_protocol": "rule:admin_required"
"identity:delete_protocol": "rule:admin_required",
"identity:create_mapping": "rule:admin_required",
"identity:get_mapping": "rule:admin_required",
"identity:list_mappings": "rule:admin_required",
"identity:delete_mapping": "rule:admin_required",
"identity:update_mapping": "rule:admin_required"
}

View File

@ -124,5 +124,11 @@
"identity:update_protocol": "rule:admin_required",
"identity:get_protocol": "rule:admin_required",
"identity:list_protocols": "rule:admin_required",
"identity:delete_protocol": "rule:admin_required"
"identity:delete_protocol": "rule:admin_required",
"identity:create_mapping": "rule:admin_required",
"identity:get_mapping": "rule:admin_required",
"identity:list_mappings": "rule:admin_required",
"identity:delete_mapping": "rule:admin_required",
"identity:update_mapping": "rule:admin_required"
}

View File

@ -18,6 +18,7 @@ from keystone.common import sql
from keystone.common.sql import migration
from keystone.contrib.federation import core
from keystone import exception
from keystone.openstack.common import jsonutils
class FederationProtocolModel(sql.ModelBase, sql.DictBase):
@ -66,13 +67,32 @@ class IdentityProviderModel(sql.ModelBase, sql.DictBase):
return d
class MappingModel(sql.ModelBase, sql.DictBase):
__tablename__ = 'mapping'
attributes = ['id', 'rules']
id = sql.Column(sql.String(64), primary_key=True)
rules = sql.Column(sql.JsonBlob(), nullable=False)
@classmethod
def from_dict(cls, dictionary):
new_dictionary = dictionary.copy()
return cls(**new_dictionary)
def to_dict(self):
"""Return a dictionary with model's attributes."""
d = dict()
for attr in self.__class__.attributes:
d[attr] = getattr(self, attr)
return d
class Federation(sql.Base, core.Driver):
def db_sync(self):
migration.db_sync()
# Identity Provider CRUD
@sql.handle_conflicts(conflict_type='identity_provider')
def create_idp(self, idp_id, idp):
session = self.get_session()
@ -121,7 +141,6 @@ class Federation(sql.Base, core.Driver):
return idp_ref.to_dict()
# Protocol CRUD
def _get_protocol(self, session, idp_id, protocol_id):
q = session.query(FederationProtocolModel)
q = q.filter_by(id=protocol_id, idp_id=idp_id)
@ -180,3 +199,54 @@ class Federation(sql.Base, core.Driver):
q = q.filter_by(id=protocol_id, idp_id=idp_id)
q.delete(synchronize_session=False)
session.delete(key_ref)
# Mapping CRUD
def _get_mapping(self, session, mapping_id):
mapping_ref = session.query(MappingModel).get(mapping_id)
if not mapping_ref:
raise exception.MappingNotFound(mapping_id=mapping_id)
return mapping_ref
@sql.handle_conflicts(conflict_type='mapping')
def create_mapping(self, mapping_id, mapping):
session = self.get_session()
ref = {}
ref['id'] = mapping_id
ref['rules'] = jsonutils.dumps(mapping.get('rules'))
with session.begin():
mapping_ref = MappingModel.from_dict(ref)
session.add(mapping_ref)
return mapping_ref.to_dict()
def delete_mapping(self, mapping_id):
session = self.get_session()
with session.begin():
mapping_ref = self._get_mapping(session, mapping_id)
session.delete(mapping_ref)
def list_mappings(self):
session = self.get_session()
with session.begin():
mappings = session.query(MappingModel)
return [x.to_dict() for x in mappings]
def get_mapping(self, mapping_id):
session = self.get_session()
with session.begin():
mapping_ref = self._get_mapping(session, mapping_id)
return mapping_ref.to_dict()
@sql.handle_conflicts(conflict_type='mapping')
def update_mapping(self, mapping_id, mapping):
ref = {}
ref['id'] = mapping_id
ref['rules'] = jsonutils.dumps(mapping.get('rules'))
session = self.get_session()
with session.begin():
mapping_ref = self._get_mapping(session, mapping_id)
old_mapping = mapping_ref.to_dict()
old_mapping.update(ref)
new_mapping = MappingModel.from_dict(old_mapping)
for attr in MappingModel.attributes:
setattr(mapping_ref, attr, getattr(new_mapping, attr))
return mapping_ref.to_dict()

View File

@ -1,6 +1,5 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 OpenStack Foundation
#
# 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
@ -20,8 +19,10 @@ from keystone.common import controller
from keystone.common import dependency
from keystone.common import wsgi
from keystone import config
from keystone.contrib.federation import utils
from keystone import exception
CONF = config.CONF
@ -103,7 +104,7 @@ class IdentityProvider(controller.V3Controller):
@classmethod
def _add_related_links(cls, ref):
"""Add URLs for entitities related with Identity Provider.
"""Add URLs for entities related with Identity Provider.
Add URLs pointing to:
- protocols tied to the Identity Provider
@ -266,3 +267,43 @@ class FederationProtocol(IdentityProvider):
@controller.protected()
def delete_protocol(self, context, idp_id, protocol_id):
self.federation_api.delete_protocol(idp_id, protocol_id)
@dependency.requires('federation_api')
class MappingController(controller.V3Controller):
collection_name = 'mappings'
member_name = 'mapping'
@classmethod
def base_url(cls, path=None):
path = '/OS-FEDERATION/' + cls.collection_name
return controller.V3Controller.base_url(path)
@controller.protected()
def create_mapping(self, context, mapping_id, mapping):
ref = self._normalize_dict(mapping)
utils.validate_mapping_structure(ref)
mapping_ref = self.federation_api.create_mapping(mapping_id, ref)
response = MappingController.wrap_member(context, mapping_ref)
return wsgi.render_response(body=response, status=('201', 'Created'))
@controller.protected()
def list_mappings(self, context):
ref = self.federation_api.list_mappings()
return MappingController.wrap_collection(context, ref)
@controller.protected()
def get_mapping(self, context, mapping_id):
ref = self.federation_api.get_mapping(mapping_id)
return MappingController.wrap_member(context, ref)
@controller.protected()
def delete_mapping(self, context, mapping_id):
self.federation_api.delete_mapping(mapping_id)
@controller.protected()
def update_mapping(self, context, mapping_id, mapping):
mapping = self._normalize_dict(mapping)
utils.validate_mapping_structure(mapping)
mapping_ref = self.federation_api.update_mapping(mapping_id, mapping)
return MappingController.wrap_member(context, mapping_ref)

View File

@ -1,6 +1,5 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 OpenStack Foundation
#
# 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
@ -145,3 +144,59 @@ class Driver(object):
"""
raise exception.NotImplemented()
@abc.abstractmethod
def create_mapping(self, mapping_ref):
"""Create a mapping.
:param mapping_ref: mapping ref with mapping name
:type mapping_ref: dict
:returns: mapping_ref
"""
raise exception.NotImplemented()
@abc.abstractmethod
def delete_mapping(self, mapping_id):
"""Delete a mapping.
:param mapping_id: id of mapping to delete
:type mapping_ref: string
:returns: None
"""
raise exception.NotImplemented()
@abc.abstractmethod
def update_mapping(self, mapping_id, mapping_ref):
"""Update a mapping.
:param mapping_id: id of mapping to update
:type mapping_id: string
:param mapping_ref: new mapping ref
:type mapping_ref: dict
:returns: mapping_ref
"""
raise exception.NotImplemented()
@abc.abstractmethod
def list_mappings(self):
"""List all mappings.
returns: list of mappings
"""
raise exception.NotImplemented()
@abc.abstractmethod
def get_mapping(self, mapping_id):
"""Get a mapping, returns the mapping based
on mapping_id.
:param mapping_id: id of mapping to get
:type mapping_ref: string
:returns: mapping_ref
"""
raise exception.NotImplemented()

View File

@ -1,6 +1,5 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 OpenStack Foundation
#
# 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

View File

@ -0,0 +1,38 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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
mapping_table = sql.Table(
'mapping',
meta,
sql.Column('id', sql.String(64), primary_key=True),
sql.Column('rules', sql.Text(), nullable=False))
mapping_table.create(migrate_engine, checkfirst=True)
def downgrade(migrate_engine):
meta = sql.MetaData()
meta.bind = migrate_engine
# Drop previously created tables
tables = ['mapping']
for table_name in tables:
table = sql.Table(table_name, meta, autoload=True)
table.drop()

View File

@ -1,6 +1,5 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 OpenStack Foundation
#
# 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
@ -40,6 +39,12 @@ class FederationExtension(wsgi.ExtensionRouter):
DELETE /OS-FEDERATION/identity_providers/
$identity_provider/protocols/$protocol
PUT /OS-FEDERATION/mappings
GET /OS-FEDERATION/mappings
PATCH /OS-FEDERATION/mappings/$mapping_id
GET /OS-FEDERATION/mappings/$mapping_id
DELETE /OS-FEDERATION/mappings/$mapping_id
"""
def _construct_url(self, suffix):
@ -48,6 +53,7 @@ class FederationExtension(wsgi.ExtensionRouter):
def add_routes(self, mapper):
idp_controller = controllers.IdentityProvider()
protocol_controller = controllers.FederationProtocol()
mapping_controller = controllers.MappingController()
# Identity Provider CRUD operations
@ -117,3 +123,35 @@ class FederationExtension(wsgi.ExtensionRouter):
controller=protocol_controller,
action='delete_protocol',
conditions=dict(method=['DELETE']))
# Mapping CRUD operations
mapper.connect(
self._construct_url('mappings/{mapping_id}'),
controller=mapping_controller,
action='create_mapping',
conditions=dict(method=['PUT']))
mapper.connect(
self._construct_url('mappings'),
controller=mapping_controller,
action='list_mappings',
conditions=dict(method=['GET']))
mapper.connect(
self._construct_url('mappings/{mapping_id}'),
controller=mapping_controller,
action='get_mapping',
conditions=dict(method=['GET']))
mapper.connect(
self._construct_url('mappings/{mapping_id}'),
controller=mapping_controller,
action='delete_mapping',
conditions=dict(method=['DELETE']))
mapper.connect(
self._construct_url('mappings/{mapping_id}'),
controller=mapping_controller,
action='update_mapping',
conditions=dict(method=['PATCH']))

View File

@ -0,0 +1,106 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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.
"""Utilities for Federation Extension."""
import jsonschema
from keystone import exception
MAPPING_SCHEMA = {
"type": "object",
"properties": {
"rules": {
"minItems": 1,
"type": "array",
"items": {
"type": "object",
"properties": {
"local": {
"type": "array"
},
"remote": {
"minItems": 1,
"type": "array",
"items": {
"type": "object",
"oneOf": [
{"$ref": "#/definitions/empty"},
{"$ref": "#/definitions/any_one_of"},
{"$ref": "#/definitions/not_any_of"}
],
}
}
}
}
}
},
"definitions": {
"empty": {
"type": "object",
"properties": {
"required": ['type'],
"type": {
"type": "string"
},
},
"additionalProperties": False,
},
"any_one_of": {
"type": "object",
"additionalProperties": False,
"required": ['type', 'any_one_of'],
"properties": {
"type": {
"type": "string"
},
"any_one_of": {
"type": "array"
},
"regex": {
"type": "boolean"
}
}
},
"not_any_of": {
"type": "object",
"additionalProperties": False,
"required": ['type', 'not_any_of'],
"properties": {
"type": {
"type": "string"
},
"not_any_of": {
"type": "array"
},
"regex": {
"type": "boolean"
}
}
}
}
}
def validate_mapping_structure(ref):
v = jsonschema.Draft4Validator(MAPPING_SCHEMA)
messages = ''
for error in sorted(v.iter_errors(ref), key=str):
messages = messages + error.message + "\n"
if messages:
raise exception.ValidationError(messages)

View File

@ -222,6 +222,10 @@ class GroupNotFound(NotFound):
message_format = _("Could not find group, %(group_id)s.")
class MappingNotFound(NotFound):
message_format = _("Could not find mapping, %(mapping_id)s.")
class TrustNotFound(NotFound):
message_format = _("Could not find trust, %(trust_id)s.")

View File

@ -0,0 +1,211 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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.
MAPPING_SMALL = {
'name': 'large mapping',
"rules": [
{
"local": [
{
"group": {
"id": "0cd5e9"
}
}
],
"remote": [
{
"type": "orgPersonType",
"not_any_of": [
"Contractor",
"SubContractor"
]
}
]
},
{
"local": [
{
"group": {
"id": "85a868"
}
}
],
"remote": [
{
"type": "orgPersonType",
"any_one_of": [
"Contractor",
"SubContractor"
]
}
]
}
]
}
MAPPING_LARGE = {
"rules": [
{
"local": [
{
"user": {
"name": "$0 $1",
"email": "$2"
}
}
],
"remote": [
{
"type": "FirstName"
},
{
"type": "LastName"
},
{
"type": "Email"
},
{
"type": "Group",
"any_one_of": [
"Admin",
"God"
]
}
]
},
{
"local": [
{
"user": {
"name": "$0",
"email": "$1"
}
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "Email"
},
{
"type": "Group",
"any_one_of": [
"Customer"
]
}
]
},
{
"local": [
{
"group": {
"id": "123"
}
},
{
"group": {
"id": "xyz"
}
}
],
"remote": [
{
"type": "Group",
"any_one_of": [
"Special"
]
},
{
"type": "Email",
"any_one_of": [
"^@example.com$"
],
"regex": True
}
]
}
]
}
MAPPING_BAD_REQ = {
"rules": [
{
"local": [
{
"user": "name"
}
],
"remote": [
{
"type": "UserName",
"bad_requirement": [
"Young"
]
}
]
}
]
}
MAPPING_BAD_VALUE = {
"rules": [
{
"local": [
{
"user": "name"
}
],
"remote": [
{
"type": "UserName",
"any_one_of": "should_be_list"
}
]
}
]
}
MAPPING_NO_RULES = {
'rules': []
}
MAPPING_NO_REMOTE = {
"rules": [
{
"local": [
{
"user": "name"
}
],
"remote": []
}
]
}
MAPPING_MISSING_LOCAL = {
"rules": [
{
"remote": [
{
"type": "UserName",
"any_one_of": "should_be_list"
}
]
}
]
}

View File

@ -144,12 +144,16 @@ class FederationExtension(test_sql_upgrade.SqlMigrateBase):
super(FederationExtension, self).__init__(*args, **kwargs)
self.identity_provider = 'identity_provider'
self.federation_protocol = 'federation_protocol'
self.mapping = 'mapping'
def repo_package(self):
return federation
def test_upgrade(self):
self.assertTableDoesNotExist(self.identity_provider)
self.assertTableDoesNotExist(self.federation_protocol)
self.assertTableDoesNotExist(self.mapping)
self.upgrade(1, repository=self.repo_path)
self.assertTableColumns(self.identity_provider,
['id',
@ -161,13 +165,20 @@ class FederationExtension(test_sql_upgrade.SqlMigrateBase):
'idp_id',
'mapping_id'])
self.upgrade(2, repository=self.repo_path)
self.assertTableColumns(self.mapping,
['id', 'rules'])
def test_downgrade(self):
self.upgrade(1, repository=self.repo_path)
self.upgrade(2, repository=self.repo_path)
self.assertTableColumns(self.identity_provider,
['id', 'enabled', 'description'])
self.assertTableColumns(self.federation_protocol,
['id', 'idp_id', 'mapping_id'])
self.assertTableColumns(self.mapping,
['id', 'rules'])
self.downgrade(0, repository=self.repo_path)
self.assertTableDoesNotExist(self.identity_provider)
self.assertTableDoesNotExist(self.federation_protocol)
self.assertTableDoesNotExist(self.mapping)

View File

@ -1,6 +1,5 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2013 OpenStack Foundation
#
# 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
@ -22,7 +21,9 @@ from keystone.common.sql import migration
from keystone import config
from keystone import contrib
from keystone.openstack.common import importutils
from keystone.openstack.common import jsonutils
from keystone.openstack.common import log
from keystone.tests import mapping_fixtures
from keystone.tests import test_v3
@ -434,3 +435,119 @@ class FederatedIdentityProviderTests(FederationTests):
'protocol_id': proto}
self.delete(url)
self.get(url, expected_status=404)
class MappingTests(FederationTests):
MAPPING_URL = '/OS-FEDERATION/mappings/'
def assertValidMappingListResponse(self, resp, *args, **kwargs):
return self.assertValidListResponse(
resp,
'mappings',
self.assertValidMapping,
keys_to_check=[],
*args,
**kwargs)
def assertValidMappingResponse(self, resp, *args, **kwargs):
return self.assertValidResponse(
resp,
'mapping',
self.assertValidMapping,
keys_to_check=[],
*args,
**kwargs)
def assertValidMapping(self, entity, ref=None):
self.assertIsNotNone(entity.get('id'))
self.assertIsNotNone(entity.get('rules'))
if ref:
self.assertEqual(jsonutils.loads(entity['rules']), ref['rules'])
return entity
def _create_default_mapping_entry(self):
url = self.MAPPING_URL + uuid.uuid4().hex
resp = self.put(url,
body={'mapping': mapping_fixtures.MAPPING_LARGE},
expected_status=201)
return resp
def _get_id_from_response(self, resp):
r = resp.result.get('mapping')
return r.get('id')
def test_mapping_create(self):
resp = self._create_default_mapping_entry()
self.assertValidMappingResponse(resp, mapping_fixtures.MAPPING_LARGE)
def test_mapping_list(self):
url = self.MAPPING_URL
self._create_default_mapping_entry()
resp = self.get(url)
entities = resp.result.get('mappings')
self.assertIsNotNone(entities)
self.assertResponseStatus(resp, 200)
self.assertValidListLinks(resp.result.get('links'))
self.assertEqual(len(entities), 1)
def test_mapping_delete(self):
url = self.MAPPING_URL + '%(mapping_id)s'
resp = self._create_default_mapping_entry()
mapping_id = self._get_id_from_response(resp)
url = url % {'mapping_id': str(mapping_id)}
resp = self.delete(url)
self.assertResponseStatus(resp, 204)
self.get(url, expected_status=404)
def test_mapping_get(self):
url = self.MAPPING_URL + '%(mapping_id)s'
resp = self._create_default_mapping_entry()
mapping_id = self._get_id_from_response(resp)
url = url % {'mapping_id': mapping_id}
resp = self.get(url)
self.assertValidMappingResponse(resp, mapping_fixtures.MAPPING_LARGE)
def test_mapping_update(self):
url = self.MAPPING_URL + '%(mapping_id)s'
resp = self._create_default_mapping_entry()
mapping_id = self._get_id_from_response(resp)
url = url % {'mapping_id': mapping_id}
resp = self.patch(url,
body={'mapping': mapping_fixtures.MAPPING_SMALL})
self.assertValidMappingResponse(resp, mapping_fixtures.MAPPING_SMALL)
resp = self.get(url)
self.assertValidMappingResponse(resp, mapping_fixtures.MAPPING_SMALL)
def test_delete_mapping_dne(self):
url = self.MAPPING_URL + uuid.uuid4().hex
self.delete(url, expected_status=404)
def test_get_mapping_dne(self):
url = self.MAPPING_URL + uuid.uuid4().hex
self.get(url, expected_status=404)
def test_create_mapping_bad_requirements(self):
url = self.MAPPING_URL + uuid.uuid4().hex
self.put(url, expected_status=400,
body={'mapping': mapping_fixtures.MAPPING_BAD_REQ})
def test_create_mapping_no_rules(self):
url = self.MAPPING_URL + uuid.uuid4().hex
self.put(url, expected_status=400,
body={'mapping': mapping_fixtures.MAPPING_NO_RULES})
def test_create_mapping_no_remote_objects(self):
url = self.MAPPING_URL + uuid.uuid4().hex
self.put(url, expected_status=400,
body={'mapping': mapping_fixtures.MAPPING_NO_REMOTE})
def test_create_mapping_bad_value(self):
url = self.MAPPING_URL + uuid.uuid4().hex
self.put(url, expected_status=400,
body={'mapping': mapping_fixtures.MAPPING_BAD_VALUE})
def test_create_mapping_missing_local(self):
url = self.MAPPING_URL + uuid.uuid4().hex
self.put(url, expected_status=400,
body={'mapping': mapping_fixtures.MAPPING_MISSING_LOCAL})

View File

@ -19,6 +19,7 @@ oslo.config>=1.2.0
Babel>=1.3
oauthlib
dogpile.cache>=0.5.0
jsonschema>=1.3.0,!=1.4.0
# KDS exclusive dependencies