#
#    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.

"""Keystone Client functionality for use by resources."""

import collections
import uuid
import weakref

from keystoneauth1 import exceptions as ks_exception
from keystoneauth1.identity import generic as ks_auth

from keystoneclient.v3 import client as kc_v3
from oslo_config import cfg
from oslo_log import log as logging
from oslo_serialization import jsonutils
from oslo_utils import importutils

from heat.common import context
from heat.common import exception
from heat.common.i18n import _
from heat.common import password_gen

LOG = logging.getLogger(__name__)

AccessKey = collections.namedtuple('AccessKey', ['id', 'access', 'secret'])

_default_keystone_backend = (
    'heat.engine.clients.os.keystone.heat_keystoneclient.KsClientWrapper')

keystone_opts = [
    cfg.StrOpt('keystone_backend',
               default=_default_keystone_backend,
               help=_("Fully qualified class name to use as a "
                      "keystone backend."))
]
cfg.CONF.register_opts(keystone_opts)


class KsClientWrapper(object):
    """Wrap keystone client so we can encapsulate logic used in resources.

    Note this is intended to be initialized from a resource on a per-session
    basis, so the session context is passed in on initialization
    Also note that an instance of this is created in each request context as
    part of a lazy-loaded cloud backend and it can be easily referenced in
    each resource as ``self.keystone()``, so there should not be any need to
    directly instantiate instances of this class inside resources themselves.
    """

    def __init__(self, context, region_name):
        # If a trust_id is specified in the context, we immediately
        # authenticate so we can populate the context with a trust token
        # otherwise, we delay client authentication until needed to avoid
        # unnecessary calls to keystone.
        #
        # Note that when you obtain a token using a trust, it cannot be
        # used to reauthenticate and get another token, so we have to
        # get a new trust-token even if context.auth_token is set.
        #
        # - context.auth_url is expected to contain a versioned keystone
        #   path, we will work with either a v2.0 or v3 path
        self._context = weakref.ref(context)
        self._client = None
        self._admin_auth = None
        self._domain_admin_auth = None
        self._domain_admin_client = None
        self._region_name = region_name

        self.session = self.context.keystone_session
        self.v3_endpoint = self.context.keystone_v3_endpoint

        if self.context.trust_id:
            # Create a client with the specified trust_id, this
            # populates self.context.auth_token with a trust-scoped token
            self._client = self._v3_client_init()

        # The stack domain user ID should be set in heat.conf
        # It can be created via python-openstackclient
        # openstack --os-identity-api-version=3 domain create heat
        # If the domain is specified, then you must specify a domain
        # admin user.  If no domain is specified, we fall back to
        # legacy behavior with warnings.
        self._stack_domain_id = cfg.CONF.stack_user_domain_id
        self.stack_domain_name = cfg.CONF.stack_user_domain_name
        self.domain_admin_user = cfg.CONF.stack_domain_admin
        self.domain_admin_password = cfg.CONF.stack_domain_admin_password

        LOG.debug('Using stack domain %s', self.stack_domain)

    @property
    def context(self):
        ctxt = self._context()
        assert ctxt is not None, "Need a reference to the context"
        return ctxt

    @property
    def stack_domain(self):
        """Domain scope data.

        This is only used for checking for scoping data, not using the value.
        """
        return self._stack_domain_id or self.stack_domain_name

    @property
    def client(self):
        if not self._client:
            # Create connection to v3 API
            self._client = self._v3_client_init()
        return self._client

    @property
    def auth_region_name(self):
        importutils.import_module('keystonemiddleware.auth_token')
        auth_region = cfg.CONF.keystone_authtoken.region_name
        if not auth_region:
            auth_region = self._region_name
        return auth_region

    @property
    def domain_admin_auth(self):
        if not self._domain_admin_auth:
            # Note we must specify the domain when getting the token
            # as only a domain scoped token can create projects in the domain
            auth = ks_auth.Password(username=self.domain_admin_user,
                                    password=self.domain_admin_password,
                                    auth_url=self.v3_endpoint,
                                    domain_id=self._stack_domain_id,
                                    domain_name=self.stack_domain_name,
                                    user_domain_id=self._stack_domain_id,
                                    user_domain_name=self.stack_domain_name)

            # NOTE(jamielennox): just do something to ensure a valid token
            try:
                auth.get_token(self.session)
            except ks_exception.Unauthorized:
                LOG.error("Domain admin client authentication failed")
                raise exception.AuthorizationFailure()

            self._domain_admin_auth = auth

        return self._domain_admin_auth

    @property
    def domain_admin_client(self):
        if not self._domain_admin_client:
            self._domain_admin_client = kc_v3.Client(
                session=self.session,
                auth=self.domain_admin_auth,
                connect_retries=cfg.CONF.client_retry_limit,
                region_name=self.auth_region_name)

        return self._domain_admin_client

    def _v3_client_init(self):
        client = kc_v3.Client(session=self.session,
                              connect_retries=cfg.CONF.client_retry_limit,
                              region_name=self.auth_region_name)

        if hasattr(self.context.auth_plugin, 'get_access'):
            # NOTE(jamielennox): get_access returns the current token without
            # reauthenticating if it's present and valid.
            try:
                auth_ref = self.context.auth_plugin.get_access(self.session)
            except ks_exception.Unauthorized:
                LOG.error("Keystone client authentication failed")
                raise exception.AuthorizationFailure()

            if self.context.trust_id:
                # Sanity check
                if not auth_ref.trust_scoped:
                    LOG.error("trust token re-scoping failed!")
                    raise exception.AuthorizationFailure()
                # Sanity check that impersonation is effective
                if self.context.trustor_user_id != auth_ref.user_id:
                    LOG.error("Trust impersonation failed")
                    raise exception.AuthorizationFailure()

        return client

    def _create_trust_context(self, trustor_user_id, trustor_proj_id):
        # We need the service admin user ID (not name), as the trustor user
        # can't lookup the ID in keystoneclient unless they're admin
        # workaround this by getting the user_id from admin_client
        try:
            trustee_user_id = self.context.trusts_auth_plugin.get_user_id(
                self.session)
        except ks_exception.Unauthorized:
            LOG.error("Domain admin client authentication failed")
            raise exception.AuthorizationFailure()

        role_kw = {}
        # inherit the roles of the trustor, unless set trusts_delegated_roles
        if cfg.CONF.trusts_delegated_roles:
            role_kw['role_names'] = cfg.CONF.trusts_delegated_roles
        else:
            token_info = self.context.auth_token_info
            if token_info and token_info.get('token', {}).get('roles'):
                role_kw['role_ids'] = [r['id'] for r in
                                       token_info['token']['roles']]
            else:
                role_kw['role_names'] = self.context.roles
        allow_redelegation = (cfg.CONF.reauthentication_auth_method == 'trusts'
                              and cfg.CONF.allow_trusts_redelegation)
        try:
            trust = self.client.trusts.create(
                trustor_user=trustor_user_id, trustee_user=trustee_user_id,
                project=trustor_proj_id, impersonation=True,
                allow_redelegation=allow_redelegation, **role_kw)
        except ks_exception.NotFound:
            LOG.debug("Failed to find roles %s for user %s"
                      % (role_kw, trustor_user_id))
            raise exception.MissingCredentialError(
                required=_("roles %s") % role_kw)

        context_data = self.context.to_dict()
        context_data['overwrite'] = False
        trust_context = context.RequestContext.from_dict(context_data)
        trust_context.trust_id = trust.id
        trust_context.trustor_user_id = trustor_user_id
        return trust_context

    def create_trust_context(self):
        """Create a trust using the trustor identity in the current context.

        The trust is created with the trustee as the heat service user.

        If the current context already contains a trust_id, we do nothing
        and return the current context.

        Returns a context containing the new trust_id.
        """
        if self.context.trust_id:
            return self.context

        trustor_user_id = self.context.auth_plugin.get_user_id(self.session)
        trustor_proj_id = self.context.auth_plugin.get_project_id(self.session)
        return self._create_trust_context(trustor_user_id, trustor_proj_id)

    def delete_trust(self, trust_id):
        """Delete the specified trust."""
        try:
            self.client.trusts.delete(trust_id)
        except (ks_exception.NotFound, ks_exception.Unauthorized):
            pass

    def regenerate_trust_context(self):
        """Regenerate a trust using the trustor identity of current user_id.

        The trust is created with the trustee as the heat service user.

        Returns a context containing the new trust_id.
        """
        old_trust_id = self.context.trust_id
        trustor_user_id = self.context.auth_plugin.get_user_id(self.session)
        trustor_proj_id = self.context.auth_plugin.get_project_id(self.session)
        trust_context = self._create_trust_context(trustor_user_id,
                                                   trustor_proj_id)

        if old_trust_id:
            self.delete_trust(old_trust_id)
        return trust_context

    def _get_username(self, username):
        if(len(username) > 255):
            LOG.warning("Truncating the username %s to the last 255 "
                        "characters.", username)
        # get the last 255 characters of the username
        return username[-255:]

    def create_stack_user(self, username, password=''):
        """Create a user defined as part of a stack.

        The user is defined either via template or created internally by a
        resource.  This user will be added to the heat_stack_user_role as
        defined in the config.

        Returns the keystone ID of the resulting user.
        """
        # FIXME(shardy): There's duplicated logic between here and
        # create_stack_domain user, but this function is expected to
        # be removed after the transition of all resources to domain
        # users has been completed
        stack_user_role = self.client.roles.list(
            name=cfg.CONF.heat_stack_user_role)
        if len(stack_user_role) == 1:
            role_id = stack_user_role[0].id
            # Create the user
            user = self.client.users.create(
                name=self._get_username(username), password=password,
                default_project=self.context.tenant_id)
            # Add user to heat_stack_user_role
            LOG.debug("Adding user %(user)s to role %(role)s",
                      {'user': user.id, 'role': role_id})
            self.client.roles.grant(role=role_id, user=user.id,
                                    project=self.context.tenant_id)
        else:
            LOG.error("Failed to add user %(user)s to role %(role)s, "
                      "check role exists!",
                      {'user': username,
                       'role': cfg.CONF.heat_stack_user_role})
            raise exception.Error(_("Can't find role %s")
                                  % cfg.CONF.heat_stack_user_role)

        return user.id

    def stack_domain_user_token(self, user_id, project_id, password):
        """Get a token for a stack domain user."""
        if not self.stack_domain:
            # Note, no legacy fallback path as we don't want to deploy
            # tokens for non stack-domain users inside instances
            msg = _('Cannot get stack domain user token, no stack domain id '
                    'configured, please fix your heat.conf')
            raise exception.Error(msg)

        # Create a keystone session, then request a token with no
        # catalog (the token is expected to be used inside an instance
        # where a specific endpoint will be specified, and user-data
        # space is limited..)
        # TODO(rabi): generic auth plugins don't support `include_catalog'
        # flag yet. We'll add it once it's supported..
        auth = ks_auth.Password(auth_url=self.v3_endpoint,
                                user_id=user_id,
                                password=password,
                                project_id=project_id)

        return auth.get_token(self.session)

    def create_stack_domain_user(self, username, project_id, password=None):
        """Create a domain user defined as part of a stack.

        The user is defined either via template or created internally by a
        resource.  This user will be added to the heat_stack_user_role as
        defined in the config, and created in the specified project (which is
        expected to be in the stack_domain).

        Returns the keystone ID of the resulting user.
        """
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return self.create_stack_user(username=username, password=password)
        # We are creating automated user, for which most of security
        # compliance restrictions possibly set in Keystone should not apply,
        # https://docs.openstack.org/keystone/latest/admin/security-compliance.html
        # TODO(pas-ha) find a way to deal with password_regex and
        # disable_user_account_days_inactive
        # TODO(pas-ha) think if we also need to add lock_password too
        user_options = {
            "ignore_change_password_upon_first_use": True,
            "ignore_password_expiry": True,
            "ignore_lockout_failure_attempts": True
        }
        # We add the new user to a special keystone role
        # This role is designed to allow easier differentiation of the
        # heat-generated "stack users" which will generally have credentials
        # deployed on an instance (hence are implicitly untrusted)
        stack_user_role = self.domain_admin_client.roles.list(
            name=cfg.CONF.heat_stack_user_role)
        if len(stack_user_role) == 1:
            role_id = stack_user_role[0].id
            # Create user
            user = self.domain_admin_client.users.create(
                name=self._get_username(username), password=password,
                default_project=project_id, domain=self.stack_domain_id,
                options=user_options)
            # Add to stack user role
            LOG.debug("Adding user %(user)s to role %(role)s",
                      {'user': user.id, 'role': role_id})
            self.domain_admin_client.roles.grant(role=role_id, user=user.id,
                                                 project=project_id)
        else:
            LOG.error("Failed to add user %(user)s to role %(role)s, "
                      "check role exists!",
                      {'user': username,
                       'role': cfg.CONF.heat_stack_user_role})
            raise exception.Error(_("Can't find role %s")
                                  % cfg.CONF.heat_stack_user_role)

        return user.id

    @property
    def stack_domain_id(self):
        if not self._stack_domain_id:
            try:
                access = self.domain_admin_auth.get_access(self.session)
            except ks_exception.Unauthorized:
                LOG.error("Keystone client authentication failed")
                raise exception.AuthorizationFailure()

            self._stack_domain_id = access.domain_id

        return self._stack_domain_id

    def _check_stack_domain_user(self, user_id, project_id, action):
        """Sanity check that domain/project is correct."""
        user = self.domain_admin_client.users.get(user_id)

        if user.domain_id != self.stack_domain_id:
            raise ValueError(_('User %s in invalid domain') % action)
        if user.default_project_id != project_id:
            raise ValueError(_('User %s in invalid project') % action)

    def delete_stack_domain_user(self, user_id, project_id):
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return self.delete_stack_user(user_id)

        try:
            self._check_stack_domain_user(user_id, project_id, 'delete')
            self.domain_admin_client.users.delete(user_id)
        except ks_exception.NotFound:
            pass

    def delete_stack_user(self, user_id):
        try:
            self.client.users.delete(user=user_id)
        except ks_exception.NotFound:
            pass

    def create_stack_domain_project(self, stack_id):
        """Create a project in the heat stack-user domain."""
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return self.context.tenant_id
        # Note we use the tenant ID not name to ensure uniqueness in a multi-
        # domain environment (where the tenant name may not be globally unique)
        project_name = ('%s-%s' % (self.context.tenant_id, stack_id))[:64]
        desc = "Heat stack user project"
        domain_project = self.domain_admin_client.projects.create(
            name=project_name,
            domain=self.stack_domain_id,
            description=desc)
        return domain_project.id

    def delete_stack_domain_project(self, project_id):
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return

        # If stacks are created before configuring the heat domain, they
        # exist in the default domain, in the user's project, which we
        # do *not* want to delete!  However, if the keystone v3cloudsample
        # policy is used, it's possible that we'll get Forbidden when trying
        # to get the project, so again we should do nothing
        try:
            project = self.domain_admin_client.projects.get(project=project_id)
        except ks_exception.NotFound:
            return
        except ks_exception.Forbidden:
            LOG.warning('Unable to get details for project %s, '
                        'not deleting', project_id)
            return

        if project.domain_id != self.stack_domain_id:
            LOG.warning('Not deleting non heat-domain project')
            return

        try:
            project.delete()
        except ks_exception.NotFound:
            pass

    def _find_ec2_keypair(self, access, user_id=None):
        """Lookup an ec2 keypair by access ID."""
        # FIXME(shardy): add filtering for user_id when keystoneclient
        # extensible-crud-manager-operations bp lands
        credentials = self.client.credentials.list()
        for cr in credentials:
            ec2_creds = jsonutils.loads(cr.blob)
            if ec2_creds.get('access') == access:
                return AccessKey(id=cr.id,
                                 access=ec2_creds['access'],
                                 secret=ec2_creds['secret'])

    def delete_ec2_keypair(self, credential_id=None, access=None,
                           user_id=None):
        """Delete credential containing ec2 keypair."""
        if credential_id:
            try:
                self.client.credentials.delete(credential_id)
            except ks_exception.NotFound:
                pass
        elif access:
            cred = self._find_ec2_keypair(access=access, user_id=user_id)
            if cred:
                self.client.credentials.delete(cred.id)
        else:
            raise ValueError("Must specify either credential_id or access")

    def get_ec2_keypair(self, credential_id=None, access=None, user_id=None):
        """Get an ec2 keypair via v3/credentials, by id or access."""
        # Note v3/credentials does not support filtering by access
        # because it's stored in the credential blob, so we expect
        # all resources to pass credential_id except where backwards
        # compatibility is required (resource only has access stored)
        # then we'll have to do a brute-force lookup locally
        if credential_id:
            cred = self.client.credentials.get(credential_id)
            ec2_creds = jsonutils.loads(cred.blob)
            return AccessKey(id=cred.id,
                             access=ec2_creds['access'],
                             secret=ec2_creds['secret'])
        elif access:
            return self._find_ec2_keypair(access=access, user_id=user_id)
        else:
            raise ValueError("Must specify either credential_id or access")

    def create_ec2_keypair(self, user_id=None):
        user_id = user_id or self.context.get_access(self.session).user_id
        project_id = self.context.tenant_id
        data_blob = {'access': uuid.uuid4().hex,
                     'secret': password_gen.generate_openstack_password()}
        ec2_creds = self.client.credentials.create(
            user=user_id, type='ec2', blob=jsonutils.dumps(data_blob),
            project=project_id)

        # Return a AccessKey namedtuple for easier access to the blob contents
        # We return the id as the v3 api provides no way to filter by
        # access in the blob contents, so it will be much more efficient
        # if we manage credentials by ID instead
        return AccessKey(id=ec2_creds.id,
                         access=data_blob['access'],
                         secret=data_blob['secret'])

    def create_stack_domain_user_keypair(self, user_id, project_id):
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return self.create_ec2_keypair(user_id)
        data_blob = {'access': uuid.uuid4().hex,
                     'secret': password_gen.generate_openstack_password()}
        creds = self.domain_admin_client.credentials.create(
            user=user_id, type='ec2', blob=jsonutils.dumps(data_blob),
            project=project_id)
        return AccessKey(id=creds.id,
                         access=data_blob['access'],
                         secret=data_blob['secret'])

    def delete_stack_domain_user_keypair(self, user_id, project_id,
                                         credential_id):
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return self.delete_ec2_keypair(credential_id=credential_id)
        self._check_stack_domain_user(user_id, project_id, 'delete_keypair')
        try:
            self.domain_admin_client.credentials.delete(credential_id)
        except ks_exception.NotFound:
            pass

    def disable_stack_user(self, user_id):
        self.client.users.update(user=user_id, enabled=False)

    def enable_stack_user(self, user_id):
        self.client.users.update(user=user_id, enabled=True)

    def disable_stack_domain_user(self, user_id, project_id):
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return self.disable_stack_user(user_id)
        self._check_stack_domain_user(user_id, project_id, 'disable')
        self.domain_admin_client.users.update(user=user_id, enabled=False)

    def enable_stack_domain_user(self, user_id, project_id):
        if not self.stack_domain:
            # FIXME(shardy): Legacy fallback for folks using old heat.conf
            # files which lack domain configuration
            return self.enable_stack_user(user_id)
        self._check_stack_domain_user(user_id, project_id, 'enable')
        self.domain_admin_client.users.update(user=user_id, enabled=True)

    def server_keystone_endpoint_url(self, fallback_endpoint):
        ks_endpoint_type = cfg.CONF.server_keystone_endpoint_type
        if ((ks_endpoint_type == 'public') or (
            ks_endpoint_type == 'internal') or
                (ks_endpoint_type == 'admin')):
            if (hasattr(self.context, 'auth_plugin') and
                    hasattr(self.context.auth_plugin, 'get_access')):
                try:
                    auth_ref = self.context.auth_plugin.get_access(
                        self.session)
                    if hasattr(auth_ref, "service_catalog"):
                        unversioned_sc_auth_uri = (
                            auth_ref.service_catalog.get_urls(
                                service_type='identity',
                                interface=ks_endpoint_type))
                        if len(unversioned_sc_auth_uri) > 0:
                            sc_auth_uri = (
                                unversioned_sc_auth_uri[0] + "/v3")
                            return sc_auth_uri
                except ks_exception.Unauthorized:
                    LOG.error("Keystone client authentication failed")
        return fallback_endpoint


class KeystoneClient(object):
    """Keystone Auth Client.

    Delay choosing the backend client module until the client's class
    needs to be initialized.
    """

    def __new__(cls, context, region_name=None):
        if cfg.CONF.keystone_backend == _default_keystone_backend:
            return KsClientWrapper(context, region_name)
        else:
            return importutils.import_object(
                cfg.CONF.keystone_backend,
                context
            )


def list_opts():
    yield None, keystone_opts
