Merge "Implement federated auto-provisioning"
This commit is contained in:
commit
5dc7af8fb5
@ -11,7 +11,9 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from oslo_log import log
|
||||||
from pycadf import cadftaxonomy as taxonomy
|
from pycadf import cadftaxonomy as taxonomy
|
||||||
from six.moves.urllib import parse
|
from six.moves.urllib import parse
|
||||||
|
|
||||||
@ -21,16 +23,17 @@ from keystone.common import dependency
|
|||||||
from keystone import exception
|
from keystone import exception
|
||||||
from keystone.federation import constants as federation_constants
|
from keystone.federation import constants as federation_constants
|
||||||
from keystone.federation import utils
|
from keystone.federation import utils
|
||||||
from keystone.i18n import _
|
from keystone.i18n import _, _LE, _LI
|
||||||
from keystone.models import token_model
|
from keystone.models import token_model
|
||||||
from keystone import notifications
|
from keystone import notifications
|
||||||
|
|
||||||
|
LOG = log.getLogger(__name__)
|
||||||
|
|
||||||
METHOD_NAME = 'mapped'
|
METHOD_NAME = 'mapped'
|
||||||
|
|
||||||
|
|
||||||
@dependency.requires('federation_api', 'identity_api',
|
@dependency.requires('assignment_api', 'federation_api', 'identity_api',
|
||||||
'resource_api', 'token_provider_api')
|
'resource_api', 'token_provider_api', 'role_api')
|
||||||
class Mapped(base.AuthMethodHandler):
|
class Mapped(base.AuthMethodHandler):
|
||||||
|
|
||||||
def _get_token_ref(self, auth_payload):
|
def _get_token_ref(self, auth_payload):
|
||||||
@ -66,7 +69,9 @@ class Mapped(base.AuthMethodHandler):
|
|||||||
auth_context,
|
auth_context,
|
||||||
self.resource_api,
|
self.resource_api,
|
||||||
self.federation_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,
|
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,
|
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):
|
def is_ephemeral_user(mapped_properties):
|
||||||
return mapped_properties['user']['type'] == utils.UserType.EPHEMERAL
|
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,
|
user = identity_api.shadow_federated_user(identity_provider,
|
||||||
protocol, unique_id,
|
protocol, unique_id,
|
||||||
display_name)
|
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']
|
user_id = user['id']
|
||||||
group_ids = mapped_properties['group_ids']
|
group_ids = mapped_properties['group_ids']
|
||||||
build_ephemeral_user_context(auth_context, user,
|
build_ephemeral_user_context(auth_context, user,
|
||||||
|
@ -348,6 +348,11 @@ class DomainSpecificRoleMismatch(Forbidden):
|
|||||||
"as the role %(role_id)s being assigned.")
|
"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):
|
class RoleAssignmentNotFound(NotFound):
|
||||||
message_format = _("Could not find role assignment with role: "
|
message_format = _("Could not find role assignment with role: "
|
||||||
"%(role_id)s, user or group: %(actor_id)s, "
|
"%(role_id)s, user or group: %(actor_id)s, "
|
||||||
|
@ -36,6 +36,20 @@ class UserType(object):
|
|||||||
EPHEMERAL = 'ephemeral'
|
EPHEMERAL = 'ephemeral'
|
||||||
LOCAL = 'local'
|
LOCAL = 'local'
|
||||||
|
|
||||||
|
ROLE_PROPERTIES = {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
MAPPING_SCHEMA = {
|
MAPPING_SCHEMA = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@ -72,6 +86,18 @@ MAPPING_SCHEMA = {
|
|||||||
},
|
},
|
||||||
"additionalProperties": False
|
"additionalProperties": False
|
||||||
},
|
},
|
||||||
|
"projects": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "roles"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"roles": ROLE_PROPERTIES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"group": {
|
"group": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
|
@ -742,9 +742,18 @@ class MappingRuleEngineTests(unit.BaseTestCase):
|
|||||||
self.assertEqual(expected_username, values['user']['name'])
|
self.assertEqual(expected_username, values['user']['name'])
|
||||||
|
|
||||||
expected_projects = [
|
expected_projects = [
|
||||||
{"name": "a"},
|
{
|
||||||
{"name": "b"},
|
"name": "Production",
|
||||||
{"name": "project for %s" % expected_username},
|
"roles": [{"name": "observer"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Staging",
|
||||||
|
"roles": [{"name": "member"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Project for %s" % expected_username,
|
||||||
|
"roles": [{"name": "admin"}]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
self.assertEqual(expected_projects, values['projects'])
|
self.assertEqual(expected_projects, values['projects'])
|
||||||
|
|
||||||
|
@ -1590,6 +1590,44 @@ MAPPING_UNICODE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MAPPING_PROJECTS = {
|
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": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"local": [
|
"local": [
|
||||||
@ -1600,7 +1638,7 @@ MAPPING_PROJECTS = {
|
|||||||
"projects": [
|
"projects": [
|
||||||
{"name": "a"},
|
{"name": "a"},
|
||||||
{"name": "b"},
|
{"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
@ -1700,6 +1700,58 @@ class MappingCRUDTests(test_v3.RestfulTestCase):
|
|||||||
self.put(url, expected_status=http_client.BAD_REQUEST,
|
self.put(url, expected_status=http_client.BAD_REQUEST,
|
||||||
body={'mapping': bad_mapping})
|
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):
|
class FederatedTokenTests(test_v3.RestfulTestCase, FederatedSetupMixin):
|
||||||
|
|
||||||
@ -3030,6 +3082,244 @@ class FederatedUserTests(test_v3.RestfulTestCase, FederatedSetupMixin):
|
|||||||
return token_resp['user']['id'], unscoped_token
|
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):
|
class JsonHomeTests(test_v3.RestfulTestCase, test_v3.JsonHomeTestMixin):
|
||||||
JSON_HOME_DATA = {
|
JSON_HOME_DATA = {
|
||||||
'http://docs.openstack.org/api/openstack-identity/3/ext/OS-FEDERATION/'
|
'http://docs.openstack.org/api/openstack-identity/3/ext/OS-FEDERATION/'
|
||||||
|
@ -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.
|
Loading…
Reference in New Issue
Block a user