Browse Source

Refactor master identity provider configuration

- Remote template in favor of a filter plugin
- Add additional validation for identity provider config
- Add mappingMethod attribute for identity providers, default to 'claim'
Jason DeTiberus 9 years ago
parent
commit
3cbe7df846

+ 469 - 0
filter_plugins/openshift_master.py

@@ -0,0 +1,469 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# vim: expandtab:tabstop=4:shiftwidth=4
+'''
+Custom filters for use in openshift-master
+'''
+import copy
+import sys
+import yaml
+
+from ansible import errors
+from ansible.runner.filter_plugins.core import bool as ansible_bool
+
+
+class IdentityProviderBase(object):
+    """ IdentityProviderBase
+
+        Attributes:
+            name (str): Identity provider Name
+            login (bool): Is this identity provider a login provider?
+            challenge (bool): Is this identity provider a challenge provider?
+            provider (dict): Provider specific config
+            _idp (dict): internal copy of the IDP dict passed in
+            _required (list): List of lists of strings for required attributes
+            _optional (list): List of lists of strings for optional attributes
+            _allow_additional (bool): Does this provider support attributes
+                not in _required and _optional
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    # disabling this check since the number of instance attributes are
+    # necessary for this class
+    # pylint: disable=too-many-instance-attributes
+    def __init__(self, api_version, idp):
+        if api_version not in ['v1']:
+            raise errors.AnsibleFilterError("|failed api version {0} unknown".format(api_version))
+
+        self._idp = copy.deepcopy(idp)
+
+        if 'name' not in self._idp:
+            raise errors.AnsibleFilterError("|failed identity provider missing a name")
+
+        if 'kind' not in self._idp:
+            raise errors.AnsibleFilterError("|failed identity provider missing a kind")
+
+        self.name = self._idp.pop('name')
+        self.login = ansible_bool(self._idp.pop('login', False))
+        self.challenge = ansible_bool(self._idp.pop('challenge', False))
+        self.provider = dict(apiVersion=api_version, kind=self._idp.pop('kind'))
+
+        self._required = [['mappingMethod', 'mapping_method']]
+        self._optional = []
+        self._allow_additional = True
+
+    @staticmethod
+    def validate_idp_list(idp_list):
+        ''' validates a list of idps '''
+        login_providers = [x.name for x in idp_list if x.login]
+        if len(login_providers) > 1:
+            raise errors.AnsibleFilterError("|failed multiple providers are "
+                                            "not allowed for login. login "
+                                            "providers: {0}".format(', '.join(login_providers)))
+
+        names = [x.name for x in idp_list]
+        if len(set(names)) != len(names):
+            raise errors.AnsibleFilterError("|failed more than one provider configured with the same name")
+
+        for idp in idp_list:
+            idp.validate()
+
+    def validate(self):
+        ''' validate an instance of this idp class '''
+        valid_mapping_methods = ['add', 'claim', 'generate', 'lookup']
+        if self.provider['mappingMethod'] not in valid_mapping_methods:
+            raise errors.AnsibleFilterError("|failed unkown mapping method "
+                                            "for provider {0}".format(self.__class__.__name__))
+
+    @staticmethod
+    def get_default(key):
+        ''' get a default value for a given key '''
+        if key == 'mappingMethod':
+            return 'claim'
+        else:
+            return None
+
+    def set_provider_item(self, items, required=False):
+        ''' set a provider item based on the list of item names provided. '''
+        for item in items:
+            provider_key = items[0]
+            if item in self._idp:
+                self.provider[provider_key] = self._idp.pop(item)
+                break
+        else:
+            default = self.get_default(provider_key)
+            if default is not None:
+                self.provider[provider_key] = default
+            elif required:
+                raise errors.AnsibleFilterError("|failed provider {0} missing "
+                                                "required key {1}".format(self.__class__.__name__, provider_key))
+
+    def set_provider_items(self):
+        ''' set the provider items for this idp '''
+        for items in self._required:
+            self.set_provider_item(items, True)
+        for items in self._optional:
+            self.set_provider_item(items)
+        if self._allow_additional:
+            for key in self._idp.keys():
+                self.set_provider_item([key])
+        else:
+            if len(self._idp) > 0:
+                raise errors.AnsibleFilterError("|failed provider {0} "
+                                                "contains unknown keys "
+                                                "{1}".format(self.__class__.__name__, ', '.join(self._idp.keys())))
+
+    def to_dict(self):
+        ''' translate this idp to a dictionary '''
+        return dict(name=self.name, challenge=self.challenge,
+                    login=self.login, provider=self.provider)
+
+
+class LDAPPasswordIdentityProvider(IdentityProviderBase):
+    """ LDAPPasswordIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+        self._required += [['attributes'], ['url'], ['insecure']]
+        self._optional += [['ca'],
+                           ['bindDN', 'bind_dn'],
+                           ['bindPassword', 'bind_password']]
+
+        self._idp['insecure'] = ansible_bool(self._idp.pop('insecure', False))
+
+        if 'attributes' in self._idp and 'preferred_username' in self._idp['attributes']:
+            pref_user = self._idp['attributes'].pop('preferred_username')
+            self._idp['attributes']['preferredUsername'] = pref_user
+
+    def validate(self):
+        ''' validate this idp instance '''
+        IdentityProviderBase.validate(self)
+        if not isinstance(self.provider['attributes'], dict):
+            raise errors.AnsibleFilterError("|failed attributes for provider "
+                                            "{0} must be a dictionary".format(self.__class__.__name__))
+
+        attrs = ['id', 'email', 'name', 'preferredUsername']
+        for attr in attrs:
+            if attr in self.provider['attributes'] and not isinstance(self.provider['attributes'][attr], list):
+                raise errors.AnsibleFilterError("|failed {0} attribute for "
+                                                "provider {1} must be a list".format(attr, self.__class__.__name__))
+
+        unknown_attrs = set(self.provider['attributes'].keys()) - set(attrs)
+        if len(unknown_attrs) > 0:
+            raise errors.AnsibleFilterError("|failed provider {0} has unknown "
+                                            "attributes: {1}".format(self.__class__.__name__, ', '.join(unknown_attrs)))
+
+
+class KeystonePasswordIdentityProvider(IdentityProviderBase):
+    """ KeystoneIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+        self._required += [['url'], ['domainName', 'domain_name']]
+        self._optional += [['ca'], ['certFile', 'cert_file'], ['keyFile', 'key_file']]
+
+
+class RequestHeaderIdentityProvider(IdentityProviderBase):
+    """ RequestHeaderIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+        self._required += [['headers']]
+        self._optional += [['challengeURL', 'challenge_url'],
+                           ['loginURL', 'login_url'],
+                           ['clientCA', 'client_ca']]
+
+    def validate(self):
+        ''' validate this idp instance '''
+        IdentityProviderBase.validate(self)
+        if not isinstance(self.provider['headers'], list):
+            raise errors.AnsibleFilterError("|failed headers for provider {0} "
+                                            "must be a list".format(self.__class__.__name__))
+
+
+class AllowAllPasswordIdentityProvider(IdentityProviderBase):
+    """ AllowAllPasswordIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+
+
+class DenyAllPasswordIdentityProvider(IdentityProviderBase):
+    """ DenyAllPasswordIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+
+
+class HTPasswdPasswordIdentityProvider(IdentityProviderBase):
+    """ HTPasswdPasswordIdentity
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+        self._required += [['file', 'filename', 'fileName', 'file_name']]
+
+    @staticmethod
+    def get_default(key):
+        if key == 'file':
+            return '/etc/origin/htpasswd'
+        else:
+            return IdentityProviderBase.get_default(key)
+
+
+class BasicAuthPasswordIdentityProvider(IdentityProviderBase):
+    """ BasicAuthPasswordIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+        self._required += [['ca'], ['certFile', 'cert_file'], ['keyFile', 'key_file']]
+        self._optional += [['key']]
+
+
+class IdentityProviderOauthBase(IdentityProviderBase):
+    """ IdentityProviderOauthBase
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderBase.__init__(self, api_version, idp)
+        self._allow_additional = False
+        self._required += [['clientID', 'client_id'], ['clientSecret', 'client_secret']]
+
+    def validate(self):
+        ''' validate this idp instance '''
+        IdentityProviderBase.validate(self)
+        if self.challenge:
+            raise errors.AnsibleFilterError("|failed provider {0} does not "
+                                            "allow challenge authentication".format(self.__class__.__name__))
+
+
+class OpenIDIdentityProvider(IdentityProviderOauthBase):
+    """ OpenIDIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderOauthBase.__init__(self, api_version, idp)
+        self._required += [['claims'], ['urls']]
+        self._optional += [['ca'],
+                           ['extraScopes'],
+                           ['extraAuthorizeParameters']]
+        if 'claims' in self._idp and 'preferred_username' in self._idp['claims']:
+            pref_user = self._idp['claims'].pop('preferred_username')
+            self._idp['claims']['preferredUsername'] = pref_user
+        if 'urls' in self._idp and 'user_info' in self._idp['urls']:
+            user_info = self._idp['urls'].pop('user_info')
+            self._idp['urls']['userInfo'] = user_info
+        if 'extra_scopes' in self._idp:
+            self._idp['extraScopes'] = self._idp.pop('extra_scopes')
+        if 'extra_authorize_parameters' in self._idp:
+            self._idp['extraAuthorizeParameters'] = self._idp.pop('extra_authorize_parameters')
+
+        if 'extraAuthorizeParameters' in self._idp:
+            if 'include_granted_scopes' in self._idp['extraAuthorizeParameters']:
+                val = ansible_bool(self._idp['extraAuthorizeParameters'].pop('include_granted_scopes'))
+                self._idp['extraAuthorizeParameters']['include_granted_scopes'] = val
+
+
+    def validate(self):
+        ''' validate this idp instance '''
+        IdentityProviderOauthBase.validate(self)
+        if not isinstance(self.provider['claims'], dict):
+            raise errors.AnsibleFilterError("|failed claims for provider {0} "
+                                            "must be a dictionary".format(self.__class__.__name__))
+
+        if 'extraScopes' not in self.provider['extraScopes'] and not isinstance(self.provider['extraScopes'], list):
+            raise errors.AnsibleFilterError("|failed extraScopes for provider "
+                                            "{0} must be a list".format(self.__class__.__name__))
+        if ('extraAuthorizeParameters' not in self.provider['extraAuthorizeParameters']
+                and not  isinstance(self.provider['extraAuthorizeParameters'], dict)):
+            raise errors.AnsibleFilterError("|failed extraAuthorizeParameters "
+                                            "for provider {0} must be a dictionary".format(self.__class__.__name__))
+
+        required_claims = ['id']
+        optional_claims = ['email', 'name', 'preferredUsername']
+        all_claims = required_claims + optional_claims
+
+        for claim in required_claims:
+            if claim in required_claims and claim not in self.provider['claims']:
+                raise errors.AnsibleFilterError("|failed {0} claim missing "
+                                                "for provider {1}".format(claim, self.__class__.__name__))
+
+        for claim in all_claims:
+            if claim in self.provider['claims'] and not isinstance(self.provider['claims'][claim], list):
+                raise errors.AnsibleFilterError("|failed {0} claims for "
+                                                "provider {1} must be a list".format(claim, self.__class__.__name__))
+
+        unknown_claims = set(self.provider['claims'].keys()) - set(all_claims)
+        if len(unknown_claims) > 0:
+            raise errors.AnsibleFilterError("|failed provider {0} has unknown "
+                                            "claims: {1}".format(self.__class__.__name__, ', '.join(unknown_claims)))
+
+        if not isinstance(self.provider['urls'], dict):
+            raise errors.AnsibleFilterError("|failed urls for provider {0} "
+                                            "must be a dictionary".format(self.__class__.__name__))
+
+        required_urls = ['authorize', 'token']
+        optional_urls = ['userInfo']
+        all_urls = required_urls + optional_urls
+
+        for url in required_urls:
+            if url not in self.provider['urls']:
+                raise errors.AnsibleFilterError("|failed {0} url missing for "
+                                                "provider {1}".format(url, self.__class__.__name__))
+
+        unknown_urls = set(self.provider['urls'].keys()) - set(all_urls)
+        if len(unknown_urls) > 0:
+            raise errors.AnsibleFilterError("|failed provider {0} has unknown "
+                                            "urls: {1}".format(self.__class__.__name__, ', '.join(unknown_urls)))
+
+
+class GoogleIdentityProvider(IdentityProviderOauthBase):
+    """ GoogleIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    def __init__(self, api_version, idp):
+        IdentityProviderOauthBase.__init__(self, api_version, idp)
+        self._optional += [['hostedDomain', 'hosted_domain']]
+
+
+class GitHubIdentityProvider(IdentityProviderOauthBase):
+    """ GitHubIdentityProvider
+
+        Attributes:
+
+        Args:
+            api_version(str): OpenShift config version
+            idp (dict): idp config dict
+
+        Raises:
+            AnsibleFilterError:
+    """
+    pass
+
+
+class FilterModule(object):
+    ''' Custom ansible filters for use by the openshift_master role'''
+
+    @staticmethod
+    def translate_idps(idps, api_version):
+        ''' Translates a list of dictionaries into a valid identityProviders config '''
+        idp_list = []
+
+        if not isinstance(idps, list):
+            raise errors.AnsibleFilterError("|failed expects to filter on a list of identity providers")
+        for idp in idps:
+            if not isinstance(idp, dict):
+                raise errors.AnsibleFilterError("|failed identity providers must be a list of dictionaries")
+
+            cur_module = sys.modules[__name__]
+            idp_class = getattr(cur_module, idp['kind'], None)
+            idp_inst = idp_class(api_version, idp) if idp_class is not None else IdentityProviderBase(api_version, idp)
+            idp_inst.set_provider_items()
+            idp_list.append(idp_inst)
+
+
+        IdentityProviderBase.validate_idp_list(idp_list)
+        return yaml.safe_dump([idp.to_dict() for idp in idp_list], default_flow_style=False)
+
+
+    def filters(self):
+        ''' returns a mapping of filters to methods '''
+        return {"translate_idps": self.translate_idps}

+ 11 - 5
roles/openshift_master/tasks/main.yml

@@ -1,13 +1,16 @@
 ---
-# TODO: add validation for openshift_master_identity_providers
 # TODO: add ability to configure certificates given either a local file to
 #       point to or certificate contents, set in default cert locations.
 
-- assert:
-    that:
-    - openshift_master_oauth_grant_method in openshift_master_valid_grant_methods
-  when: openshift_master_oauth_grant_method is defined
+# Authentication Variable Validation
+# TODO: validate the different identity provider kinds as well
+- fail:
+    msg: >
+      Invalid OAuth grant method: {{ openshift_master_oauth_grant_method }}
+  when: openshift_master_oauth_grant_method is defined and openshift_master_oauth_grant_method not in openshift_master_valid_grant_methods
+
 
+# HA Variable Validation
 - fail:
     msg: "openshift_master_cluster_method must be set to either 'native' or 'pacemaker' for multi-master installations"
   when: openshift_master_ha | bool and ((openshift_master_cluster_method is not defined) or (openshift_master_cluster_method is defined and openshift_master_cluster_method not in ["native", "pacemaker"]))
@@ -172,6 +175,9 @@
   - restart master
   - restart master api
 
+- set_fact:
+    translated_identity_providers: "{{ openshift_master_identity_providers | translate_idps('v1') }}"
+
 # TODO: add the validate parameter when there is a validation command to run
 - name: Create master config
   template:

+ 18 - 1
roles/openshift_master/templates/master.yaml.v1.j2

@@ -107,7 +107,24 @@ networkConfig:
 {% endif %}
 # serviceNetworkCIDR must match kubernetesMasterConfig.servicesSubnet
   serviceNetworkCIDR: {{ openshift.master.portal_net }}
-{% include 'v1_partials/oauthConfig.j2' %}
+oauthConfig:
+  assetPublicURL: {{ openshift.master.public_console_url }}/
+  grantConfig:
+    method: {{ openshift.master.oauth_grant_method }}
+  identityProviders:
+{% for line in translated_identity_providers.splitlines() %}
+  {{ line }}
+{% endfor %}
+  masterCA: ca.crt
+  masterPublicURL: {{ openshift.master.public_api_url }}
+  masterURL: {{ openshift.master.api_url }}
+  sessionConfig:
+    sessionMaxAgeSeconds: {{ openshift.master.session_max_seconds }}
+    sessionName: {{ openshift.master.session_name }}
+    sessionSecretsFile: {{ openshift.master.session_secrets_file }}
+  tokenConfig:
+    accessTokenMaxAgeSeconds: {{ openshift.master.access_token_max_seconds }}
+    authorizeTokenMaxAgeSeconds: {{ openshift.master.auth_token_max_seconds }}
 pauseControllers: false
 policyConfig:
   bootstrapPolicyFile: {{ openshift_master_policy }}

+ 0 - 93
roles/openshift_master/templates/v1_partials/oauthConfig.j2

@@ -1,93 +0,0 @@
-{% macro identity_provider_config(identity_provider) %}
-      apiVersion: v1
-      kind: {{ identity_provider.kind }}
-{% if identity_provider.kind == 'HTPasswdPasswordIdentityProvider' %}
-      file: {{ identity_provider.filename }}
-{% elif identity_provider.kind == 'BasicAuthPasswordIdentityProvider' %}
-      url: {{ identity_provider.url }}
-{% for key in ('ca', 'certFile', 'keyFile') %}
-{% if key in identity_provider %}
-      {{ key }}: "{{ identity_provider[key] }}"
-{% endif %}
-{% endfor %}
-{% elif identity_provider.kind == 'LDAPPasswordIdentityProvider' %}
-      attributes:
-{% for attribute_key in identity_provider.attributes %}
-        {{ attribute_key }}:
-{% for attribute_value in identity_provider.attributes[attribute_key] %}
-        - {{ attribute_value }}
-{% endfor %}
-{% endfor %}
-{% for key in ('bindDN', 'bindPassword', 'ca') %}
-      {{ key }}: "{{ identity_provider[key] }}"
-{% endfor %}
-{% for key in ('insecure', 'url') %}
-      {{ key }}: {{ identity_provider[key] }}
-{% endfor %}
-{% elif identity_provider.kind == 'RequestHeaderIdentityProvider' %}
-      headers: {{ identity_provider.headers }}
-{% if 'clientCA' in identity_provider %}
-      clientCA: {{ identity_provider.clientCA }}
-{% endif %}
-{% elif identity_provider.kind == 'GitHubIdentityProvider' %}
-      clientID: {{ identity_provider.clientID }}
-      clientSecret: {{ identity_provider.clientSecret }}
-{% elif identity_provider.kind == 'GoogleIdentityProvider' %}
-      clientID: {{ identity_provider.clientID }}
-      clientSecret: {{ identity_provider.clientSecret }}
-{% if 'hostedDomain' in identity_provider %}
-      hostedDomain: {{ identity_provider.hostedDomain }}
-{% endif %}
-{% elif identity_provider.kind == 'OpenIDIdentityProvider' %}
-      clientID: {{ identity_provider.clientID }}
-      clientSecret: {{ identity_provider.clientSecret }}
-      claims:
-        id: identity_provider.claims.id
-{% for claim_key in ('preferredUsername', 'name', 'email') %}
-{% if claim_key in identity_provider.claims %}
-        {{ claim_key }}: {{ identity_provider.claims[claim_key] }}
-{% endif %}
-{% endfor %}
-      urls:
-        authorize: {{ identity_provider.urls.authorize }}
-        token: {{ identity_provider.urls.token }}
-{% if 'userInfo' in identity_provider.urls %}
-        userInfo: {{ identity_provider.userInfo }}
-{% endif %}
-{% if 'extraScopes' in identity_provider %}
-      extraScopes:
-{% for scope in identity_provider.extraScopes %}
-      - {{ scope }}
-{% endfor %}
-{% endif %}
-{% if 'extraAuthorizeParameters' in identity_provider %}
-      extraAuthorizeParameters:
-{% for param_key, param_value in identity_provider.extraAuthorizeParameters.iteritems() %}
-        {{ param_key }}: {{ param_value }}
-{% endfor %}
-{% endif %}
-{% endif %}
-{% endmacro %}
-oauthConfig:
-  assetPublicURL: {{ openshift.master.public_console_url }}/
-  grantConfig:
-    method: {{ openshift.master.oauth_grant_method }}
-  identityProviders:
-{% for identity_provider in openshift.master.identity_providers %}
-  - name: {{ identity_provider.name }}
-    challenge: {{ identity_provider.challenge }}
-    login: {{ identity_provider.login }}
-    provider:
-{{ identity_provider_config(identity_provider) }}
-{%- endfor %}
-  masterCA: ca.crt
-  masterPublicURL: {{ openshift.master.public_api_url }}
-  masterURL: {{ openshift.master.api_url }}
-  sessionConfig:
-    sessionMaxAgeSeconds: {{ openshift.master.session_max_seconds }}
-    sessionName: {{ openshift.master.session_name }}
-    sessionSecretsFile: {{ openshift.master.session_secrets_file }}
-  tokenConfig:
-    accessTokenMaxAgeSeconds: {{ openshift.master.access_token_max_seconds }}
-    authorizeTokenMaxAgeSeconds: {{ openshift.master.auth_token_max_seconds }}
-{# Comment to preserve newline after authorizeTokenMaxAgeSeconds #}