# 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


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)

    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
        )


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
    # Preserve the original token's expiry to prevent users from
    # indefinitely extending their session by repeatedly rescoping.
    # The non-federated path in token.py does the same via setdefault().
    response_data['expires_at'] = token.expires_at

    return response_data


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'],
    )


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,
    )


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


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


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


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']
        )


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
    )


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,
                )


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'."
        )


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


def extract_assertion_data():
    assertion = dict(utils.get_assertion_params_from_env())
    return assertion


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


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'],
    )
