Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New, Dynamic user registration role #1410

Merged
merged 3 commits into from
Jun 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://jmespath.org/>`_ | 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: | |
| | | |
Expand Down Expand Up @@ -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 <https://github.com/dpgaspar/Flask-AppBuilder-Skeleton/blob/master/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 <http://jmespath.org/>`_ 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 <https://pypi.org/project/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 <https://jmespath.org/specification.html>`_.
5 changes: 4 additions & 1 deletion examples/oauth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion flask_appbuilder/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"])
Expand Down
59 changes: 59 additions & 0 deletions flask_appbuilder/tests/_test_oauth_registration_role.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down