Răsfoiți Sursa

HUE-9366 [libsaml] SAML Authentication with additional group checks

Lib check:
./build/env/bin/hue test specific libsaml.tests
Romain 5 ani în urmă
părinte
comite
8b46e3a54e

+ 6 - 0
desktop/conf.dist/hue.ini

@@ -1937,6 +1937,12 @@
   # Performs the logout or not.
   ## logout_enabled=true
 
+  # Comma separated list of group names which are all required to complete the authentication. e.g. admin,sales.
+  ## required_groups=
+
+  # Name of the SAML attribute containing the list of groups the user belongs to.
+  ## required_groups_attribute=groups
+
 
 ###########################################################################
 # Settings to configure OAuth

+ 6 - 0
desktop/conf/pseudo-distributed.ini.tmpl

@@ -1925,6 +1925,12 @@
   # Performs the logout or not.
   ## logout_enabled=true
 
+  # Comma separated list of group names which are all required to complete the authentication. e.g. admin,sales.
+  ## required_groups=
+
+  # Name of the SAML attribute containing the list of groups the user belongs to.
+  ## required_groups_attribute=groups
+
 
 ###########################################################################
 # Settings to configure OAuth

+ 9 - 1
desktop/core/src/desktop/auth/views_test.py

@@ -17,12 +17,14 @@
 
 from builtins import object
 import datetime
-from nose.tools import assert_true, assert_false, assert_equal, assert_raises
+import sys
 
 from django_auth_ldap import backend as django_auth_ldap_backend
 from django.db.utils import DataError
 from django.conf import settings
 from django.test.client import Client
+from nose.plugins.skip import SkipTest
+from nose.tools import assert_true, assert_false, assert_equal, assert_raises
 
 from hadoop.test_base import PseudoHdfsTestBase
 from hadoop import pseudo_hdfs4
@@ -38,6 +40,12 @@ from desktop.lib.django_test_util import make_logged_in_client
 from desktop.lib.test_utils import add_to_group
 
 
+if sys.version_info[0] > 2:
+  from unittest.mock import patch, Mock, MagicMock
+else:
+  from mock import patch, Mock, MagicMock
+
+
 def get_mocked_config():
   return {
     'mocked_ldap': {

+ 1 - 0
desktop/core/src/desktop/views.py

@@ -392,6 +392,7 @@ def csrf_failure(request, reason=None):
   access_warn(request, reason)
   return render("403_csrf.mako", request, dict(uri=request.build_absolute_uri()), status=403)
 
+@login_notrequired
 def serve_403_error(request, *args, **kwargs):
   """Registered handler for 403. We just return a simple error"""
   access_warn(request, "403 access forbidden")

+ 7 - 0
desktop/libs/libsaml/src/libsaml/backend.py

@@ -59,6 +59,13 @@ class SAML2Backend(_Saml2Backend):
     return force_username_case(main_attribute)
 
 
+  def is_authorized(self, attributes, attribute_mapping):
+    """Hook to allow custom authorization policies based on user belonging to a list of SAML groups."""
+    LOG.debug('is_authorized() attributes = %s' % attributes)
+    LOG.debug('is_authorized() attribute_mapping = %s' % attribute_mapping)
+    return not conf.REQUIRED_GROUPS.get() or set(conf.REQUIRED_GROUPS.get()).issubset(set(attributes[conf.REQUIRED_GROUPS_ATTRIBUTE.get()]))
+
+
   def get_user(self, user_id):
     if isinstance(user_id, str):
       user_id = force_username_case(user_id)

+ 12 - 0
desktop/libs/libsaml/src/libsaml/conf.py

@@ -181,6 +181,18 @@ NAME_ID_FORMAT = Config(
   type=str,
   help=_t("Request this NameID format from the server"))
 
+REQUIRED_GROUPS = Config(
+  key="required_groups",
+  type=coerce_csv,
+  default=[],
+  help=_t("Comma separated list of group names which are all required to complete the authentication. e.g. admin,sales"))
+
+REQUIRED_GROUPS_ATTRIBUTE = Config(
+  key="required_groups_attribute",
+  default="groups",
+  type=str,
+  help=_t("Name of the SAML attribute containing the list of groups the user belongs to."))
+
 
 def get_key_file_password():
   password = os.environ.get('HUE_SAML_KEY_FILE_PASSWORD')

+ 110 - 1
desktop/libs/libsaml/src/libsaml/tests.py

@@ -18,15 +18,18 @@
 
 import sys
 
+from nose.plugins.skip import SkipTest
 from nose.tools import assert_equal, assert_true, assert_false
 
-from libsaml.conf import xmlsec
+from libsaml.conf import xmlsec, REQUIRED_GROUPS, REQUIRED_GROUPS_ATTRIBUTE
+
 
 if sys.version_info[0] > 2:
   from unittest.mock import patch, Mock
 else:
   from mock import patch, Mock
 
+
 def test_xmlsec_dynamic_default_no_which():
 
   with patch('libsaml.conf.subprocess') as subprocess:
@@ -35,3 +38,109 @@ def test_xmlsec_dynamic_default_no_which():
     )
 
     assert_equal('/usr/local/bin/xmlsec1', xmlsec())
+
+
+class TestLibSaml():
+
+  def setUp(self):
+    try:
+      from djangosaml2 import views as djangosaml2_views
+      from libsaml import views as libsaml_views
+    except ImportError:
+      raise SkipTest('djangosaml2 or libsaml modules not found')
+
+  def test_is_authorized_groups(self):
+    from libsaml.backend import SAML2Backend
+
+    # Single group
+    attributes = {'groups': ['analyst']}
+    attribute_mapping = {}
+
+    resets = [
+      REQUIRED_GROUPS.set_for_testing(['analyst']),
+      REQUIRED_GROUPS_ATTRIBUTE.set_for_testing('groups'),
+    ]
+
+    try:
+      assert_true(SAML2Backend().is_authorized(attributes, attribute_mapping))
+    finally:
+      for reset in resets:
+        reset()
+
+    attributes = {'groups': ['analyst', 'finance']}
+    attribute_mapping = {}
+
+    resets = [
+      REQUIRED_GROUPS.set_for_testing(['analyst']),
+      REQUIRED_GROUPS_ATTRIBUTE.set_for_testing('groups'),
+    ]
+
+    try:
+      assert_true(SAML2Backend().is_authorized(attributes, attribute_mapping))
+    finally:
+      for reset in resets:
+        reset()
+
+
+    # Multi groups
+    attributes = {'groups': ['analyst', 'sales', 'engineering']}
+    attribute_mapping = {}
+
+    resets = [
+      REQUIRED_GROUPS.set_for_testing(['analyst', 'sales']),
+      REQUIRED_GROUPS_ATTRIBUTE.set_for_testing('groups'),
+    ]
+
+    try:
+      assert_true(SAML2Backend().is_authorized(attributes, attribute_mapping))
+    finally:
+      for reset in resets:
+        reset()
+
+
+  def test_is_non_authorized_groups(self):
+    from libsaml.backend import SAML2Backend
+
+    # Single group
+    attributes = {'groups': ['intern']}
+    attribute_mapping = {}
+
+    resets = [
+      REQUIRED_GROUPS.set_for_testing(['sales']),
+      REQUIRED_GROUPS_ATTRIBUTE.set_for_testing('groups'),
+    ]
+
+    try:
+      assert_false(SAML2Backend().is_authorized(attributes, attribute_mapping))
+    finally:
+      for reset in resets:
+        reset()
+
+    attributes = {'groups': ['intern', 'finance']}
+    attribute_mapping = {}
+
+    resets = [
+      REQUIRED_GROUPS.set_for_testing(['sales']),
+      REQUIRED_GROUPS_ATTRIBUTE.set_for_testing('groups'),
+    ]
+
+    try:
+      assert_false(SAML2Backend().is_authorized(attributes, attribute_mapping))
+    finally:
+      for reset in resets:
+        reset()
+
+    # Multi groups
+    attributes = {'groups': ['intern', 'sales']}
+    attribute_mapping = {}
+
+    resets = [
+      REQUIRED_GROUPS.set_for_testing(['sales', 'engineering']),
+      REQUIRED_GROUPS_ATTRIBUTE.set_for_testing('groups'),
+    ]
+
+    try:
+      assert_false(SAML2Backend().is_authorized(attributes, attribute_mapping))
+    finally:
+      for reset in resets:
+        reset()

+ 3 - 1
docs/designs/authentication/saml.md

@@ -1,6 +1,8 @@
 
 # SAML Authentication with additional group checks
 
+[HUE-9366](https://issues.cloudera.org/browse/HUE-9366)
+
 SAML is one of the main solution for offering SSO with LDAP and OpenId Connect. In Hue, this happens with the [SAML2Backend](https://docs.gethue.com/administrator/configuration/server/#saml) [Django Backend](https://docs.djangoproject.com/en/3.0/ref/contrib/auth/).
 
 ## Restricting user authentication via a dedicated 'groups' attribute
@@ -38,5 +40,5 @@ In case of failure to authenticate properly (e.g. bad credentials or user not pa
 Add tests (with the help of the Mock module) to check if:
 
 * Authentication with group list [] --> rejects
-* Authentication with group list missing one group --> rejetcs
+* Authentication with group list missing one group --> rejects
 * Authentication with group list intersecting all the required groups --> succeeds