Source code for keystone.auth.plugins.mapped

# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

import functools
from urllib import parse
import uuid

import flask
from oslo_log import log
from pycadf import cadftaxonomy as taxonomy

from keystone.auth import plugins as auth_plugins
from keystone.auth.plugins import base
from keystone.common import provider_api
from keystone import exception
from keystone.federation import constants as federation_constants
from keystone.federation import utils
from keystone.i18n import _
from keystone import notifications

LOG = log.getLogger(__name__)

METHOD_NAME = 'mapped'
PROVIDERS = provider_api.ProviderAPIs


[docs] class Mapped(base.AuthMethodHandler): def _get_token_ref(self, auth_payload): token_id = auth_payload['id'] return PROVIDERS.token_provider_api.validate_token(token_id)
[docs] def authenticate(self, auth_payload): """Authenticate mapped user and set an authentication context. :param auth_payload: the content of the authentication for a given method In addition to ``user_id`` in ``response_data``, this plugin sets ``group_ids``, ``OS-FEDERATION:identity_provider`` and ``OS-FEDERATION:protocol`` """ if 'id' in auth_payload: token_ref = self._get_token_ref(auth_payload) response_data = handle_scoped_token( token_ref, PROVIDERS.federation_api, PROVIDERS.identity_api ) else: response_data = handle_unscoped_token( auth_payload, PROVIDERS.resource_api, PROVIDERS.federation_api, PROVIDERS.identity_api, PROVIDERS.assignment_api, PROVIDERS.role_api, ) return base.AuthHandlerResponse( status=True, response_body=None, response_data=response_data )
[docs] def handle_scoped_token(token, federation_api, identity_api): response_data = {} utils.validate_expiration(token) token_audit_id = token.audit_id identity_provider = token.identity_provider_id protocol = token.protocol_id user_id = token.user_id group_ids = [] for group_dict in token.federated_groups: group_ids.append(group_dict['id']) send_notification = functools.partial( notifications.send_saml_audit_notification, 'authenticate', user_id, group_ids, identity_provider, protocol, token_audit_id, ) utils.assert_enabled_identity_provider(federation_api, identity_provider) try: mapping = federation_api.get_mapping_from_idp_and_protocol( identity_provider, protocol ) utils.validate_mapped_group_ids(group_ids, mapping['id'], identity_api) except Exception: # NOTE(topol): Diaper defense to catch any exception, so we can # send off failed authentication notification, raise the exception # after sending the notification send_notification(taxonomy.OUTCOME_FAILURE) raise else: send_notification(taxonomy.OUTCOME_SUCCESS) response_data['user_id'] = user_id response_data['group_ids'] = group_ids response_data[federation_constants.IDENTITY_PROVIDER] = identity_provider response_data[federation_constants.PROTOCOL] = protocol return response_data
[docs] def configure_project_domain(shadow_project, idp_domain_id, resource_api): """Configure federated projects domain. We set the domain to be the default (idp_domain_id) if the project from the attribute mapping comes without a domain. """ LOG.debug('Processing domain for project: %s', shadow_project) domain = shadow_project.get('domain', {"id": idp_domain_id}) if 'id' not in domain: db_domain = resource_api.get_domain_by_name(domain['name']) domain = {"id": db_domain.get('id')} shadow_project['domain'] = domain LOG.debug( 'Project [%s] domain ID was resolved to [%s]', shadow_project['name'], shadow_project['domain']['id'], )
[docs] def configure_federated_projects( shadow_projects, idp_domain_id, existing_roles, user, assignment_api, resource_api, schema_version, ): for shadow_project in shadow_projects: configure_project_domain(shadow_project, idp_domain_id, resource_api) handle_projects_from_mapping( shadow_projects, existing_roles, user, schema_version, assignment_api, resource_api, )
[docs] def retrieve_all_project_assignments(user, assignment_api): user_assignments = assignment_api.list_role_assignments(user_id=user['id']) assignments_by_projects = {} for user_assignment in user_assignments: # `project_id` only exist in project-role assingmnets. user_assignment_project = user_assignment.get('project_id') if not user_assignment_project: continue project_assignments_for_user = assignments_by_projects.get( user_assignment_project, [] ) project_assignments_for_user.append(user_assignment) assignments_by_projects[user_assignment_project] = ( project_assignments_for_user ) return assignments_by_projects
[docs] def create_or_show_project(shadow_project, user, resource_api): project_domain = shadow_project['domain'] try: # Check to 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'], project_domain['id'] ) except exception.ProjectNotFound: LOG.debug('Project being created: %s', shadow_project) LOG.info( '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': project_domain['id'], } project = resource_api.create_project(project_ref['id'], project_ref) return project
[docs] def should_sync_project_assignment_with_idp(schema_version): minimum_version_to_sync_project_assignments_state = float( utils.SCHEMA_VERSION_SYNC_PROJECT_ASSIGNMENTS_WITH_IDP_STATE ) version = float(schema_version) return version >= minimum_version_to_sync_project_assignments_state
[docs] def create_new_grants(all_roles, project, user, assignment_api): for role in all_roles: assignment_api.create_grant( role, user_id=user['id'], project_id=project['id'] )
[docs] def update_assignments( idp_user_project_shadow_role_ids, project, user, schema_version, current_user_assignments, assignment_api, ): for assignment in current_user_assignments: # If the role is already in the assignment for this user # and project, we can ignore it. if assignment['role_id'] in idp_user_project_shadow_role_ids: LOG.debug( "Role [%s] is already assigned for user [" "%s] and project [%s].", assignment['role_id'], user, project, ) idp_user_project_shadow_role_ids.remove(assignment['role_id']) else: if not should_sync_project_assignment_with_idp(schema_version): LOG.debug( "Role [%s] from the user [%s] in project [%s] is " "removed from the IdP, but the user still has the " "permission in OpenStack. One should consider " "migrating to attribute mapping schema version " "'3.0'.", assignment['role_id'], user, project, ) continue LOG.debug( "Role [%s] was removed from the user [%s] in project [%s].", assignment['role_id'], user, project, ) assignment_api.delete_grant( assignment['role_id'], user_id=user['id'], project_id=project['id'], ) # After removing the roles from the user, and skipping the # already assigned ones, we register the new ones create_new_grants( idp_user_project_shadow_role_ids, project, user, assignment_api )
[docs] def remove_left_over_project_assignments( all_projects_ids_processed, project_assignments_for_user, shadow_projects, user, assignment_api, ): for project_id in project_assignments_for_user.keys(): if project_id not in all_projects_ids_processed: LOG.debug( "The project [%s] was not in the set of attributes" " [%s] that configure user access to projects from" " the IdP. Therefore, we will remove all user [%s]" " permissions to it.", project_id, shadow_projects, user, ) assignments = project_assignments_for_user[project_id] for assignment in assignments: LOG.debug( "Removing role [%s] from project [%s] and user [%s].", assignment['role_id'], project_id, user, ) assignment_api.delete_grant( assignment['role_id'], user_id=user['id'], project_id=project_id, )
[docs] def handle_projects_from_mapping( shadow_projects, existing_roles, user, schema_version, assignment_api, resource_api, ): project_assignments_for_user = retrieve_all_project_assignments( user, assignment_api ) LOG.debug( "Current project assignments [%s] for user [%s].", project_assignments_for_user, user, ) all_projects_ids_processed = set() for shadow_project in shadow_projects: project = create_or_show_project(shadow_project, user, resource_api) all_projects_ids_processed.add(project['id']) all_project_shadow_role_ids = [] for shadow_role in shadow_project['roles']: all_project_shadow_role_ids.append( existing_roles[shadow_role['name']]['id'] ) # Assignments that the user has in the project user_assignments = project_assignments_for_user.get(project['id'], []) # If user does not have assignment yet for the project. # Therefore, we can create all of them. if user_assignments: update_assignments( all_project_shadow_role_ids, project, user, schema_version, user_assignments, assignment_api, ) else: create_new_grants( all_project_shadow_role_ids, project, user, assignment_api ) LOG.debug( "Projects [%s] assigned to user [%s].", all_projects_ids_processed, user, ) LOG.debug( "All projects [%s] the user [%s] had before this login.", project_assignments_for_user.keys(), user, ) if should_sync_project_assignment_with_idp(schema_version): remove_left_over_project_assignments( all_projects_ids_processed, project_assignments_for_user, shadow_projects, user, assignment_api, ) else: LOG.debug( "We are not going to remove left overs project " "assignments because the attribute mapping is " "using a schema version lower than '3.0'." )
[docs] def handle_unscoped_token( auth_payload, resource_api, federation_api, identity_api, assignment_api, role_api, ): def validate_shadow_mapping( shadow_projects, existing_roles, user_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( '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'] != user_domain_id ): LOG.error( 'Role %(role)s is a domain-specific role and ' 'cannot be assigned within %(domain)s.', { 'role': shadow_role['name'], 'domain': user_domain_id, }, ) raise exception.DomainSpecificRoleNotWithinIdPDomain( role_name=shadow_role['name'], identity_provider=idp_id ) def is_ephemeral_user(mapped_properties): return mapped_properties['user']['type'] == utils.UserType.EPHEMERAL def build_ephemeral_user_context( user, mapped_properties, identity_provider, protocol ): resp = {} resp['user_id'] = user['id'] resp['group_ids'] = mapped_properties['group_ids'] resp[federation_constants.IDENTITY_PROVIDER] = identity_provider resp[federation_constants.PROTOCOL] = protocol return resp def build_local_user_context(mapped_properties): resp = {} user_info = auth_plugins.UserAuthInfo.create( mapped_properties, METHOD_NAME ) resp['user_id'] = user_info.user_id return resp assertion = extract_assertion_data() try: identity_provider = auth_payload['identity_provider'] except KeyError: raise exception.ValidationError( attribute='identity_provider', target='mapped' ) try: protocol = auth_payload['protocol'] except KeyError: raise exception.ValidationError(attribute='protocol', target='mapped') utils.assert_enabled_identity_provider(federation_api, identity_provider) group_ids = None # NOTE(topol): The user is coming in from an IdP with a SAML assertion # instead of from a token, so we set token_id to None token_id = None # NOTE(marek-denis): This variable is set to None and there is a # possibility that it will be used in the CADF notification. This means # operation will not be mapped to any user (even ephemeral). user_id = None try: try: mapped_properties, mapping_id = apply_mapping_filter( identity_provider, protocol, assertion, resource_api, federation_api, identity_api, ) except exception.ValidationError as e: # if mapping is either invalid or yield no valid identity, # it is considered a failed authentication raise exception.Unauthorized(e) if is_ephemeral_user(mapped_properties): idp_domain_id = federation_api.get_idp(identity_provider)[ 'domain_id' ] validate_and_prepare_federated_user( mapped_properties, idp_domain_id, resource_api ) user = identity_api.shadow_federated_user( identity_provider, protocol, mapped_properties['user'], group_ids=mapped_properties['group_ids'], ) if 'projects' in mapped_properties: 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, mapped_properties['user']['domain']['id'], identity_provider, ) configure_federated_projects( mapped_properties['projects'], idp_domain_id, existing_roles, user, assignment_api, resource_api, mapped_properties['schema_version'], ) user_id = user['id'] group_ids = mapped_properties['group_ids'] response_data = build_ephemeral_user_context( user, mapped_properties, identity_provider, protocol ) else: response_data = build_local_user_context(mapped_properties) except Exception: # NOTE(topol): Diaper defense to catch any exception, so we can # send off failed authentication notification, raise the exception # after sending the notification outcome = taxonomy.OUTCOME_FAILURE notifications.send_saml_audit_notification( 'authenticate', user_id, group_ids, identity_provider, protocol, token_id, outcome, ) raise else: outcome = taxonomy.OUTCOME_SUCCESS notifications.send_saml_audit_notification( 'authenticate', user_id, group_ids, identity_provider, protocol, token_id, outcome, ) return response_data
[docs] def extract_assertion_data(): assertion = dict(utils.get_assertion_params_from_env()) return assertion
[docs] def apply_mapping_filter( identity_provider, protocol, assertion, resource_api, federation_api, identity_api, ): idp = federation_api.get_idp(identity_provider) utils.validate_idp(idp, protocol, assertion) mapped_properties, mapping_id = federation_api.evaluate( identity_provider, protocol, assertion ) # NOTE(marek-denis): We update group_ids only here to avoid fetching # groups identified by name/domain twice. # NOTE(marek-denis): Groups are translated from name/domain to their # corresponding ids in the auth plugin, as we need information what # ``mapping_id`` was used as well as idenity_api and resource_api # objects. group_ids = mapped_properties['group_ids'] utils.validate_mapped_group_ids(group_ids, mapping_id, identity_api) group_ids.extend( utils.transform_to_group_ids( mapped_properties['group_names'], mapping_id, identity_api, resource_api, ) ) mapped_properties['group_ids'] = list(set(group_ids)) return mapped_properties, mapping_id
[docs] def validate_and_prepare_federated_user( mapped_properties, idp_domain_id, resource_api ): """Setup federated username. Function covers all the cases for properly setting user id, a primary identifier for identity objects. Initial version of the mapping engine assumed user is identified by ``name`` and his ``id`` is built from the name. We, however need to be able to accept local rules that identify user by either id or name/domain. The following use-cases are covered: 1) If neither user_name nor user_id is set raise exception.Unauthorized 2) If user_id is set and user_name not, set user_name equal to user_id 3) If user_id is not set and user_name is, set user_id as url safe version of user_name. Furthermore, we set the IdP as the user domain, if the user definition does not come with a domain definition. :param mapped_properties: Properties issued by a RuleProcessor. :type: dictionary :param idp_domain_id: The domain ID of the IdP registered in OpenStack. :type: string :param resource_api: The resource API used to access the database layer. :type: object :raises keystone.exception.Unauthorized: If neither `user_name` nor `user_id` is set. :returns: tuple with user identification :rtype: tuple """ user = mapped_properties['user'] user_id = user.get('id') user_name = user.get('name') or flask.request.remote_user if not any([user_id, user_name]): msg = _( "Could not map user while setting ephemeral user identity. " "Either mapping rules must specify user id/name or " "REMOTE_USER environment variable must be set." ) raise exception.Unauthorized(msg) elif not user_name: user['name'] = user_id elif not user_id: user_id = user_name if user_name: user['name'] = user_name user['id'] = parse.quote(user_id) LOG.debug('Processing domain for federated user: %s', user) domain = user.get('domain', {"id": idp_domain_id}) if 'id' not in domain: db_domain = resource_api.get_domain_by_name(domain['name']) domain = {"id": db_domain.get('id')} user['domain'] = domain LOG.debug( 'User [%s] domain ID was resolved to [%s]', user['name'], user['domain']['id'], )