Adds rule processing for mapping
Adds a function to compare mapping reference rules to incoming assertion headers, returns a set of identity values. bp mapping-distributed-admin Change-Id: If11539fb66dfeb778755e4f5d2ba8efb2fab522c
This commit is contained in:
parent
8557e4756e
commit
76dc98d385
|
@ -15,9 +15,16 @@
|
|||
|
||||
"""Utilities for Federation Extension."""
|
||||
|
||||
import re
|
||||
|
||||
import jsonschema
|
||||
import six
|
||||
|
||||
from keystone import exception
|
||||
from keystone.openstack.common import log
|
||||
|
||||
|
||||
LOG = log.getLogger(__name__)
|
||||
|
||||
|
||||
MAPPING_SCHEMA = {
|
||||
|
@ -107,3 +114,272 @@ def validate_mapping_structure(ref):
|
|||
|
||||
if messages:
|
||||
raise exception.ValidationError(messages)
|
||||
|
||||
|
||||
class RuleProcessor(object):
|
||||
"""A class to process assertions and mapping rules."""
|
||||
|
||||
class _EvalType(object):
|
||||
"""Mapping rule evaluation types."""
|
||||
ANY_ONE_OF = 'any_one_of'
|
||||
NOT_ANY_OF = 'not_any_of'
|
||||
|
||||
def __init__(self, rules):
|
||||
"""Initialize RuleProcessor.
|
||||
|
||||
Example rules can be found at:
|
||||
:class:`keystone.tests.mapping_fixtures`
|
||||
|
||||
:param rules: rules from a mapping
|
||||
:type rules: dict
|
||||
|
||||
"""
|
||||
|
||||
self.rules = rules
|
||||
|
||||
def process(self, assertion_data):
|
||||
"""Transform assertion to a dictionary of user name and group ids
|
||||
based on mapping rules.
|
||||
|
||||
This function will iterate through the mapping rules to find
|
||||
assertions that are valid.
|
||||
|
||||
:param assertion_data: an assertion containing values from an IdP
|
||||
:type assertion_data: dict
|
||||
|
||||
Example assertion_data::
|
||||
|
||||
{
|
||||
'Email': 'testacct@example.com',
|
||||
'UserName': 'testacct',
|
||||
'FirstName': 'Test',
|
||||
'LastName': 'Account',
|
||||
'orgPersonType': 'Tester'
|
||||
}
|
||||
|
||||
:returns: dictionary with user and group_ids
|
||||
|
||||
The expected return structure is::
|
||||
|
||||
{
|
||||
'name': 'foobar',
|
||||
'group_ids': ['abc123', 'def456']
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
# Assertions will come in as string key-value pairs, and will use a
|
||||
# semi-colon to indicate multiple values, i.e. groups.
|
||||
# This will create a new dictionary where the values are arrays, and
|
||||
# any multiple values are stored in the arrays.
|
||||
assertion = dict((n, v.split(';')) for n, v in assertion_data.items())
|
||||
identity_values = []
|
||||
|
||||
for rule in self.rules:
|
||||
direct_maps = self._verify_all_requirements(rule['remote'],
|
||||
assertion)
|
||||
|
||||
# If the compare comes back as None, then the rule did not apply
|
||||
# to the assertion data, go on to the next rule
|
||||
if direct_maps is None:
|
||||
continue
|
||||
|
||||
# If there are no direct mappings, then add the local mapping
|
||||
# directly to the array of saved values. However, if there is
|
||||
# a direct mapping, then perform variable replacement.
|
||||
if not direct_maps:
|
||||
identity_values += rule['local']
|
||||
else:
|
||||
for local in rule['local']:
|
||||
new_local = self._update_local_mapping(local, direct_maps)
|
||||
identity_values.append(new_local)
|
||||
|
||||
return self._transform(identity_values)
|
||||
|
||||
def _transform(self, identity_values):
|
||||
"""Transform local mappings, to an easier to understand format.
|
||||
|
||||
Transform the incoming array to generate the return value for
|
||||
the process function. Generating content for Keystone tokens will
|
||||
be easier if some pre-processing is done at this level.
|
||||
|
||||
:param identity_values: local mapping from valid evaluations
|
||||
:type identity_values: array of dict
|
||||
|
||||
Example identity_values::
|
||||
|
||||
[{'group': {'id': '0cd5e9'}, 'user': {'email': 'bob@example.com'}}]
|
||||
|
||||
:returns: dictionary with user name and group_ids.
|
||||
|
||||
"""
|
||||
|
||||
# initialize the group_ids as a set to eliminate duplicates
|
||||
user_name = None
|
||||
group_ids = set()
|
||||
|
||||
for identity_value in identity_values:
|
||||
if 'user' in identity_value:
|
||||
# if a mapping outputs more than one user name, log it
|
||||
if user_name is not None:
|
||||
LOG.warning(_('Ignoring user name %s'),
|
||||
identity_value['user']['name'])
|
||||
else:
|
||||
user_name = identity_value['user']['name']
|
||||
if 'group' in identity_value:
|
||||
group_ids.add(identity_value['group']['id'])
|
||||
|
||||
return {'name': user_name, 'group_ids': list(group_ids)}
|
||||
|
||||
def _update_local_mapping(self, local, direct_maps):
|
||||
"""Replace any {0}, {1} ... values with data from the assertion.
|
||||
|
||||
:param local: local mapping reference that needs to be updated
|
||||
:type local: dict
|
||||
:param direct_maps: list of identity values, used to update local
|
||||
:type direct_maps: list
|
||||
|
||||
Example local::
|
||||
|
||||
{'user': {'name': '{0} {1}', 'email': '{2}'}}
|
||||
|
||||
Example direct_maps::
|
||||
|
||||
['Bob', 'Thompson', 'bob@example.com']
|
||||
|
||||
:returns: new local mapping reference with replaced values.
|
||||
|
||||
The expected return structure is::
|
||||
|
||||
{'user': {'name': 'Bob Thompson', 'email': 'bob@example.org'}}
|
||||
|
||||
"""
|
||||
|
||||
new = {}
|
||||
for k, v in six.iteritems(local):
|
||||
if isinstance(v, dict):
|
||||
new_value = self._update_local_mapping(v, direct_maps)
|
||||
else:
|
||||
new_value = v.format(*direct_maps)
|
||||
new[k] = new_value
|
||||
return new
|
||||
|
||||
def _verify_all_requirements(self, requirements, assertion):
|
||||
"""Go through the remote requirements of a rule, and compare against
|
||||
the assertion.
|
||||
|
||||
If a value of ``None`` is returned, the rule with this assertion
|
||||
doesn't apply.
|
||||
If an array of zero length is returned, then there are no direct
|
||||
mappings to be performed, but the rule is valid.
|
||||
Otherwise, then it will return the values, in order, to be directly
|
||||
mapped, again, the rule is valid.
|
||||
|
||||
:param requirements: list of remote requirements from rules
|
||||
:type requirements: list
|
||||
|
||||
Example requirements::
|
||||
|
||||
[
|
||||
{
|
||||
"type": "UserName"
|
||||
},
|
||||
{
|
||||
"type": "orgPersonType",
|
||||
"any_one_of": [
|
||||
"Customer"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
:param assertion: dict of attributes from an IdP
|
||||
:type assertion: dict
|
||||
|
||||
Example assertion::
|
||||
|
||||
{
|
||||
'UserName': ['testacct'],
|
||||
'LastName': ['Account'],
|
||||
'orgPersonType': ['Tester'],
|
||||
'Email': ['testacct@example.com'],
|
||||
'FirstName': ['Test']
|
||||
}
|
||||
|
||||
:returns: list of direct mappings or None.
|
||||
|
||||
"""
|
||||
|
||||
direct_maps = []
|
||||
|
||||
for requirement in requirements:
|
||||
requirement_type = requirement['type']
|
||||
regex = requirement.get('regex', False)
|
||||
|
||||
any_one_values = requirement.get(self._EvalType.ANY_ONE_OF)
|
||||
if any_one_values is not None:
|
||||
if self._evaluate_requirement(any_one_values,
|
||||
requirement_type,
|
||||
self._EvalType.ANY_ONE_OF,
|
||||
regex,
|
||||
assertion):
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
|
||||
not_any_values = requirement.get(self._EvalType.NOT_ANY_OF)
|
||||
if not_any_values is not None:
|
||||
if self._evaluate_requirement(not_any_values,
|
||||
requirement_type,
|
||||
self._EvalType.NOT_ANY_OF,
|
||||
regex,
|
||||
assertion):
|
||||
continue
|
||||
else:
|
||||
return None
|
||||
|
||||
# If 'any_one_of' or 'not_any_of' are not found, then values are
|
||||
# within 'type'. Attempt to find that 'type' within the assertion.
|
||||
direct_map_values = assertion.get(requirement_type)
|
||||
if direct_map_values:
|
||||
direct_maps += direct_map_values
|
||||
|
||||
return direct_maps
|
||||
|
||||
def _evaluate_requirement(self, values, requirement_type,
|
||||
eval_type, regex, assertion):
|
||||
"""Evaluate the incoming requirement and assertion.
|
||||
|
||||
If the requirement type does not exist in the assertion data, then
|
||||
return False. If regex is specified, then compare the values and
|
||||
assertion values. Otherwise, grab the intersection of the values
|
||||
and use that to compare against the evaluation type.
|
||||
|
||||
:param values: list of allowed values, defined in the requirement
|
||||
:type values: list
|
||||
:param requirement_type: key to look for in the assertion
|
||||
:type requirement_type: string
|
||||
:param eval_type: determine how to evaluate requirements
|
||||
:type eval_type: string
|
||||
:param regex: perform evaluation with regex
|
||||
:type regex: boolean
|
||||
:param assertion: dict of attributes from the IdP
|
||||
:type assertion: dict
|
||||
|
||||
:returns: boolean, whether requirement is valid or not.
|
||||
|
||||
"""
|
||||
|
||||
assertion_values = assertion.get(requirement_type)
|
||||
if not assertion_values:
|
||||
return False
|
||||
|
||||
if regex:
|
||||
return re.search(values[0], assertion_values[0])
|
||||
|
||||
any_match = bool(set(values).intersection(set(assertion_values)))
|
||||
if any_match and eval_type == self._EvalType.ANY_ONE_OF:
|
||||
return True
|
||||
if not any_match and eval_type == self._EvalType.NOT_ANY_OF:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
@ -13,13 +13,23 @@
|
|||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Fixtures for Federation Mapping."""
|
||||
|
||||
EMPLOYEE_GROUP_ID = "0cd5e9"
|
||||
CONTRACTOR_GROUP_ID = "85a868"
|
||||
TESTER_GROUP_ID = "123"
|
||||
DEVELOPER_GROUP_ID = "xyz"
|
||||
|
||||
# Mapping summary:
|
||||
# LastName Smith & Not Contractor or SubContractor -> group 0cd5e9
|
||||
# FirstName Jill & Contractor or SubContractor -> to group 85a868
|
||||
MAPPING_SMALL = {
|
||||
"rules": [
|
||||
{
|
||||
"local": [
|
||||
{
|
||||
"group": {
|
||||
"id": "0cd5e9"
|
||||
"id": EMPLOYEE_GROUP_ID
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -30,6 +40,12 @@ MAPPING_SMALL = {
|
|||
"Contractor",
|
||||
"SubContractor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "LastName",
|
||||
"any_one_of": [
|
||||
"Bo"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -37,7 +53,7 @@ MAPPING_SMALL = {
|
|||
"local": [
|
||||
{
|
||||
"group": {
|
||||
"id": "85a868"
|
||||
"id": CONTRACTOR_GROUP_ID
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -48,20 +64,33 @@ MAPPING_SMALL = {
|
|||
"Contractor",
|
||||
"SubContractor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "FirstName",
|
||||
"any_one_of": [
|
||||
"Jill"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Mapping summary:
|
||||
# orgPersonType Admin or Big Cheese -> name {0} {1} email {2} and group 0cd5e9
|
||||
# orgPersonType Customer -> user name {0} email {1}
|
||||
# orgPersonType Test and email ^@example.com$ -> group 123 and xyz
|
||||
MAPPING_LARGE = {
|
||||
"rules": [
|
||||
{
|
||||
"local": [
|
||||
{
|
||||
"user": {
|
||||
"name": "$0 $1",
|
||||
"email": "$2"
|
||||
"name": "{0} {1}",
|
||||
"email": "{2}"
|
||||
},
|
||||
"group": {
|
||||
"id": EMPLOYEE_GROUP_ID
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -76,10 +105,10 @@ MAPPING_LARGE = {
|
|||
"type": "Email"
|
||||
},
|
||||
{
|
||||
"type": "Group",
|
||||
"type": "orgPersonType",
|
||||
"any_one_of": [
|
||||
"Admin",
|
||||
"God"
|
||||
"Big Cheese"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
@ -88,8 +117,8 @@ MAPPING_LARGE = {
|
|||
"local": [
|
||||
{
|
||||
"user": {
|
||||
"name": "$0",
|
||||
"email": "$1"
|
||||
"name": "{0}",
|
||||
"email": "{1}"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@ -101,9 +130,12 @@ MAPPING_LARGE = {
|
|||
"type": "Email"
|
||||
},
|
||||
{
|
||||
"type": "Group",
|
||||
"any_one_of": [
|
||||
"Customer"
|
||||
"type": "orgPersonType",
|
||||
"not_any_of": [
|
||||
"Admin",
|
||||
"Employee",
|
||||
"Contractor",
|
||||
"Tester"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
@ -112,26 +144,26 @@ MAPPING_LARGE = {
|
|||
"local": [
|
||||
{
|
||||
"group": {
|
||||
"id": "123"
|
||||
"id": TESTER_GROUP_ID
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": {
|
||||
"id": "xyz"
|
||||
"id": DEVELOPER_GROUP_ID
|
||||
}
|
||||
}
|
||||
],
|
||||
"remote": [
|
||||
{
|
||||
"type": "Group",
|
||||
"type": "orgPersonType",
|
||||
"any_one_of": [
|
||||
"Special"
|
||||
"Tester"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Email",
|
||||
"any_one_of": [
|
||||
"^@example.com$"
|
||||
".*@example.com$"
|
||||
],
|
||||
"regex": True
|
||||
}
|
||||
|
@ -214,7 +246,7 @@ MAPPING_WRONG_TYPE = {
|
|||
{
|
||||
"local": [
|
||||
{
|
||||
"user": "$1"
|
||||
"user": "{1}"
|
||||
}
|
||||
],
|
||||
"remote": [
|
||||
|
@ -231,7 +263,7 @@ MAPPING_MISSING_TYPE = {
|
|||
{
|
||||
"local": [
|
||||
{
|
||||
"user": "$1"
|
||||
"user": "{1}"
|
||||
}
|
||||
],
|
||||
"remote": [
|
||||
|
@ -331,3 +363,51 @@ MAPPING_EXTRA_RULES_PROPS = {
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
EMPLOYEE_ASSERTION = {
|
||||
'Email': 'tim@example.com',
|
||||
'UserName': 'tbo',
|
||||
'FirstName': 'Tim',
|
||||
'LastName': 'Bo',
|
||||
'orgPersonType': 'Employee;BuildingX;'
|
||||
}
|
||||
|
||||
CONTRACTOR_ASSERTION = {
|
||||
'Email': 'jill@example.com',
|
||||
'UserName': 'jsmith',
|
||||
'FirstName': 'Jill',
|
||||
'LastName': 'Smith',
|
||||
'orgPersonType': 'Contractor;Non-Dev;'
|
||||
}
|
||||
|
||||
ADMIN_ASSERTION = {
|
||||
'Email': 'bob@example.com',
|
||||
'UserName': 'bob',
|
||||
'FirstName': 'Bob',
|
||||
'LastName': 'Thompson',
|
||||
'orgPersonType': 'Admin;Chief;'
|
||||
}
|
||||
|
||||
CUSTOMER_ASSERTION = {
|
||||
'Email': 'beth@example.com',
|
||||
'UserName': 'bwilliams',
|
||||
'FirstName': 'Beth',
|
||||
'LastName': 'Williams',
|
||||
'orgPersonType': 'Customer;'
|
||||
}
|
||||
|
||||
TESTER_ASSERTION = {
|
||||
'Email': 'testacct@example.com',
|
||||
'UserName': 'testacct',
|
||||
'FirstName': 'Test',
|
||||
'LastName': 'Account',
|
||||
'orgPersonType': 'Tester;'
|
||||
}
|
||||
|
||||
BAD_TESTER_ASSERTION = {
|
||||
'Email': 'eviltester@example.org',
|
||||
'UserName': 'Evil',
|
||||
'FirstName': 'Test',
|
||||
'LastName': 'Account',
|
||||
'orgPersonType': 'Tester;'
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import uuid
|
|||
from keystone.common.sql import migration
|
||||
from keystone import config
|
||||
from keystone import contrib
|
||||
from keystone.contrib.federation import utils as mapping_utils
|
||||
from keystone.openstack.common import importutils
|
||||
from keystone.openstack.common import jsonutils
|
||||
from keystone.openstack.common import log
|
||||
|
@ -584,3 +585,135 @@ class MappingCRUDTests(FederationTests):
|
|||
url = self.MAPPING_URL + uuid.uuid4().hex
|
||||
self.put(url, expected_status=400,
|
||||
body={'mapping': mapping_fixtures.MAPPING_EXTRA_RULES_PROPS})
|
||||
|
||||
|
||||
class MappingRuleEngineTests(FederationTests):
|
||||
"""A class for testing the mapping rule engine."""
|
||||
|
||||
def test_rule_engine_any_one_of_and_direct_mapping(self):
|
||||
"""Should return user's name and group id EMPLOYEE_GROUP_ID.
|
||||
|
||||
The ADMIN_ASSERTION should successfully have a match in MAPPING_LARGE.
|
||||
The will test the case where `any_one_of` is valid, and there is
|
||||
a direct mapping for the users name.
|
||||
|
||||
"""
|
||||
|
||||
mapping = mapping_fixtures.MAPPING_LARGE
|
||||
assertion = mapping_fixtures.ADMIN_ASSERTION
|
||||
rp = mapping_utils.RuleProcessor(mapping['rules'])
|
||||
values = rp.process(assertion)
|
||||
|
||||
fn = mapping_fixtures.ADMIN_ASSERTION.get('FirstName')
|
||||
ln = mapping_fixtures.ADMIN_ASSERTION.get('LastName')
|
||||
full_name = '%s %s' % (fn, ln)
|
||||
|
||||
group_ids = values.get('group_ids')
|
||||
name = values.get('name')
|
||||
|
||||
self.assertIn(mapping_fixtures.EMPLOYEE_GROUP_ID, group_ids)
|
||||
self.assertEqual(name, full_name)
|
||||
|
||||
def test_rule_engine_no_regex_match(self):
|
||||
"""Should return no values, the email of the tester won't match.
|
||||
|
||||
This will not match since the email in the assertion will fail
|
||||
the regex test. It is set to match any @example.com address.
|
||||
But the incoming value is set to eviltester@example.org.
|
||||
|
||||
"""
|
||||
|
||||
mapping = mapping_fixtures.MAPPING_LARGE
|
||||
assertion = mapping_fixtures.BAD_TESTER_ASSERTION
|
||||
rp = mapping_utils.RuleProcessor(mapping['rules'])
|
||||
values = rp.process(assertion)
|
||||
|
||||
group_ids = values.get('group_ids')
|
||||
name = values.get('name')
|
||||
|
||||
self.assertIsNone(name)
|
||||
self.assertEqual(group_ids, [])
|
||||
|
||||
def test_rule_engine_any_one_of_many_rules(self):
|
||||
"""Should return group CONTRACTOR_GROUP_ID.
|
||||
|
||||
The CONTRACTOR_ASSERTION should successfully have a match in
|
||||
MAPPING_SMALL. This will test the case where many rules
|
||||
must be matched, including an `any_one_of`, and a direct
|
||||
mapping.
|
||||
|
||||
"""
|
||||
|
||||
mapping = mapping_fixtures.MAPPING_SMALL
|
||||
assertion = mapping_fixtures.CONTRACTOR_ASSERTION
|
||||
rp = mapping_utils.RuleProcessor(mapping['rules'])
|
||||
values = rp.process(assertion)
|
||||
|
||||
group_ids = values.get('group_ids')
|
||||
name = values.get('name')
|
||||
|
||||
self.assertIsNone(name)
|
||||
self.assertIn(mapping_fixtures.CONTRACTOR_GROUP_ID, group_ids)
|
||||
|
||||
def test_rule_engine_not_any_of_and_direct_mapping(self):
|
||||
"""Should return user's name and email.
|
||||
|
||||
The CUSTOMER_ASSERTION should successfully have a match in
|
||||
MAPPING_LARGE. This will test the case where a requirement
|
||||
has `not_any_of`, and direct mapping to a username, no group.
|
||||
|
||||
"""
|
||||
|
||||
mapping = mapping_fixtures.MAPPING_LARGE
|
||||
assertion = mapping_fixtures.CUSTOMER_ASSERTION
|
||||
rp = mapping_utils.RuleProcessor(mapping['rules'])
|
||||
values = rp.process(assertion)
|
||||
|
||||
user_name = mapping_fixtures.CUSTOMER_ASSERTION.get('UserName')
|
||||
group_ids = values.get('group_ids')
|
||||
name = values.get('name')
|
||||
|
||||
self.assertEqual(name, user_name)
|
||||
self.assertEqual(group_ids, [])
|
||||
|
||||
def test_rule_engine_not_any_of_many_rules(self):
|
||||
"""Should return group EMPLOYEE_GROUP_ID.
|
||||
|
||||
The EMPLOYEE_ASSERTION should successfully have a match in
|
||||
MAPPING_SMALL. This will test the case where many remote
|
||||
rules must be matched, including a `not_any_of`.
|
||||
|
||||
"""
|
||||
|
||||
mapping = mapping_fixtures.MAPPING_SMALL
|
||||
assertion = mapping_fixtures.EMPLOYEE_ASSERTION
|
||||
rp = mapping_utils.RuleProcessor(mapping['rules'])
|
||||
values = rp.process(assertion)
|
||||
|
||||
group_ids = values.get('group_ids')
|
||||
name = values.get('name')
|
||||
|
||||
self.assertIsNone(name)
|
||||
self.assertIn(mapping_fixtures.EMPLOYEE_GROUP_ID, group_ids)
|
||||
|
||||
def test_rule_engine_regex_match_and_many_groups(self):
|
||||
"""Should return group DEVELOPER_GROUP_ID and TESTER_GROUP_ID.
|
||||
|
||||
The TESTER_ASSERTION should successfully have a match in
|
||||
MAPPING_LARGE. This will test a successful regex match
|
||||
for an `any_one_of` evaluation type, and will have many
|
||||
groups returned.
|
||||
|
||||
"""
|
||||
|
||||
mapping = mapping_fixtures.MAPPING_LARGE
|
||||
assertion = mapping_fixtures.TESTER_ASSERTION
|
||||
rp = mapping_utils.RuleProcessor(mapping['rules'])
|
||||
values = rp.process(assertion)
|
||||
|
||||
group_ids = values.get('group_ids')
|
||||
name = values.get('name')
|
||||
|
||||
self.assertIsNone(name)
|
||||
self.assertIn(mapping_fixtures.DEVELOPER_GROUP_ID, group_ids)
|
||||
self.assertIn(mapping_fixtures.TESTER_GROUP_ID, group_ids)
|
||||
|
|
Loading…
Reference in New Issue