diff --git a/docs/config.rst b/docs/config.rst index bad0a30fa4..35e64711c9 100755 --- a/docs/config.rst +++ b/docs/config.rst @@ -34,6 +34,13 @@ Use config.py to configure the following parameters. By default it will use SQLL | | exist. Mandatory when using user | | | | registration | | +----------------------------------------+--------------------------------------------+-----------+ +| AUTH_USER_REGISTRATION_ROLE_JMESPATH | The `JMESPath `_ | No | +| | expression used to evaluate user role on | | +| | registration. If set, takes precedence | | +| | over ``AUTH_USER_REGISTRATION_ROLE``. | | +| | Requires ``jmespath`` to be installed. | | +| | See :ref:`jmespath-examples` for examples | | ++----------------------------------------+--------------------------------------------+-----------+ | AUTH_LDAP_SERVER | define your ldap server when AUTH_TYPE=2 | Cond. | | | example: | | | | | | @@ -261,3 +268,25 @@ Next you only have to import them to the Flask app object, like this app.config.from_object('config') Take a look at the skeleton `config.py `_ + + +.. _jmespath-examples: + +Using JMESPath to map user registration role +-------------------------------------------- + +If user self registration is enabled and ``AUTH_USER_REGISTRATION_ROLE_JMESPATH`` is set, it is +used as a `JMESPath `_ expression to evalate user registration role. The input +values is ``userinfo`` dict, returned by ``get_oauth_user_info`` function of Security Manager. +Usage of JMESPath expressions requires `jmespath `_ package +to be installed. + +In case of Google OAuth, userinfo contains user's email that can be used to map some users as admins +and rest of the domain users as read only users. For example, this expression: +``contains(['user1@domain.com', 'user2@domain.com'], email) && 'Admin' || 'Viewer'`` +causes users 1 and 2 to be registered with role ``Admin`` and rest with the role ``Viewer``. + +JMESPath expression allow more groups to be evaluated: +``email == 'user1@domain.com' && 'Admin' || (email == 'user2@domain.com' && 'Op' || 'Viewer')`` + +For more example, see `specification `_. diff --git a/examples/oauth/config.py b/examples/oauth/config.py index 1ddad40393..d929ea9ace 100644 --- a/examples/oauth/config.py +++ b/examples/oauth/config.py @@ -94,9 +94,12 @@ # Will allow user self registration AUTH_USER_REGISTRATION = True -# The default user self registration role +# The default user self registration role for all users AUTH_USER_REGISTRATION_ROLE = "Admin" +# Self registration role based on user info +AUTH_USER_REGISTRATION_ROLE_JMESPATH = "contains(['alice@example.com', 'celine@example.com'], email) && 'Admin' || 'Public'" + # When using LDAP Auth, setup the ldap server # AUTH_LDAP_SERVER = "ldap://ldapserver.new" # AUTH_LDAP_USE_TLS = False diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 078a4bf870..5ebf885f45 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -215,6 +215,7 @@ def __init__(self, appbuilder): # Self Registration app.config.setdefault("AUTH_USER_REGISTRATION", False) app.config.setdefault("AUTH_USER_REGISTRATION_ROLE", self.auth_role_public) + app.config.setdefault("AUTH_USER_REGISTRATION_ROLE_JMESPATH", None) # LDAP Config if self.auth_type == AUTH_LDAP: @@ -347,6 +348,10 @@ def auth_user_registration(self): def auth_user_registration_role(self): return self.appbuilder.get_app.config["AUTH_USER_REGISTRATION_ROLE"] + @property + def auth_user_registration_role_jmespath(self) -> str: + return self.appbuilder.get_app.config["AUTH_USER_REGISTRATION_ROLE_JMESPATH"] + @property def auth_ldap_search(self): return self.appbuilder.get_app.config["AUTH_LDAP_SEARCH"] @@ -1020,12 +1025,19 @@ def auth_user_oauth(self, userinfo): return None # User does not exist, create one if self registration. if not user: + role_name = self.auth_user_registration_role + if self.auth_user_registration_role_jmespath: + import jmespath + + role_name = jmespath.search( + self.auth_user_registration_role_jmespath, userinfo + ) user = self.add_user( username=userinfo["username"], first_name=userinfo.get("first_name", ""), last_name=userinfo.get("last_name", ""), email=userinfo.get("email", ""), - role=self.find_role(self.auth_user_registration_role), + role=self.find_role(role_name), ) if not user: log.error("Error creating a new OAuth user %s" % userinfo["username"]) diff --git a/flask_appbuilder/tests/_test_oauth_registration_role.py b/flask_appbuilder/tests/_test_oauth_registration_role.py new file mode 100644 index 0000000000..cc6b4cffc1 --- /dev/null +++ b/flask_appbuilder/tests/_test_oauth_registration_role.py @@ -0,0 +1,59 @@ +import logging +import unittest + +from flask import Flask +from flask_appbuilder import AppBuilder, SQLA + + +logging.basicConfig(format="%(asctime)s:%(levelname)s:%(name)s:%(message)s") +logging.getLogger().setLevel(logging.DEBUG) +log = logging.getLogger(__name__) + + +class OAuthRegistrationRoleTestCase(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + self.db = SQLA(self.app) + + def tearDown(self): + self.appbuilder = None + self.app = None + self.db = None + + def test_self_registration_not_enabled(self): + self.app.config["AUTH_USER_REGISTRATION"] = False + self.appbuilder = AppBuilder(self.app, self.db.session) + + result = self.appbuilder.sm.auth_user_oauth(userinfo={"username": "testuser"}) + + self.assertIsNone(result) + self.assertEqual(len(self.appbuilder.sm.get_all_users()), 0) + + def test_register_and_attach_static_role(self): + self.app.config["AUTH_USER_REGISTRATION"] = True + self.app.config["AUTH_USER_REGISTRATION_ROLE"] = "Public" + self.appbuilder = AppBuilder(self.app, self.db.session) + + user = self.appbuilder.sm.auth_user_oauth(userinfo={"username": "testuser"}) + + self.assertEqual(user.roles[0].name, "Public") + + def test_register_and_attach_dynamic_role(self): + self.app.config["AUTH_USER_REGISTRATION"] = True + self.app.config[ + "AUTH_USER_REGISTRATION_ROLE_JMESPATH" + ] = "contains(['alice', 'celine'], username) && 'Admin' || 'Public'" + self.appbuilder = AppBuilder(self.app, self.db.session) + + # Role for admin + user = self.appbuilder.sm.auth_user_oauth( + userinfo={"username": "alice", "email": "alice@example.com"} + ) + self.assertEqual(user.roles[0].name, "Admin") + + # Role for non-admin + user = self.appbuilder.sm.auth_user_oauth( + userinfo={"username": "bob", "email": "bob@example.com"} + ) + self.assertEqual(user.roles[0].name, "Public") diff --git a/requirements-dev.txt b/requirements-dev.txt index f58074adb2..533014d69c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,3 +14,4 @@ mysqlclient>=1.4.2, < 2.0.0 cython==0.29.17 pymssql==2.1.4 black==19.3b0 +jmespath==0.9.5 diff --git a/setup.py b/setup.py index 4e5c289ae7..0c56bc67e2 100644 --- a/setup.py +++ b/setup.py @@ -65,6 +65,7 @@ def desc(): "PyJWT>=1.7.1", "sqlalchemy-utils>=0.32.21, <1", ], + extras_require={"jmespath": ["jmespath>=0.9.5"]}, tests_require=["nose>=1.0", "mockldap>=0.3.0"], classifiers=[ "Development Status :: 5 - Production/Stable",