Implement federated auto-provisioning

Provide a way to provision projects and assignments when a federated
user authenticates for the first time for an unscoped token.

implements bp shadow-mapping

Change-Id: I6029dac8294e8cfc4bf622ac71b5e731956389db
This commit is contained in:
Lance Bragstad 2016-12-22 18:21:04 +00:00
parent 1c94ae71d6
commit 9e830dbe02
7 changed files with 513 additions and 9 deletions

View File

@ -11,7 +11,9 @@
# under the License.
import functools
import uuid
from oslo_log import log
from pycadf import cadftaxonomy as taxonomy
from six.moves.urllib import parse
@ -21,16 +23,17 @@ from keystone.common import dependency
from keystone import exception
from keystone.federation import constants as federation_constants
from keystone.federation import utils
from keystone.i18n import _
from keystone.i18n import _, _LE, _LI
from keystone.models import token_model
from keystone import notifications
LOG = log.getLogger(__name__)
METHOD_NAME = 'mapped'
@dependency.requires('federation_api', 'identity_api',
'resource_api', 'token_provider_api')
@dependency.requires('assignment_api', 'federation_api', 'identity_api',
'resource_api', 'token_provider_api', 'role_api')
class Mapped(base.AuthMethodHandler):
def _get_token_ref(self, auth_payload):
@ -66,7 +69,9 @@ class Mapped(base.AuthMethodHandler):
auth_context,
self.resource_api,
self.federation_api,
self.identity_api)
self.identity_api,
self.assignment_api,
self.role_api)
def handle_scoped_token(request, auth_context, token_ref,
@ -105,7 +110,75 @@ def handle_scoped_token(request, auth_context, token_ref,
def handle_unscoped_token(request, auth_payload, auth_context,
resource_api, federation_api, identity_api):
resource_api, federation_api, identity_api,
assignment_api, role_api):
def validate_shadow_mapping(shadow_projects, existing_roles, idp_domain_id,
idp_id):
# Validate that the roles in the shadow mapping actually exist. If
# they don't we should bail early before creating anything.
for shadow_project in shadow_projects:
for shadow_role in shadow_project['roles']:
# The role in the project mapping must exist in order for it to
# be useful.
if shadow_role['name'] not in existing_roles:
LOG.error(
_LE('Role %s was specified in the mapping but does '
'not exist. All roles specified in a mapping must '
'exist before assignment.'),
shadow_role['name']
)
# NOTE(lbragstad): The RoleNotFound exception usually
# expects a role_id as the parameter, but in this case we
# only have a name so we'll pass that instead.
raise exception.RoleNotFound(shadow_role['name'])
role = existing_roles[shadow_role['name']]
if (role['domain_id'] is not None and
role['domain_id'] != idp_domain_id):
LOG.error(
_LE('Role %(role)s is a domain-specific role and '
'cannot be assigned within %(domain)s.'),
{'role': shadow_role['name'], 'domain': idp_domain_id}
)
raise exception.DomainSpecificRoleNotWithinIdPDomain(
role_name=shadow_role['name'],
identity_provider=idp_id
)
def create_projects_from_mapping(shadow_projects, idp_domain_id,
existing_roles, user, assignment_api,
resource_api):
for shadow_project in shadow_projects:
try:
# Check and see if the project already exists and if it
# does not, try to create it.
project = resource_api.get_project_by_name(
shadow_project['name'], idp_domain_id
)
except exception.ProjectNotFound:
LOG.info(
_LI('Project %(project_name)s does not exist. It will be '
'automatically provisioning for user %(user_id)s.'),
{'project_name': shadow_project['name'],
'user_id': user['id']}
)
project_ref = {
'id': uuid.uuid4().hex,
'name': shadow_project['name'],
'domain_id': idp_domain_id
}
project = resource_api.create_project(
project_ref['id'],
project_ref
)
shadow_roles = shadow_project['roles']
for shadow_role in shadow_roles:
assignment_api.create_grant(
existing_roles[shadow_role['name']]['id'],
user_id=user['id'],
project_id=project['id']
)
def is_ephemeral_user(mapped_properties):
return mapped_properties['user']['type'] == utils.UserType.EPHEMERAL
@ -155,6 +228,34 @@ def handle_unscoped_token(request, auth_payload, auth_context,
user = identity_api.shadow_federated_user(identity_provider,
protocol, unique_id,
display_name)
if 'projects' in mapped_properties:
idp_domain_id = federation_api.get_idp(
identity_provider
)['domain_id']
existing_roles = {
role['name']: role for role in role_api.list_roles()
}
# NOTE(lbragstad): If we are dealing with a shadow mapping,
# then we need to make sure we validate all pieces of the
# mapping and what it's saying to create. If there is something
# wrong with how the mapping is, we should bail early before we
# create anything.
validate_shadow_mapping(
mapped_properties['projects'],
existing_roles,
idp_domain_id,
identity_provider
)
create_projects_from_mapping(
mapped_properties['projects'],
idp_domain_id,
existing_roles,
user,
assignment_api,
resource_api
)
user_id = user['id']
group_ids = mapped_properties['group_ids']
build_ephemeral_user_context(auth_context, user,

View File

@ -348,6 +348,11 @@ class DomainSpecificRoleMismatch(Forbidden):
" as the role: %(role_id)s being assigned.")
class DomainSpecificRoleNotWithinIdPDomain(Forbidden):
message_format = _("role: %(role_name)s must be within the same domain as "
"the identity provider: %(identity_provider)s.")
class RoleAssignmentNotFound(NotFound):
message_format = _("Could not find role assignment with role: "
"%(role_id)s, user or group: %(actor_id)s, "

View File

@ -36,6 +36,20 @@ class UserType(object):
EPHEMERAL = 'ephemeral'
LOCAL = 'local'
ROLE_PROPERTIES = {
"type": "array",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"additionalProperties": False
}
}
}
MAPPING_SCHEMA = {
"type": "object",
@ -72,6 +86,18 @@ MAPPING_SCHEMA = {
},
"additionalProperties": False
},
"projects": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "roles"],
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"roles": ROLE_PROPERTIES
}
}
},
"group": {
"type": "object",
"oneOf": [

View File

@ -742,9 +742,18 @@ class MappingRuleEngineTests(unit.BaseTestCase):
self.assertEqual(expected_username, values['user']['name'])
expected_projects = [
{"name": "a"},
{"name": "b"},
{"name": "project for %s" % expected_username},
{
"name": "Production",
"roles": [{"name": "observer"}]
},
{
"name": "Staging",
"roles": [{"name": "member"}]
},
{
"name": "Project for %s" % expected_username,
"roles": [{"name": "admin"}]
}
]
self.assertEqual(expected_projects, values['projects'])

View File

@ -1590,6 +1590,44 @@ MAPPING_UNICODE = {
}
MAPPING_PROJECTS = {
"rules": [
{
"local": [
{
"user": {
"name": "{0}"
}
},
{
"projects": [
{"name": "Production",
"roles": [{"name": "observer"}]},
{"name": "Staging",
"roles": [{"name": "member"}]},
{"name": "Project for {0}",
"roles": [{"name": "admin"}]},
],
}
],
"remote": [
{
"type": "UserName"
},
{
"type": "Email",
},
{
"type": "orgPersonType",
"any_one_of": [
"Employee"
]
}
]
}
]
}
MAPPING_PROJECTS_WITHOUT_ROLES = {
"rules": [
{
"local": [
@ -1600,7 +1638,7 @@ MAPPING_PROJECTS = {
"projects": [
{"name": "a"},
{"name": "b"},
{"name": "project for {0}"},
{"name": "Project for {0}"},
],
}
],
@ -1612,3 +1650,29 @@ MAPPING_PROJECTS = {
},
]
}
MAPPING_PROJECTS_WITHOUT_NAME = {
"rules": [
{
"local": [
{
"user": {
"name": "{0}"
},
"projects": [
{"roles": [{"name": "observer"}]},
{"name": "Staging",
"roles": [{"name": "member"}]},
{"name": "Project for {0}",
"roles": [{"name": "admin"}]},
]
}
],
"remote": [
{
"type": "UserName"
}
]
},
]
}

View File

@ -1700,6 +1700,58 @@ class MappingCRUDTests(test_v3.RestfulTestCase):
self.put(url, expected_status=http_client.BAD_REQUEST,
body={'mapping': bad_mapping})
def test_create_shadow_mapping_without_roles_fails(self):
"""Validate that mappings with projects contain roles when created."""
url = self.MAPPING_URL + uuid.uuid4().hex
self.put(
url,
body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_ROLES},
expected_status=http_client.BAD_REQUEST
)
def test_update_shadow_mapping_without_roles_fails(self):
"""Validate that mappings with projects contain roles when updated."""
url = self.MAPPING_URL + uuid.uuid4().hex
resp = self.put(
url,
body={'mapping': mapping_fixtures.MAPPING_PROJECTS},
expected_status=http_client.CREATED
)
self.assertValidMappingResponse(
resp, mapping_fixtures.MAPPING_PROJECTS
)
self.patch(
url,
body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_ROLES},
expected_status=http_client.BAD_REQUEST
)
def test_create_shadow_mapping_without_name_fails(self):
"""Validate project mappings contain the project name when created."""
url = self.MAPPING_URL + uuid.uuid4().hex
self.put(
url,
body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_NAME},
expected_status=http_client.BAD_REQUEST
)
def test_update_shadow_mapping_without_name_fails(self):
"""Validate project mappings contain the project name when updated."""
url = self.MAPPING_URL + uuid.uuid4().hex
resp = self.put(
url,
body={'mapping': mapping_fixtures.MAPPING_PROJECTS},
expected_status=http_client.CREATED
)
self.assertValidMappingResponse(
resp, mapping_fixtures.MAPPING_PROJECTS
)
self.patch(
url,
body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_NAME},
expected_status=http_client.BAD_REQUEST
)
class FederatedTokenTests(test_v3.RestfulTestCase, FederatedSetupMixin):
@ -3015,6 +3067,244 @@ class FederatedUserTests(test_v3.RestfulTestCase, FederatedSetupMixin):
return token_resp['user']['id'], unscoped_token
class ShadowMappingTests(test_v3.RestfulTestCase, FederatedSetupMixin):
"""Test class dedicated to auto-provisioning resources at login.
A shadow mapping is a mapping that contains extra properties about that
specific federated user's situation based on attributes from the assertion.
For example, a shadow mapping can tell us that a user should have specific
role assignments on certain projects within a domain. When a federated user
authenticates, the shadow mapping will create these entities before
returning the authenticated response to the user. This test class is
dedicated to testing specific aspects of shadow mapping when performing
federated authentication.
"""
def setUp(self):
super(ShadowMappingTests, self).setUp()
# update the mapping we have already setup to have specific projects
# and roles.
self.federation_api.update_mapping(
self.mapping['id'],
mapping_fixtures.MAPPING_PROJECTS
)
# The shadow mapping we're using in these tests contain a role named
# `member` and `observer` for the sake of using something other than
# `admin`. We'll need to create those before hand, otherwise the
# mapping will fail during authentication because the roles defined in
# the mapping do not exist yet. The shadow mapping mechanism currently
# doesn't support creating roles on-the-fly, but this could change in
# the future after we get some feedback from shadow mapping being used
# in real deployments. We also want to make sure we are dealing with
# global roles and not domain-scoped roles. We have specific tests
# below that test that behavior and the setup is done in the test.
member_role_ref = unit.new_role_ref(name='member')
assert member_role_ref['domain_id'] is None
self.member_role = self.role_api.create_role(
member_role_ref['id'], member_role_ref
)
observer_role_ref = unit.new_role_ref(name='observer')
assert observer_role_ref['domain_id'] is None
self.observer_role = self.role_api.create_role(
observer_role_ref['id'], observer_role_ref
)
# This is a mapping of the project name to the role that is supposed to
# be assigned to the user on that project from the shadow mapping.
self.expected_results = {
'Production': 'observer',
'Staging': 'member',
'Project for tbo': 'admin'
}
def auth_plugin_config_override(self):
methods = ['saml2']
super(ShadowMappingTests, self).auth_plugin_config_override(methods)
def load_fixtures(self, fixtures):
super(ShadowMappingTests, self).load_fixtures(fixtures)
self.load_federation_sample_data()
def test_shadow_mapping_creates_projects(self):
projects = self.resource_api.list_projects()
for project in projects:
self.assertNotIn(project['name'], self.expected_results)
response = self._issue_unscoped_token()
self.assertValidMappedUser(response.json_body['token'])
unscoped_token = response.headers.get('X-Subject-Token')
response = self.get('/auth/projects', token=unscoped_token)
projects = response.json_body['projects']
for project in projects:
project = self.resource_api.get_project_by_name(
project['name'],
self.idp['domain_id']
)
self.assertIn(project['name'], self.expected_results)
def test_shadow_mapping_create_projects_role_assignments(self):
response = self._issue_unscoped_token()
self.assertValidMappedUser(response.json_body['token'])
unscoped_token = response.headers.get('X-Subject-Token')
response = self.get('/auth/projects', token=unscoped_token)
projects = response.json_body['projects']
for project in projects:
# Ask for a scope token to each project in the mapping. Each token
# should contain a different role so let's check that is right,
# too.
scope = self._scope_request(
unscoped_token, 'project', project['id']
)
response = self.v3_create_token(scope)
project_name = response.json_body['token']['project']['name']
roles = response.json_body['token']['roles']
self.assertEqual(
self.expected_results[project_name], roles[0]['name']
)
def test_shadow_mapping_does_not_create_roles(self):
# If a role required by the mapping does not exist, then we should fail
# the mapping since shadow mapping currently does not support creating
# mappings on-the-fly.
self.role_api.delete_role(self.observer_role['id'])
self.assertRaises(exception.RoleNotFound, self._issue_unscoped_token)
def test_shadow_mapping_creates_project_in_identity_provider_domain(self):
response = self._issue_unscoped_token()
self.assertValidMappedUser(response.json_body['token'])
unscoped_token = response.headers.get('X-Subject-Token')
response = self.get('/auth/projects', token=unscoped_token)
projects = response.json_body['projects']
for project in projects:
self.assertEqual(project['domain_id'], self.idp['domain_id'])
def test_shadow_mapping_is_idempotent(self):
"""Test that projects remain idempotent for every federated auth."""
response = self._issue_unscoped_token()
self.assertValidMappedUser(response.json_body['token'])
unscoped_token = response.headers.get('X-Subject-Token')
response = self.get('/auth/projects', token=unscoped_token)
project_ids = [p['id'] for p in response.json_body['projects']]
response = self._issue_unscoped_token()
unscoped_token = response.headers.get('X-Subject-Token')
response = self.get('/auth/projects', token=unscoped_token)
projects = response.json_body['projects']
for project in projects:
self.assertIn(project['id'], project_ids)
def test_roles_outside_idp_domain_fail_mapping(self):
# Create a new domain
d = unit.new_domain_ref()
new_domain = self.resource_api.create_domain(d['id'], d)
# Delete the member role and recreate it in a different domain
self.role_api.delete_role(self.member_role['id'])
member_role_ref = unit.new_role_ref(
name='member',
domain_id=new_domain['id']
)
self.role_api.create_role(member_role_ref['id'], member_role_ref)
self.assertRaises(
exception.DomainSpecificRoleNotWithinIdPDomain,
self._issue_unscoped_token
)
def test_roles_in_idp_domain_can_be_assigned_from_mapping(self):
# Delete the member role and recreate it in the domain of the idp
self.role_api.delete_role(self.member_role['id'])
member_role_ref = unit.new_role_ref(
name='member',
domain_id=self.idp['domain_id']
)
self.role_api.create_role(member_role_ref['id'], member_role_ref)
response = self._issue_unscoped_token()
user_id = response.json_body['token']['user']['id']
unscoped_token = response.headers.get('X-Subject-Token')
response = self.get('/auth/projects', token=unscoped_token)
projects = response.json_body['projects']
staging_project = self.resource_api.get_project_by_name(
'Staging', self.idp['domain_id']
)
for project in projects:
# Even though the mapping successfully assigned the Staging project
# a member role for our user, the /auth/projects response doesn't
# include projects with only domain-specific role assignments.
self.assertNotEqual(project['name'], 'Staging')
domain_role_assignments = self.assignment_api.list_role_assignments(
user_id=user_id,
project_id=staging_project['id'],
strip_domain_roles=False
)
self.assertEqual(
staging_project['id'], domain_role_assignments[0]['project_id']
)
self.assertEqual(
user_id, domain_role_assignments[0]['user_id']
)
def test_mapping_with_groups_includes_projects_with_group_assignment(self):
# create a group called Observers
observer_group = unit.new_group_ref(
domain_id=self.idp['domain_id'],
name='Observers'
)
observer_group = self.identity_api.create_group(observer_group)
# make sure the Observers group has a role on the finance project
finance_project = unit.new_project_ref(
domain_id=self.idp['domain_id'],
name='Finance'
)
finance_project = self.resource_api.create_project(
finance_project['id'], finance_project
)
self.assignment_api.create_grant(
self.observer_role['id'],
group_id=observer_group['id'],
project_id=finance_project['id']
)
# update the mapping
group_rule = {
'group': {
'name': 'Observers',
'domain': {
'id': self.idp['domain_id']
}
}
}
updated_mapping = copy.deepcopy(mapping_fixtures.MAPPING_PROJECTS)
updated_mapping['rules'][0]['local'].append(group_rule)
self.federation_api.update_mapping(self.mapping['id'], updated_mapping)
response = self._issue_unscoped_token()
# user_id = response.json_body['token']['user']['id']
unscoped_token = response.headers.get('X-Subject-Token')
response = self.get('/auth/projects', token=unscoped_token)
projects = response.json_body['projects']
self.expected_results = {
# These assignments are all a result of a direct mapping from the
# shadow user to the newly created project.
'Production': 'observer',
'Staging': 'member',
'Project for tbo': 'admin',
# This is a result of the mapping engine maintaining its old
# behavior.
'Finance': 'observer'
}
for project in projects:
# Ask for a scope token to each project in the mapping. Each token
# should contain a different role so let's check that is right,
# too.
scope = self._scope_request(
unscoped_token, 'project', project['id']
)
response = self.v3_create_token(scope)
project_name = response.json_body['token']['project']['name']
roles = response.json_body['token']['roles']
self.assertEqual(
self.expected_results[project_name], roles[0]['name']
)
class JsonHomeTests(test_v3.RestfulTestCase, test_v3.JsonHomeTestMixin):
JSON_HOME_DATA = {
'http://docs.openstack.org/api/openstack-identity/3/ext/OS-FEDERATION/'

View File

@ -0,0 +1,9 @@
---
features:
- The federated mapping engine now supports the ability
to automatically provision federated users into projects
specified in the mapping rules at federated login time. If the
project within the mapping does not exist, it will be
automatically created in the domain of the Identity Provider.
This behavior can be done using a specific syntax within
the ``local`` rules section of a mapping.