Support authentication via SAML 2.0 assertions

This patch will support authentication via SAML 2.0 assertions.
A new authentication plugin will allow external users to authenticate
with keystone, provided the incoming assertion is valid.

The file keystone/contrib/federation/controllers.py was extended with two
new controllers.V3Controller classes:

*) DomainV3 which handles /v3/OS-FEDERATION/domains API call and returns
   list of domains a user can access based on the provided list of groups.

*) ProjectV3 which handles /v3/OS-FEDERATION/projects API call and returns
   list of project a user can access based on the provided list of groups.

Change-Id: I89f70e3a24e825e21580772c088c6fd5c44f3b63
Implements: blueprint saml-id
This commit is contained in:
Marek Denis 2014-02-05 00:09:30 +00:00
parent b5a26b35ac
commit 986c3eb08a
19 changed files with 1026 additions and 19 deletions

View File

@ -124,5 +124,9 @@
"identity:get_mapping": "rule:admin_required",
"identity:list_mappings": "rule:admin_required",
"identity:delete_mapping": "rule:admin_required",
"identity:update_mapping": "rule:admin_required"
"identity:update_mapping": "rule:admin_required",
"identity:list_projects_for_groups": "",
"identity:list_domains_for_groups": ""
}

View File

@ -136,5 +136,9 @@
"identity:get_mapping": "rule:admin_required",
"identity:list_mappings": "rule:admin_required",
"identity:delete_mapping": "rule:admin_required",
"identity:update_mapping": "rule:admin_required"
"identity:update_mapping": "rule:admin_required",
"identity:list_projects_for_groups": "",
"identity:list_domains_for_groups": ""
}

View File

@ -160,6 +160,15 @@ class Assignment(kvs.Base, assignment.Driver):
return project_refs
def get_roles_for_groups(self, group_ids, project_id=None, domain_id=None):
raise exception.NotImplemented()
def list_projects_for_groups(self, group_ids):
raise exception.NotImplemented()
def list_domains_for_groups(self, group_ids):
raise exception.NotImplemented()
def add_role_to_user_and_project(self, user_id, tenant_id, role_id):
self.get_project(tenant_id)
self.get_role(role_id)

View File

@ -143,6 +143,15 @@ class Assignment(assignment.Driver):
return [self._set_default_domain(x) for x in
self.project.get_user_projects(user_dn, associations)]
def get_roles_for_groups(self, group_ids, project_id=None, domain_id=None):
raise exception.NotImplemented()
def list_projects_for_groups(self, group_ids):
raise exception.NotImplemented()
def list_domains_for_groups(self, group_ids):
raise exception.NotImplemented()
def list_user_ids_for_project(self, tenant_id):
self.get_project(tenant_id)
tenant_dn = self.project._id_to_dn(tenant_id)

View File

@ -279,6 +279,52 @@ class Assignment(assignment.Driver):
return _project_ids_to_dicts(session, project_ids)
def get_roles_for_groups(self, group_ids, project_id=None, domain_id=None):
if project_id is not None:
assignment_type = AssignmentType.GROUP_PROJECT
target_id = project_id
elif domain_id is not None:
assignment_type = AssignmentType.GROUP_DOMAIN
target_id = domain_id
else:
raise AttributeError(_("Must specify either domain or project"))
sql_constraints = sql.and_(
RoleAssignment.type == assignment_type,
RoleAssignment.target_id == target_id,
Role.id == RoleAssignment.role_id,
RoleAssignment.actor_id.in_(group_ids))
session = db_session.get_session()
with session.begin():
query = session.query(Role).filter(
sql_constraints).distinct()
return [role.to_dict() for role in query.all()]
def _list_entities_for_groups(self, group_ids, entity):
if entity == Domain:
assignment_type = AssignmentType.GROUP_DOMAIN
else:
assignment_type = AssignmentType.GROUP_PROJECT
group_sql_conditions = sql.and_(
RoleAssignment.type == assignment_type,
entity.id == RoleAssignment.target_id,
RoleAssignment.actor_id.in_(group_ids))
session = db_session.get_session()
with session.begin():
query = session.query(entity).filter(
group_sql_conditions)
return [x.to_dict() for x in query.all()]
def list_projects_for_groups(self, group_ids):
return self._list_entities_for_groups(group_ids, Project)
def list_domains_for_groups(self, group_ids):
return self._list_entities_for_groups(group_ids, Domain)
def add_role_to_user_and_project(self, user_id, tenant_id, role_id):
with sql.transaction() as session:
self._get_project(session, tenant_id)

View File

@ -828,6 +828,47 @@ class Driver(object):
"""
raise exception.NotImplemented()
@abc.abstractmethod
def get_roles_for_groups(self, group_ids, project_id=None, domain_id=None):
"""List all the roles assigned to groups on either domain or
project.
If the project_id is not None, this value will be used, no matter what
was specified in the domain_id.
:param group_ids: iterable with group ids
:param project_id: id of the project
:param domain_id: id of the domain
:raises: AttributeError: In case both project_id and domain_id are set
to None
:returns: a list of Role entities matching groups and
project_id or domain_id
"""
raise exception.NotImplemented()
@abc.abstractmethod
def list_projects_for_groups(self, group_ids):
"""List projects accessible to specified groups.
:param group_ids: List of group ids.
:returns: List of projects accessible to specified groups.
"""
raise exception.NotImplemented()
@abc.abstractmethod
def list_domains_for_groups(self, group_ids):
"""List domains accessible to specified groups.
:param group_ids: List of group ids.
:returns: List of domains accessible to specified groups.
"""
raise exception.NotImplemented()
@abc.abstractmethod
def get_project(self, project_id):
"""Get a project by ID.

View File

@ -21,6 +21,7 @@ from keystone.common import controller
from keystone.common import dependency
from keystone.common import wsgi
from keystone import config
from keystone.contrib import federation
from keystone import exception
from keystone.openstack.common import importutils
from keystone.openstack.common import log
@ -343,6 +344,10 @@ class Auth(controller.V3Controller):
# scope is specified
return
# Skip scoping when unscoped federated token is being issued
if federation.IDENTITY_PROVIDER in auth_context:
return
# fill in default_project_id if it is available
try:
user_ref = self.identity_api.get_user(auth_context['user_id'])

View File

@ -0,0 +1,88 @@
# 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 six.moves.urllib import parse
from keystone import auth
from keystone.common import dependency
from keystone import config
from keystone.contrib import federation
from keystone.contrib.federation import utils
from keystone import exception
from keystone.openstack.common import jsonutils
from keystone.openstack.common import log
from keystone.openstack.common import timeutils
CONF = config.CONF
LOG = log.getLogger(__name__)
@dependency.requires('token_api', 'federation_api')
class Saml2(auth.AuthMethodHandler):
method = 'saml2'
def authenticate(self, context, auth_payload, auth_context):
"""Authenticate federated user and return an authentication context.
:param context: keystone's request context
:param auth_payload: the content of the authentication for a
given method
:param auth_context: user authentication context, a dictionary
shared by all plugins.
In addition to ``user_id`` in ``auth_context``, the ``saml2`` plugin
also sets ``group_ids``, ``identity_provider`` and ``protocol``.
These values are required for issuing an unscoped federated token.
When scoping the federated tokens, the plugin sets
``federated_token``, this entry stores the unscoped token.
"""
if 'id' in auth_payload:
fields = self._handle_scoped_token(auth_payload)
else:
fields = self._handle_unscoped_token(context, auth_payload)
auth_context.update(fields)
def _handle_scoped_token(self, auth_payload):
token_ref = self.token_api.get_token(auth_payload['id'])
self._validate_expiration(token_ref)
groups = token_ref['user'][federation.GROUPS]
return {
'user_id': token_ref['user_id'],
'group_ids': [group['id'] for group in groups]
}
def _handle_unscoped_token(self, context, auth_payload):
assertion = context['environment']
identity_provider = auth_payload['identity_provider']
protocol = auth_payload['protocol']
mapping = self.federation_api.get_mapping_from_idp_and_protocol(
identity_provider, protocol)
rules = jsonutils.loads(mapping['rules'])
rule_processor = utils.RuleProcessor(rules)
mapped_properties = rule_processor.process(assertion)
return {
'user_id': parse.quote(mapped_properties['name']),
'group_ids': mapped_properties['group_ids'],
federation.IDENTITY_PROVIDER: identity_provider,
federation.PROTOCOL: protocol
}
def _validate_expiration(self, token_ref):
if timeutils.utcnow() > token_ref['expires']:
raise exception.Unauthorized(_('Federation token is expired'))

View File

@ -34,6 +34,7 @@ It is a dictionary with the following attributes:
* ``domain_id`` (optional): domain ID of the scoped domain if auth is
domain-scoped
* ``roles`` (optional): list of role names for the given scope
* ``group_ids``: list of group IDs for which the API user has membership
"""
@ -81,6 +82,8 @@ def v3_token_to_auth_context(token):
creds['roles'] = []
for role in token_data['roles']:
creds['roles'].append(role['name'])
creds['group_ids'] = [
g['id'] for g in token_data['user'].get('OS-FEDERATION:groups', [])]
return creds

View File

@ -58,6 +58,7 @@ relationship = sql.orm.relationship
joinedload = sql.orm.joinedload
# Suppress flake8's unused import warning for flag_modified:
flag_modified = flag_modified
and_ = sql.and_
def initialize():

View File

@ -246,3 +246,11 @@ class Federation(core.Driver):
for attr in MappingModel.attributes:
setattr(mapping_ref, attr, getattr(new_mapping, attr))
return mapping_ref.to_dict()
def get_mapping_from_idp_and_protocol(self, idp_id, protocol_id):
session = db_session.get_session()
with session.begin():
protocol_ref = self._get_protocol(session, idp_id, protocol_id)
mapping_id = protocol_ref.mapping_id
mapping_ref = self._get_mapping(session, mapping_id)
return mapping_ref.to_dict()

View File

@ -12,6 +12,7 @@
"""Extensions supporting Federation."""
from keystone.common import authorization
from keystone.common import controller
from keystone.common import dependency
from keystone.common import wsgi
@ -237,3 +238,49 @@ class MappingController(_ControllerBase):
utils.validate_mapping_structure(mapping)
mapping_ref = self.federation_api.update_mapping(mapping_id, mapping)
return MappingController.wrap_member(context, mapping_ref)
@dependency.requires('assignment_api')
class DomainV3(controller.V3Controller):
collection_name = 'domains'
member_name = 'domain'
def __init__(self):
super(DomainV3, self).__init__()
self.get_member_from_driver = self.assignment_api.get_domain
@controller.protected()
def list_domains_for_groups(self, context):
"""List all domains available to an authenticated user's groups.
:param context: request context
:returns: list of accessible domains
"""
auth_context = context['environment'][authorization.AUTH_CONTEXT_ENV]
domains = self.assignment_api.list_domains_for_groups(
auth_context['group_ids'])
return DomainV3.wrap_collection(context, domains)
@dependency.requires('assignment_api')
class ProjectV3(controller.V3Controller):
collection_name = 'projects'
member_name = 'project'
def __init__(self):
super(ProjectV3, self).__init__()
self.get_member_from_driver = self.assignment_api.get_project
@controller.protected()
def list_projects_for_groups(self, context):
"""List all projects available to an authenticated user's groups.
:param context: request context
:returns: list of accessible projects
"""
auth_context = context['environment'][authorization.AUTH_CONTEXT_ENV]
projects = self.assignment_api.list_projects_for_groups(
auth_context['group_ids'])
return ProjectV3.wrap_collection(context, projects)

View File

@ -41,6 +41,11 @@ EXTENSION_DATA = {
extension.register_admin_extension(EXTENSION_DATA['alias'], EXTENSION_DATA)
extension.register_public_extension(EXTENSION_DATA['alias'], EXTENSION_DATA)
FEDERATION = 'OS-FEDERATION'
GROUPS = 'OS-FEDERATION:groups'
IDENTITY_PROVIDER = 'OS-FEDERATION:identity_provider'
PROTOCOL = 'OS-FEDERATION:protocol'
@dependency.provider('federation_api')
class Manager(manager.Manager):
@ -195,3 +200,18 @@ class Driver(object):
"""
raise exception.NotImplemented()
@abc.abstractmethod
def get_mapping_from_idp_and_protocol(self, idp_id, protocol_id):
"""Get mapping based on idp_id and protocol_id.
:param idp_id: id of the identity provider
:type idp_id: string
:param protocol_id: id of the protocol
:type protocol_id: string
:raises: keystone.exception.IdentityProviderNotFound,
keystone.exception.FederatedProtocolNotFound,
:returns: mapping_ref
"""
raise exception.NotImplemented()

View File

@ -55,6 +55,8 @@ class FederationExtension(wsgi.ExtensionRouter):
idp_controller = controllers.IdentityProvider()
protocol_controller = controllers.FederationProtocol()
mapping_controller = controllers.MappingController()
project_controller = controllers.ProjectV3()
domain_controller = controllers.DomainV3()
# Identity Provider CRUD operations
@ -156,3 +158,15 @@ class FederationExtension(wsgi.ExtensionRouter):
controller=mapping_controller,
action='update_mapping',
conditions=dict(method=['PATCH']))
mapper.connect(
self._construct_url('domains'),
controller=domain_controller,
action='list_domains_for_groups',
conditions=dict(method=['GET']))
mapper.connect(
self._construct_url('projects'),
controller=project_controller,
action='list_projects_for_groups',
conditions=dict(method=['GET']))

View File

@ -191,7 +191,10 @@ class RuleProcessor(object):
new_local = self._update_local_mapping(local, direct_maps)
identity_values.append(new_local)
return self._transform(identity_values)
mapped_properties = self._transform(identity_values)
if mapped_properties.get('name') is None:
raise exception.Unauthorized(_("Could not map user"))
return mapped_properties
def _transform(self, identity_values):
"""Transform local mappings, to an easier to understand format.

View File

@ -28,9 +28,17 @@ MAPPING_SMALL = {
"group": {
"id": EMPLOYEE_GROUP_ID
}
},
{
"user": {
"name": "{0}"
}
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "orgPersonType",
"not_any_of": [
@ -52,9 +60,17 @@ MAPPING_SMALL = {
"group": {
"id": CONTRACTOR_GROUP_ID
}
},
{
"user": {
"name": "{0}"
}
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "orgPersonType",
"any_one_of": [
@ -148,9 +164,17 @@ MAPPING_LARGE = {
"group": {
"id": DEVELOPER_GROUP_ID
}
},
{
"user": {
"name": "{0}"
}
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "orgPersonType",
"any_one_of": [
@ -278,9 +302,17 @@ MAPPING_EXTRA_REMOTE_PROPS_NOT_ANY_OF = {
"group": {
"id": "0cd5e9"
}
},
{
"user": {
"name": "{0}"
}
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "orgPersonType",
"not_any_of": [
@ -301,9 +333,17 @@ MAPPING_EXTRA_REMOTE_PROPS_ANY_ONE_OF = {
"group": {
"id": "0cd5e9"
}
},
{
"user": {
"name": "{0}"
}
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "orgPersonType",
"any_one_of": [
@ -324,9 +364,17 @@ MAPPING_EXTRA_REMOTE_PROPS_JUST_TYPE = {
"group": {
"id": "0cd5e9"
}
},
{
"user": {
"name": "{0}"
}
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "orgPersonType",
"invalid_type": "xyz"
@ -344,12 +392,20 @@ MAPPING_EXTRA_RULES_PROPS = {
"group": {
"id": "0cd5e9"
}
},
{
"user": {
"name": "{0}"
}
}
],
"invalid_type": {
"id": "xyz",
},
"remote": [
{
"type": "UserName"
},
{
"type": "orgPersonType",
"not_any_of": [

View File

@ -29,8 +29,9 @@ ca_certs = examples/pki/certs/cacert.pem
backends = keystone.tests.test_kvs.KVSBackendForcedKeyMangleFixture, keystone.tests.test_kvs.KVSBackendFixture
[auth]
methods = external,password,token,oauth1
methods = external,password,token,oauth1,saml2
oauth1 = keystone.auth.plugins.oauth1.OAuth
saml2 = keystone.auth.plugins.saml2.Saml2
[paste_deploy]
config_file = keystone-paste.ini

View File

@ -13,10 +13,12 @@
import random
import uuid
from keystone.auth import controllers as auth_controllers
from keystone.common.sql import migration_helpers
from keystone import config
from keystone import contrib
from keystone.contrib.federation import utils as mapping_utils
from keystone import exception
from keystone.openstack.common.db.sqlalchemy import migration
from keystone.openstack.common import importutils
from keystone.openstack.common import jsonutils
@ -600,8 +602,8 @@ class MappingRuleEngineTests(FederationTests):
rp = mapping_utils.RuleProcessor(mapping['rules'])
values = rp.process(assertion)
fn = mapping_fixtures.ADMIN_ASSERTION.get('FirstName')
ln = mapping_fixtures.ADMIN_ASSERTION.get('LastName')
fn = assertion.get('FirstName')
ln = assertion.get('LastName')
full_name = '%s %s' % (fn, ln)
group_ids = values.get('group_ids')
@ -611,24 +613,21 @@ class MappingRuleEngineTests(FederationTests):
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.
"""Should deny authorization, 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.
RuleProcessor should raise exception.Unauthorized exception.
"""
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, [])
self.assertRaises(exception.Unauthorized,
rp.process, assertion)
def test_rule_engine_any_one_of_many_rules(self):
"""Should return group CONTRACTOR_GROUP_ID.
@ -645,10 +644,11 @@ class MappingRuleEngineTests(FederationTests):
rp = mapping_utils.RuleProcessor(mapping['rules'])
values = rp.process(assertion)
user_name = assertion.get('UserName')
group_ids = values.get('group_ids')
name = values.get('name')
self.assertIsNone(name)
self.assertEqual(user_name, name)
self.assertIn(mapping_fixtures.CONTRACTOR_GROUP_ID, group_ids)
def test_rule_engine_not_any_of_and_direct_mapping(self):
@ -665,7 +665,7 @@ class MappingRuleEngineTests(FederationTests):
rp = mapping_utils.RuleProcessor(mapping['rules'])
values = rp.process(assertion)
user_name = mapping_fixtures.CUSTOMER_ASSERTION.get('UserName')
user_name = assertion.get('UserName')
group_ids = values.get('group_ids')
name = values.get('name')
@ -685,11 +685,11 @@ class MappingRuleEngineTests(FederationTests):
assertion = mapping_fixtures.EMPLOYEE_ASSERTION
rp = mapping_utils.RuleProcessor(mapping['rules'])
values = rp.process(assertion)
user_name = assertion.get('UserName')
group_ids = values.get('group_ids')
name = values.get('name')
self.assertIsNone(name)
self.assertEqual(name, user_name)
self.assertIn(mapping_fixtures.EMPLOYEE_GROUP_ID, group_ids)
def test_rule_engine_regex_match_and_many_groups(self):
@ -706,10 +706,596 @@ class MappingRuleEngineTests(FederationTests):
assertion = mapping_fixtures.TESTER_ASSERTION
rp = mapping_utils.RuleProcessor(mapping['rules'])
values = rp.process(assertion)
user_name = assertion.get('UserName')
group_ids = values.get('group_ids')
name = values.get('name')
self.assertIsNone(name)
self.assertEqual(user_name, name)
self.assertIn(mapping_fixtures.DEVELOPER_GROUP_ID, group_ids)
self.assertIn(mapping_fixtures.TESTER_GROUP_ID, group_ids)
class FederatedTokenTests(FederationTests):
IDP = 'ORG_IDP'
PROTOCOL = 'saml2'
AUTH_METHOD = 'saml2'
USER = 'user@ORGANIZATION'
UNSCOPED_V3_SAML2_REQ = {
"identity": {
"methods": [AUTH_METHOD],
AUTH_METHOD: {
"identity_provider": IDP,
"protocol": PROTOCOL
}
}
}
AUTH_URL = '/auth/tokens'
def setUp(self):
super(FederationTests, self).setUp()
self.load_sample_data()
self.load_federation_sample_data()
def idp_ref(self, id=None):
idp = {
'id': id or uuid.uuid4().hex,
'enabled': True,
'description': uuid.uuid4().hex
}
return idp
def proto_ref(self, mapping_id=None):
proto = {
'id': uuid.uuid4().hex,
'mapping_id': mapping_id or uuid.uuid4().hex
}
return proto
def mapping_ref(self, rules=None):
return {
'id': uuid.uuid4().hex,
'rules': rules or self.rules['rules']
}
def _scope_request(self, unscoped_token_id, scope, scope_id):
return {
'auth': {
'identity': {
'methods': [
self.AUTH_METHOD
],
self.AUTH_METHOD: {
'id': unscoped_token_id
}
},
'scope': {
scope: {
'id': scope_id
}
}
}
}
def _project(self, project):
return (project['id'], project['name'])
def _roles(self, roles):
return set([(r['id'], r['name']) for r in roles])
def _check_projects_and_roles(self, token, roles, projects):
"""Check whether the projects and the roles match."""
token_roles = token.get('roles')
if token_roles is None:
raise AssertionError('Roles not found in the token')
token_roles = self._roles(token_roles)
roles_ref = self._roles(roles)
self.assertEqual(token_roles, roles_ref)
token_projects = token.get('project')
if token_projects is None:
raise AssertionError('Projects not found in the token')
token_projects = self._project(token_projects)
projects_ref = self._project(projects)
self.assertEqual(token_projects, projects_ref)
def _check_scoped_token_attributes(self, token):
def xor_project_domain(iterable):
return sum(('project' in iterable, 'domain' in iterable)) % 2
for obj in ('user', 'catalog', 'expires_at', 'issued_at',
'methods', 'roles'):
self.assertIn(obj, token)
# Check for either project or domain
if not xor_project_domain(token.keys()):
raise AssertionError("You must specify either"
"project or domain.")
def _issue_unscoped_token(self, assertion='EMPLOYEE_ASSERTION'):
api = auth_controllers.Auth()
context = {'environment': {}}
self._inject_assertion(context, assertion)
r = api.authenticate_for_token(context, self.UNSCOPED_V3_SAML2_REQ)
return r
def test_issue_unscoped_token(self):
r = self._issue_unscoped_token()
self.assertIsNotNone(r.headers.get('X-Subject-Token'))
def test_scope_to_project_once(self):
r = self.post(self.AUTH_URL,
body=self.TOKEN_SCOPE_PROJECT_EMPLOYEE_FROM_EMPLOYEE)
token_resp = r.result['token']
project_id = token_resp['project']['id']
self.assertEqual(project_id, self.proj_employees['id'])
self._check_scoped_token_attributes(token_resp)
roles_ref = [self.role_employee]
projects_ref = self.proj_employees
self._check_projects_and_roles(token_resp, roles_ref, projects_ref)
def scope_to_bad_project(self):
"""Scope unscoped token with a project we don't have access to."""
self.post(self.AUTH_URL,
body=self.TOKEN_SCOPE_PROJECT_EMPLOYEE_FROM_CUSTOMER,
expected_status=401)
def test_scope_to_project_multiple_times(self):
"""Try to scope the unscoped token multiple times.
The new tokens should be scoped to:
* Customers' project
* Employees' project
"""
bodies = (self.TOKEN_SCOPE_PROJECT_EMPLOYEE_FROM_ADMIN,
self.TOKEN_SCOPE_PROJECT_CUSTOMER_FROM_ADMIN)
project_ids = (self.proj_employees['id'],
self.proj_customers['id'])
for body, project_id_ref in zip(bodies, project_ids):
r = self.post(self.AUTH_URL, body=body)
token_resp = r.result['token']
project_id = token_resp['project']['id']
self.assertEqual(project_id, project_id_ref)
self._check_scoped_token_attributes(token_resp)
def test_scope_token_from_nonexistent_unscoped_token(self):
"""Try to scope token from non-existent unscoped token."""
self.post(self.AUTH_URL,
body=self.TOKEN_SCOPE_PROJECT_FROM_NONEXISTENT_TOKEN,
expected_status=404)
def test_issue_token_from_rules_without_user(self):
api = auth_controllers.Auth()
context = {'environment': {}}
self._inject_assertion(context, 'BAD_TESTER_ASSERTION')
self.assertRaises(exception.Unauthorized,
api.authenticate_for_token,
context, self.UNSCOPED_V3_SAML2_REQ)
def test_issue_token_with_nonexistent_group(self):
r = self._issue_unscoped_token(assertion='CONTRACTOR_ASSERTION')
groups = r.json['token']['user']['OS-FEDERATION:groups']
group_ids = set(g['id'] for g in groups)
group_ids_ref = set(['bad_group_id'])
self.assertEqual(group_ids, group_ids_ref)
def test_scope_to_domain_once(self):
r = self.post(self.AUTH_URL,
body=self.TOKEN_SCOPE_DOMAIN_A_FROM_CUSTOMER)
token_resp = r.result['token']
domain_id = token_resp['domain']['id']
self.assertEqual(domain_id, self.domainA['id'])
self._check_scoped_token_attributes(token_resp)
def test_scope_to_domain_multiple_tokens(self):
"""Issue multiple tokens scoping to different domains.
The new tokens should be scoped to:
* domainA
* domainB
* domainC
"""
bodies = (self.TOKEN_SCOPE_DOMAIN_A_FROM_ADMIN,
self.TOKEN_SCOPE_DOMAIN_B_FROM_ADMIN,
self.TOKEN_SCOPE_DOMAIN_C_FROM_ADMIN)
domain_ids = (self.domainA['id'],
self.domainB['id'],
self.domainC['id'])
for body, domain_id_ref in zip(bodies, domain_ids):
r = self.post(self.AUTH_URL, body=body)
token_resp = r.result['token']
domain_id = token_resp['domain']['id']
self.assertEqual(domain_id, domain_id_ref)
self._check_scoped_token_attributes(token_resp)
def test_list_projects(self):
url = '/OS-FEDERATION/projects'
token = (self.tokens['CUSTOMER_ASSERTION'],
self.tokens['EMPLOYEE_ASSERTION'],
self.tokens['ADMIN_ASSERTION'])
projects_refs = (set([self.proj_customers['id']]),
set([self.proj_employees['id'],
self.project_all['id']]),
set([self.proj_employees['id'],
self.project_all['id'],
self.proj_customers['id']]))
for token, projects_ref in zip(token, projects_refs):
r = self.get(url, token=token)
projects_resp = r.result['projects']
projects = set(p['id'] for p in projects_resp)
self.assertEqual(projects, projects_ref)
def test_list_domains(self):
url = '/OS-FEDERATION/domains'
tokens = (self.tokens['CUSTOMER_ASSERTION'],
self.tokens['EMPLOYEE_ASSERTION'],
self.tokens['ADMIN_ASSERTION'])
domain_refs = (set([self.domainA['id']]),
set([self.domainA['id'],
self.domainB['id']]),
set([self.domainA['id'],
self.domainB['id'],
self.domainC['id']]))
for token, domains_ref in zip(tokens, domain_refs):
r = self.get(url, token=token)
domains_resp = r.result['domains']
domains = set(p['id'] for p in domains_resp)
self.assertEqual(domains, domains_ref)
def test_full_workflow(self):
"""Test 'standard' workflow for granting access tokens.
* Issue unscoped token
* List available projects based on groups
* Scope token to a one of available projects
"""
r = self._issue_unscoped_token()
employee_unscoped_token_id = r.headers.get('X-Subject-Token')
r = self.get('/OS-FEDERATION/projects',
token=employee_unscoped_token_id)
projects = r.result['projects']
random_project = random.randint(0, len(projects)) - 1
project = projects[random_project]
v3_scope_request = self._scope_request(employee_unscoped_token_id,
'project', project['id'])
r = self.post(self.AUTH_URL, body=v3_scope_request)
token_resp = r.result['token']
project_id = token_resp['project']['id']
self.assertEqual(project_id, project['id'])
self._check_scoped_token_attributes(token_resp)
def load_federation_sample_data(self):
"""Inject additional data."""
# Create and add domains
self.domainA = self.new_domain_ref()
self.assignment_api.create_domain(self.domainA['id'],
self.domainA)
self.domainB = self.new_domain_ref()
self.assignment_api.create_domain(self.domainB['id'],
self.domainB)
self.domainC = self.new_domain_ref()
self.assignment_api.create_domain(self.domainC['id'],
self.domainC)
# Create and add projects
self.proj_employees = self.new_project_ref(
domain_id=self.domainA['id'])
self.assignment_api.create_project(self.proj_employees['id'],
self.proj_employees)
self.proj_customers = self.new_project_ref(
domain_id=self.domainA['id'])
self.assignment_api.create_project(self.proj_customers['id'],
self.proj_customers)
self.project_all = self.new_project_ref(
domain_id=self.domainA['id'])
self.assignment_api.create_project(self.project_all['id'],
self.project_all)
# Create and add groups
self.group_employees = self.new_group_ref(
domain_id=self.domainA['id'])
self.identity_api.create_group(self.group_employees['id'],
self.group_employees)
self.group_customers = self.new_group_ref(
domain_id=self.domainA['id'])
self.identity_api.create_group(self.group_customers['id'],
self.group_customers)
self.group_admins = self.new_group_ref(
domain_id=self.domainA['id'])
self.identity_api.create_group(self.group_admins['id'],
self.group_admins)
# Create and add roles
self.role_employee = self.new_role_ref()
self.assignment_api.create_role(self.role_employee['id'],
self.role_employee)
self.role_customer = self.new_role_ref()
self.assignment_api.create_role(self.role_customer['id'],
self.role_customer)
self.role_admin = self.new_role_ref()
self.assignment_api.create_role(self.role_admin['id'],
self.role_admin)
# Employees can access
# * proj_employees
# * project_all
self.assignment_api.create_grant(self.role_employee['id'],
group_id=self.group_employees['id'],
project_id=self.proj_employees['id'])
self.assignment_api.create_grant(self.role_employee['id'],
group_id=self.group_employees['id'],
project_id=self.project_all['id'])
# Customers can access
# * proj_customers
self.assignment_api.create_grant(self.role_customer['id'],
group_id=self.group_customers['id'],
project_id=self.proj_customers['id'])
# Admins can access:
# * proj_customers
# * proj_employees
# * project_all
self.assignment_api.create_grant(self.role_admin['id'],
group_id=self.group_admins['id'],
project_id=self.proj_customers['id'])
self.assignment_api.create_grant(self.role_admin['id'],
group_id=self.group_admins['id'],
project_id=self.proj_employees['id'])
self.assignment_api.create_grant(self.role_admin['id'],
group_id=self.group_admins['id'],
project_id=self.project_all['id'])
self.assignment_api.create_grant(self.role_customer['id'],
group_id=self.group_customers['id'],
domain_id=self.domainA['id'])
# Customers can access:
# * domain A
self.assignment_api.create_grant(self.role_customer['id'],
group_id=self.group_customers['id'],
domain_id=self.domainA['id'])
# Employees can access:
# * domain A
# * domain B
self.assignment_api.create_grant(self.role_employee['id'],
group_id=self.group_employees['id'],
domain_id=self.domainA['id'])
self.assignment_api.create_grant(self.role_employee['id'],
group_id=self.group_employees['id'],
domain_id=self.domainB['id'])
# Admins can access:
# * domain A
# * domain B
# * domain C
self.assignment_api.create_grant(self.role_admin['id'],
group_id=self.group_admins['id'],
domain_id=self.domainA['id'])
self.assignment_api.create_grant(self.role_admin['id'],
group_id=self.group_admins['id'],
domain_id=self.domainB['id'])
self.assignment_api.create_grant(self.role_admin['id'],
group_id=self.group_admins['id'],
domain_id=self.domainC['id'])
self.rules = {
'rules': [
{
'local': [
{
'group': {
'id': self.group_employees['id']
}
},
{
'user': {
'name': '{0}'
}
}
],
'remote': [
{
'type': 'UserName'
},
{
'type': 'orgPersonType',
'any_one_of': [
'Employee'
]
}
]
},
{
'local': [
{
'group': {
'id': self.group_customers['id']
}
},
{
'user': {
'name': '{0}'
}
}
],
'remote': [
{
'type': 'UserName'
},
{
'type': 'orgPersonType',
'any_one_of': [
'Customer'
]
}
]
},
{
'local': [
{
'group': {
'id': self.group_admins['id']
}
},
{
'group': {
'id': self.group_employees['id']
}
},
{
'group': {
'id': self.group_customers['id']
}
},
{
'user': {
'name': '{0}'
}
}
],
'remote': [
{
'type': 'UserName'
},
{
'type': 'orgPersonType',
'any_one_of': [
'Admin',
'Chief'
]
}
]
},
{
'local': [
{
'group': {
'id': 'bad_group_id'
}
},
{
'user': {
'name': '{0}'
}
}
],
'remote': [
{
'type': 'UserName',
},
{
'type': 'FirstName',
'any_one_of': [
'Jill'
]
},
{
'type': 'LastName',
'any_one_of': [
'Smith'
]
}
]
},
]
}
# Add IDP
self.idp = self.idp_ref(id=self.IDP)
self.federation_api.create_idp(self.idp['id'],
self.idp)
# Add a mapping
self.mapping = self.mapping_ref()
self.federation_api.create_mapping(self.mapping['id'],
self.mapping)
# Add protocols
self.proto_saml = self.proto_ref(mapping_id=self.mapping['id'])
self.proto_saml['id'] = self.PROTOCOL
self.federation_api.create_protocol(self.idp['id'],
self.proto_saml['id'],
self.proto_saml)
# Generate fake tokens
context = {'environment': {}}
self.tokens = {}
VARIANTS = ('EMPLOYEE_ASSERTION', 'CUSTOMER_ASSERTION',
'ADMIN_ASSERTION')
api = auth_controllers.Auth()
for variant in VARIANTS:
self._inject_assertion(context, variant)
r = api.authenticate_for_token(context, self.UNSCOPED_V3_SAML2_REQ)
self.tokens[variant] = r.headers.get('X-Subject-Token')
self.TOKEN_SCOPE_PROJECT_FROM_NONEXISTENT_TOKEN = self._scope_request(
uuid.uuid4().hex, 'project', self.proj_customers['id'])
self.TOKEN_SCOPE_PROJECT_EMPLOYEE_FROM_EMPLOYEE = self._scope_request(
self.tokens['EMPLOYEE_ASSERTION'], 'project',
self.proj_employees['id'])
self.TOKEN_SCOPE_PROJECT_EMPLOYEE_FROM_ADMIN = self._scope_request(
self.tokens['ADMIN_ASSERTION'], 'project',
self.proj_employees['id'])
self.TOKEN_SCOPE_PROJECT_CUSTOMER_FROM_ADMIN = self._scope_request(
self.tokens['ADMIN_ASSERTION'], 'project',
self.proj_customers['id'])
self.TOKEN_SCOPE_PROJECT_EMPLOYEE_FROM_CUSTOMER = self._scope_request(
self.tokens['CUSTOMER_ASSERTION'], 'project',
self.proj_employees['id'])
self.TOKEN_SCOPE_DOMAIN_A_FROM_CUSTOMER = self._scope_request(
self.tokens['CUSTOMER_ASSERTION'], 'domain', self.domainA['id'])
self.TOKEN_SCOPE_DOMAIN_B_FROM_CUSTOMER = self._scope_request(
self.tokens['CUSTOMER_ASSERTION'], 'domain', self.domainB['id'])
self.TOKEN_SCOPE_DOMAIN_B_FROM_CUSTOMER = self._scope_request(
self.tokens['CUSTOMER_ASSERTION'], 'domain',
self.domainB['id'])
self.TOKEN_SCOPE_DOMAIN_A_FROM_ADMIN = self._scope_request(
self.tokens['ADMIN_ASSERTION'], 'domain', self.domainA['id'])
self.TOKEN_SCOPE_DOMAIN_B_FROM_ADMIN = self._scope_request(
self.tokens['ADMIN_ASSERTION'], 'domain', self.domainB['id'])
self.TOKEN_SCOPE_DOMAIN_C_FROM_ADMIN = self._scope_request(
self.tokens['ADMIN_ASSERTION'], 'domain',
self.domainC['id'])
def _inject_assertion(self, context, variant):
assertion = getattr(mapping_fixtures, variant)
context['environment'].update(assertion)
context['query_string'] = []

View File

@ -16,9 +16,11 @@ import json
import sys
import six
from six.moves.urllib import parse
from keystone.common import dependency
from keystone import config
from keystone.contrib import federation
from keystone import exception
from keystone import token
from keystone.token import provider
@ -169,6 +171,34 @@ class V3TokenDataHelper(object):
user_id, project_id)
return [self.assignment_api.get_role(role_id) for role_id in roles]
def _populate_roles_for_groups(self, group_ids,
project_id=None, domain_id=None,
user_id=None):
def _check_roles(roles, user_id, project_id, domain_id):
# User was granted roles so simply exit this function.
if roles:
return
if project_id:
msg = _('User %(user_id)s has no access '
'to project %(project_id)s') % {
'user_id': user_id,
'project_id': project_id}
elif domain_id:
msg = _('User %(user_id)s has no access '
'to domain %(domain_id)s') % {
'user_id': user_id,
'domain_id': domain_id}
# Since no roles were found a user is not authorized to
# perform any operations. Raise an exception with
# appropriate error message.
raise exception.Unauthorized(msg)
roles = self.assignment_api.get_roles_for_groups(group_ids,
project_id,
domain_id)
_check_roles(roles, user_id, project_id, domain_id)
return roles
def _populate_user(self, token_data, user_id, trust):
if 'user' in token_data:
# no need to repopulate user if it already exists
@ -391,6 +421,11 @@ class BaseProvider(provider.Provider):
'trust_id' in metadata_ref):
trust = self.trust_api.get_trust(metadata_ref['trust_id'])
token_ref = None
if 'saml2' in method_names:
token_ref = self._handle_saml2_tokens(auth_context, project_id,
domain_id)
access_token = None
if 'oauth1' in method_names:
if self.oauth_api:
@ -408,6 +443,7 @@ class BaseProvider(provider.Provider):
expires=expires_at,
trust=trust,
bind=auth_context.get('bind') if auth_context else None,
token=token_ref,
include_catalog=include_catalog,
access_token=access_token)
@ -451,6 +487,32 @@ class BaseProvider(provider.Provider):
return (token_id, token_data)
def _handle_saml2_tokens(self, auth_context, project_id, domain_id):
user_id = auth_context['user_id']
group_ids = auth_context['group_ids']
token_data = {
'user': {
'id': user_id,
'name': parse.unquote(user_id)
}
}
if project_id or domain_id:
roles = self.v3_token_data_helper._populate_roles_for_groups(
group_ids, project_id, domain_id)
token_data.update({'roles': roles})
else:
idp = auth_context[federation.IDENTITY_PROVIDER]
protocol = auth_context[federation.PROTOCOL]
token_data['user'].update({
federation.FEDERATION: {
'identity_provider': {'id': idp},
'protocol': {'id': protocol}
},
federation.GROUPS: [{'id': x} for x in group_ids]
})
return token_data
def _verify_token(self, token_id):
"""Verify the given token and return the token_ref."""
token_ref = self.token_api.get_token(token_id)