You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by bo...@apache.org on 2018/03/23 08:19:28 UTC

[14/14] incubator-airflow git commit: [AIRFLOW-1433][AIRFLOW-85] New Airflow Webserver UI with RBAC support

[AIRFLOW-1433][AIRFLOW-85] New Airflow Webserver UI with RBAC support

Closes #3015 from jgao54/rbac


Project: http://git-wip-us.apache.org/repos/asf/incubator-airflow/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-airflow/commit/05e1861e
Tree: http://git-wip-us.apache.org/repos/asf/incubator-airflow/tree/05e1861e
Diff: http://git-wip-us.apache.org/repos/asf/incubator-airflow/diff/05e1861e

Branch: refs/heads/master
Commit: 05e1861e24de42f9a2c649cd93041c5c744504e1
Parents: bd01004
Author: Joy Gao <Jo...@apache.org>
Authored: Fri Mar 23 09:18:48 2018 +0100
Committer: Bolke de Bruin <bo...@xs4all.nl>
Committed: Fri Mar 23 09:18:48 2018 +0100

----------------------------------------------------------------------
 MANIFEST.in                                     |     3 +
 UPDATING.md                                     |    30 +
 airflow/bin/cli.py                              |    81 +-
 .../config_templates/airflow_local_settings.py  |    10 +
 airflow/config_templates/default_airflow.cfg    |     4 +
 airflow/config_templates/default_test.cfg       |     1 +
 .../default_webserver_config.py                 |    84 +
 airflow/configuration.py                        |    10 +
 airflow/models.py                               |    60 +-
 airflow/settings.py                             |     2 +-
 airflow/utils/db.py                             |    19 +-
 airflow/www/views.py                            |     7 +-
 airflow/www_rbac/__init__.py                    |    13 +
 airflow/www_rbac/api/__init__.py                |    14 +
 airflow/www_rbac/api/experimental/__init__.py   |    14 +
 airflow/www_rbac/api/experimental/endpoints.py  |   231 +
 airflow/www_rbac/app.py                         |   174 +
 airflow/www_rbac/blueprints.py                  |    30 +
 airflow/www_rbac/decorators.py                  |    88 +
 airflow/www_rbac/forms.py                       |   130 +
 airflow/www_rbac/security.py                    |   174 +
 airflow/www_rbac/static/airflow.gif             |   Bin 0 -> 622963 bytes
 airflow/www_rbac/static/bootstrap-theme.css     |  6494 ++++++++
 .../www_rbac/static/bootstrap-toggle.min.css    |    28 +
 airflow/www_rbac/static/bootstrap-toggle.min.js |     9 +
 .../www_rbac/static/bootstrap3-typeahead.min.js |    21 +
 airflow/www_rbac/static/connection_form.js      |    78 +
 airflow/www_rbac/static/d3.tip.v0.6.3.js        |   302 +
 airflow/www_rbac/static/d3.v3.min.js            |     5 +
 airflow/www_rbac/static/dagre-d3.js             |  5007 ++++++
 airflow/www_rbac/static/dagre-d3.min.js         |     2 +
 airflow/www_rbac/static/dagre.css               |    38 +
 .../www_rbac/static/dataTables.bootstrap.css    |   333 +
 airflow/www_rbac/static/docs                    |     1 +
 airflow/www_rbac/static/favicon.ico             |   Bin 0 -> 1406 bytes
 airflow/www_rbac/static/gantt-chart-d3v2.js     |   267 +
 airflow/www_rbac/static/gantt.css               |    57 +
 airflow/www_rbac/static/graph.css               |    72 +
 airflow/www_rbac/static/jqClock.min.js          |    27 +
 airflow/www_rbac/static/jquery.dataTables.css   |   495 +
 .../www_rbac/static/jquery.dataTables.min.js    |   189 +
 airflow/www_rbac/static/loading.gif             |   Bin 0 -> 16671 bytes
 airflow/www_rbac/static/main.css                |   267 +
 airflow/www_rbac/static/nv.d3.css               |   788 +
 airflow/www_rbac/static/nv.d3.js                | 14260 +++++++++++++++++
 airflow/www_rbac/static/pin.svg                 |   129 +
 airflow/www_rbac/static/pin_100.jpg             |   Bin 0 -> 6780 bytes
 airflow/www_rbac/static/pin_100.png             |   Bin 0 -> 10977 bytes
 airflow/www_rbac/static/pin_25.png              |   Bin 0 -> 1442 bytes
 airflow/www_rbac/static/pin_30.png              |   Bin 0 -> 2015 bytes
 airflow/www_rbac/static/pin_35.png              |   Bin 0 -> 2171 bytes
 airflow/www_rbac/static/pin_40.png              |   Bin 0 -> 2685 bytes
 airflow/www_rbac/static/pin_large.jpg           |   Bin 0 -> 399613 bytes
 airflow/www_rbac/static/pin_large.png           |   Bin 0 -> 358276 bytes
 airflow/www_rbac/static/screenshots/gantt.png   |   Bin 0 -> 31140 bytes
 airflow/www_rbac/static/screenshots/graph.png   |   Bin 0 -> 38282 bytes
 airflow/www_rbac/static/screenshots/tree.png    |   Bin 0 -> 35259 bytes
 airflow/www_rbac/static/sort_asc.png            |   Bin 0 -> 160 bytes
 airflow/www_rbac/static/sort_both.png           |   Bin 0 -> 201 bytes
 airflow/www_rbac/static/sort_desc.png           |   Bin 0 -> 158 bytes
 airflow/www_rbac/static/tree.css                |    96 +
 airflow/www_rbac/templates/airflow/chart.html   |    57 +
 airflow/www_rbac/templates/airflow/circles.html |   144 +
 airflow/www_rbac/templates/airflow/code.html    |    43 +
 airflow/www_rbac/templates/airflow/config.html  |    69 +
 airflow/www_rbac/templates/airflow/confirm.html |    35 +
 .../www_rbac/templates/airflow/conn_create.html |    23 +
 .../www_rbac/templates/airflow/conn_edit.html   |    23 +
 airflow/www_rbac/templates/airflow/dag.html     |   430 +
 .../www_rbac/templates/airflow/dag_code.html    |    58 +
 .../www_rbac/templates/airflow/dag_details.html |    72 +
 airflow/www_rbac/templates/airflow/dags.html    |   468 +
 .../templates/airflow/duration_chart.html       |    77 +
 airflow/www_rbac/templates/airflow/gantt.html   |    67 +
 airflow/www_rbac/templates/airflow/graph.html   |   369 +
 airflow/www_rbac/templates/airflow/master.html  |    18 +
 .../www_rbac/templates/airflow/model_list.html  |    93 +
 .../www_rbac/templates/airflow/noaccess.html    |    24 +
 airflow/www_rbac/templates/airflow/task.html    |    75 +
 .../templates/airflow/task_instance.html        |    75 +
 airflow/www_rbac/templates/airflow/ti_code.html |    43 +
 airflow/www_rbac/templates/airflow/ti_log.html  |    40 +
 .../www_rbac/templates/airflow/traceback.html   |    33 +
 airflow/www_rbac/templates/airflow/tree.html    |   381 +
 .../templates/airflow/variable_list.html        |    30 +
 airflow/www_rbac/templates/airflow/version.html |    30 +
 airflow/www_rbac/templates/airflow/xcom.html    |    37 +
 .../templates/appbuilder/baselayout.html        |    84 +
 .../www_rbac/templates/appbuilder/index.html    |    18 +
 .../www_rbac/templates/appbuilder/navbar.html   |    47 +
 .../templates/appbuilder/navbar_menu.html       |    57 +
 .../templates/appbuilder/navbar_right.html      |    64 +
 airflow/www_rbac/utils.py                       |   350 +
 airflow/www_rbac/validators.py                  |    54 +
 airflow/www_rbac/views.py                       |  2056 +++
 airflow/www_rbac/widgets.py                     |    19 +
 run_unit_tests.sh                               |     1 -
 scripts/ci/airflow_travis.cfg                   |     1 +
 setup.py                                        |     3 +-
 tests/core.py                                   |     4 +-
 tests/www_rbac/__init__.py                      |    13 +
 tests/www_rbac/api/__init__.py                  |    13 +
 tests/www_rbac/api/experimental/__init__.py     |    13 +
 .../www_rbac/api/experimental/test_endpoints.py |   302 +
 .../api/experimental/test_kerberos_endpoints.py |    97 +
 .../2017-09-01T00.00.00/1.log                   |     1 +
 tests/www_rbac/test_security.py                 |   118 +
 tests/www_rbac/test_utils.py                    |   109 +
 tests/www_rbac/test_validators.py               |    91 +
 tests/www_rbac/test_views.py                    |   405 +
 110 files changed, 36839 insertions(+), 39 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/MANIFEST.in
----------------------------------------------------------------------
diff --git a/MANIFEST.in b/MANIFEST.in
index 69ccafe..2ee9f90 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -18,6 +18,9 @@ include CHANGELOG.txt
 include README.md
 graft airflow/www/templates
 graft airflow/www/static
+graft airflow/www_rbac/static
+graft airflow/www_rbac/templates
+graft airflow/www_rbac/translations
 include airflow/alembic.ini
 graft scripts/systemd
 graft scripts/upstart

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/UPDATING.md
----------------------------------------------------------------------
diff --git a/UPDATING.md b/UPDATING.md
index 21581bf..0c1b0d4 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -5,6 +5,36 @@ assists people when migrating to a new version.
 
 ## Airflow Master
 
+### New Webserver UI with Role-Based Access Control
+
+Our current webserver UI uses the Flask-Admin extension. The new webserver UI uses the [Flask-AppBuilder (FAB)](https://github.com/dpgaspar/Flask-AppBuilder) extension. It has built-in authentication support and Role-Based Access Control (RBAC), which provides configurable roles and permissions for individual users.
+
+To turn on this feature, in your airflow.cfg file, under [webserver], set configuration variable `rbac = True`, and then run `airflow` command, which will generate the `webserver_config.py` file in your $AIRFLOW_HOME.
+
+#### Setting up Authentication
+
+FAB has built-in authentication support for DB, OAuth, OpenID, LDAP, and REMOTE_USER. The default auth type is `AUTH_DB`.
+
+For any other authentication type (OAuth, OpenID, LDAP, REMOTE_USER), see the [Authentication section of FAB docs](http://flask-appbuilder.readthedocs.io/en/latest/security.html#authentication-methods) for how to configure variables in webserver_config.py file.
+
+Once you modify your config file, run `airflow initdb` to generate new tables for RBAC support (these tables will have the prefix `ab_`).
+
+#### Creating an Admin Account
+
+Once you updated configuration settings and generated new tables, you need to create an admin account with `airflow create_user` command.
+
+#### Using your new UI
+
+Run `airflow webserver` as usual to start the new UI. This will bring you to a log in page, enter the admin username and password that were just created.
+
+There are five roles created for Airflow by default: Admin, User, Op, Viewer, and Public. To configure roles/permissions, go to the `Security` tab and click `List Roles` in the new UI.
+
+#### Breaking changes
+- Users created and stored in the old users table will not be migrated automatically. You will need to reconfigure with one of FAB's built-in authentication support.
+- Airflow dag home page is now `/home` (instead of `/admin`).
+- All ModelViews in Flask-AppBuilder follow a different pattern from Flask-Admin. The `/admin` part of the url path will no longer exist. For example: `/admin/connection` becomes `/connection/list`, `/admin/connection/new` becomes `/connection/add`, `/admin/connection/edit` becomes `/connection/edit`, etc.
+- Due to security concerns, the new webserver will no longer support the features in the `Data Profiling` menu of old UI, including `Ad Hoc Query`, `Charts`, and `Known Events`.
+
 ### MySQL setting required
 
 We now rely on more strict ANSI SQL settings for MySQL in order to have sane defaults. Make sure

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/bin/cli.py
----------------------------------------------------------------------
diff --git a/airflow/bin/cli.py b/airflow/bin/cli.py
index 1801cc7..8d7d419 100755
--- a/airflow/bin/cli.py
+++ b/airflow/bin/cli.py
@@ -40,6 +40,7 @@ import traceback
 import time
 import psutil
 import re
+import getpass
 from urllib.parse import urlunparse
 
 import airflow
@@ -58,6 +59,9 @@ from airflow.utils.net import get_hostname
 from airflow.utils.log.logging_mixin import (LoggingMixin, redirect_stderr,
                                              redirect_stdout)
 from airflow.www.app import (cached_app, create_app)
+from airflow.www_rbac.app import cached_app as cached_app_rbac
+from airflow.www_rbac.app import create_app as create_app_rbac
+from airflow.www_rbac.app import cached_appbuilder
 
 from sqlalchemy import func
 from sqlalchemy.orm import exc
@@ -730,11 +734,11 @@ def webserver(args):
         print(
             "Starting the web server on port {0} and host {1}.".format(
                 args.port, args.hostname))
-        app = create_app(conf)
+        app = create_app_rbac(conf) if settings.RBAC else create_app(conf)
         app.run(debug=True, port=args.port, host=args.hostname,
                 ssl_context=(ssl_cert, ssl_key) if ssl_cert and ssl_key else None)
     else:
-        app = cached_app(conf)
+        app = cached_app_rbac(conf) if settings.RBAC else cached_app(conf)
         pid, stdout, stderr, log_file = setup_locations(
             "webserver", args.pid, args.stdout, args.stderr, args.log_file)
         if args.daemon:
@@ -760,7 +764,7 @@ def webserver(args):
             '-b', args.hostname + ':' + str(args.port),
             '-n', 'airflow-webserver',
             '-p', str(pid),
-            '-c', 'python:airflow.www.gunicorn_config'
+            '-c', 'python:airflow.www.gunicorn_config',
         ]
 
         if args.access_logfile:
@@ -775,7 +779,8 @@ def webserver(args):
         if ssl_cert:
             run_args += ['--certfile', ssl_cert, '--keyfile', ssl_key]
 
-        run_args += ["airflow.www.app:cached_app()"]
+        webserver_module = 'www_rbac' if settings.RBAC else 'www'
+        run_args += ["airflow." + webserver_module + ".app:cached_app()"]
 
         gunicorn_master_proc = None
 
@@ -934,7 +939,7 @@ def worker(args):
 
 def initdb(args):  # noqa
     print("DB: " + repr(settings.engine.url))
-    db_utils.initdb()
+    db_utils.initdb(settings.RBAC)
     print("Done.")
 
 
@@ -943,7 +948,7 @@ def resetdb(args):
     if args.yes or input(
         "This will drop existing tables if they exist. "
         "Proceed? (y/n)").upper() == "Y":
-        db_utils.resetdb()
+        db_utils.resetdb(settings.RBAC)
     else:
         print("Bail.")
 
@@ -1139,7 +1144,11 @@ def kerberos(args):  # noqa
     import airflow.security.kerberos
 
     if args.daemon:
-        pid, stdout, stderr, log_file = setup_locations("kerberos", args.pid, args.stdout, args.stderr, args.log_file)
+        pid, stdout, stderr, log_file = setup_locations("kerberos",
+                                                        args.pid,
+                                                        args.stdout,
+                                                        args.stderr,
+                                                        args.log_file)
         stdout = open(stdout, 'w+')
         stderr = open(stderr, 'w+')
 
@@ -1158,6 +1167,39 @@ def kerberos(args):  # noqa
         airflow.security.kerberos.run()
 
 
+def create_user(args):
+    fields = {
+        'role': args.role,
+        'username': args.username,
+        'email': args.email,
+        'firstname': args.firstname,
+        'lastname': args.lastname,
+    }
+    empty_fields = [k for k, v in fields.items() if not v]
+    if empty_fields:
+        print('Missing arguments: {}.'.format(', '.join(empty_fields)))
+        sys.exit(0)
+
+    appbuilder = cached_appbuilder()
+    role = appbuilder.sm.find_role(args.role)
+    if not role:
+        print('{} is not a valid role.'.format(args.role))
+        sys.exit(0)
+
+    password = getpass.getpass('Password:')
+    password_confirmation = getpass.getpass('Repeat for confirmation:')
+    if password != password_confirmation:
+        print('Passwords did not match!')
+        sys.exit(0)
+
+    user = appbuilder.sm.add_user(args.username, args.firstname, args.lastname,
+                                  args.email, role, password)
+    if user:
+        print('{} user {} created.'.format(args.role, args.username))
+    else:
+        print('Failed to create user.')
+
+
 Arg = namedtuple(
     'Arg', ['flags', 'help', 'action', 'default', 'nargs', 'type', 'choices', 'metavar'])
 Arg.__new__.__defaults__ = (None, None, None, None, None, None, None)
@@ -1523,6 +1565,27 @@ class CLIFactory(object):
             ('--conn_extra',),
             help='Connection `Extra` field, optional when adding a connection',
             type=str),
+        # create_user
+        'role': Arg(
+            ('-r', '--role',),
+            help='Role of the user',
+            type=str),
+        'firstname': Arg(
+            ('-f', '--firstname',),
+            help='First name of the admin user',
+            type=str),
+        'lastname': Arg(
+            ('-l', '--lastname',),
+            help='Last name of the admin user',
+            type=str),
+        'email': Arg(
+            ('-e', '--email',),
+            help='Email of the admin user',
+            type=str),
+        'username': Arg(
+            ('-u', '--username',),
+            help='Username of the admin user',
+            type=str),
     }
     subparsers = (
         {
@@ -1660,6 +1723,10 @@ class CLIFactory(object):
             'help': "List/Add/Delete connections",
             'args': ('list_connections', 'add_connection', 'delete_connection',
                      'conn_id', 'conn_uri', 'conn_extra') + tuple(alternative_conn_specs),
+        }, {
+            'func': create_user,
+            'help': "Create an admin account",
+            'args': ('role', 'username', 'email', 'firstname', 'lastname'),
         },
     )
     subparsers_dict = {sp['func'].__name__: sp for sp in subparsers}

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/config_templates/airflow_local_settings.py
----------------------------------------------------------------------
diff --git a/airflow/config_templates/airflow_local_settings.py b/airflow/config_templates/airflow_local_settings.py
index 861c7a9..b72f999 100644
--- a/airflow/config_templates/airflow_local_settings.py
+++ b/airflow/config_templates/airflow_local_settings.py
@@ -22,6 +22,11 @@ from airflow import configuration as conf
 # settings.py and cli.py. Please see AIRFLOW-1455.
 LOG_LEVEL = conf.get('core', 'LOGGING_LEVEL').upper()
 
+
+# Flask appbuilder's info level log is very verbose,
+# so it's set to 'WARN' by default.
+FAB_LOG_LEVEL = 'WARN'
+
 LOG_FORMAT = conf.get('core', 'LOG_FORMAT')
 
 BASE_LOG_FOLDER = conf.get('core', 'BASE_LOG_FOLDER')
@@ -76,6 +81,11 @@ DEFAULT_LOGGING_CONFIG = {
             'level': LOG_LEVEL,
             'propagate': False,
         },
+        'flask_appbuilder': {
+            'handler': ['console'],
+            'level': FAB_LOG_LEVEL,
+            'propagate': True,
+        }
     },
     'root': {
         'handlers': ['console'],

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/config_templates/default_airflow.cfg
----------------------------------------------------------------------
diff --git a/airflow/config_templates/default_airflow.cfg b/airflow/config_templates/default_airflow.cfg
index 8f82208..cc3e89f 100644
--- a/airflow/config_templates/default_airflow.cfg
+++ b/airflow/config_templates/default_airflow.cfg
@@ -266,6 +266,10 @@ hide_paused_dags_by_default = False
 # Consistent page size across all listing views in the UI
 page_size = 100
 
+# Use FAB-based webserver with RBAC feature
+rbac = False
+
+
 [email]
 email_backend = airflow.utils.email.send_email_smtp
 

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/config_templates/default_test.cfg
----------------------------------------------------------------------
diff --git a/airflow/config_templates/default_test.cfg b/airflow/config_templates/default_test.cfg
index 9016c2b..2f7cbb8 100644
--- a/airflow/config_templates/default_test.cfg
+++ b/airflow/config_templates/default_test.cfg
@@ -64,6 +64,7 @@ dag_default_view = tree
 log_fetch_timeout_sec = 5
 hide_paused_dags_by_default = False
 page_size = 100
+rbac = False
 
 [email]
 email_backend = airflow.utils.email.send_email_smtp

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/config_templates/default_webserver_config.py
----------------------------------------------------------------------
diff --git a/airflow/config_templates/default_webserver_config.py b/airflow/config_templates/default_webserver_config.py
new file mode 100644
index 0000000..953e650
--- /dev/null
+++ b/airflow/config_templates/default_webserver_config.py
@@ -0,0 +1,84 @@
+# 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.
+
+import os
+from airflow import configuration as conf
+from flask_appbuilder.security.manager import AUTH_DB
+# from flask_appbuilder.security.manager import AUTH_LDAP
+# from flask_appbuilder.security.manager import AUTH_OAUTH
+# from flask_appbuilder.security.manager import AUTH_OID
+# from flask_appbuilder.security.manager import AUTH_REMOTE_USER
+basedir = os.path.abspath(os.path.dirname(__file__))
+
+# The SQLAlchemy connection string.
+SQLALCHEMY_DATABASE_URI = conf.get('core', 'SQL_ALCHEMY_CONN')
+
+# Flask-WTF flag for CSRF
+CSRF_ENABLED = True
+
+# ----------------------------------------------------
+# AUTHENTICATION CONFIG
+# ----------------------------------------------------
+# For details on how to set up each of the following authentication, see
+# http://flask-appbuilder.readthedocs.io/en/latest/security.html# authentication-methods
+# for details.
+
+# The authentication type
+# AUTH_OID : Is for OpenID
+# AUTH_DB : Is for database
+# AUTH_LDAP : Is for LDAP
+# AUTH_REMOTE_USER : Is for using REMOTE_USER from web server
+# AUTH_OAUTH : Is for OAuth
+AUTH_TYPE = AUTH_DB
+
+# Uncomment to setup Full admin role name
+# AUTH_ROLE_ADMIN = 'Admin'
+
+# Uncomment to setup Public role name, no authentication needed
+# AUTH_ROLE_PUBLIC = 'Public'
+
+# Will allow user self registration
+# AUTH_USER_REGISTRATION = True
+
+# The default user self registration role
+# AUTH_USER_REGISTRATION_ROLE = "Public"
+
+# When using OAuth Auth, uncomment to setup provider(s) info
+# Google OAuth example:
+# OAUTH_PROVIDERS = [{
+# 	'name':'google',
+#     'whitelist': ['@YOU_COMPANY_DOMAIN'],  # optional
+#     'token_key':'access_token',
+#     'icon':'fa-google',
+#         'remote_app': {
+#             'base_url':'https://www.googleapis.com/oauth2/v2/',
+#             'request_token_params':{
+#                 'scope': 'email profile'
+#             },
+#             'access_token_url':'https://accounts.google.com/o/oauth2/token',
+#             'authorize_url':'https://accounts.google.com/o/oauth2/auth',
+#             'request_token_url': None,
+#             'consumer_key': CONSUMER_KEY,
+#             'consumer_secret': SECRET_KEY,
+#         }
+# }]
+
+# When using LDAP Auth, setup the ldap server
+# AUTH_LDAP_SERVER = "ldap://ldapserver.new"
+
+# When using OpenID Auth, uncomment to setup OpenID providers.
+# example for OpenID authentication
+# OPENID_PROVIDERS = [
+#    { 'name': 'Yahoo', 'url': 'https://me.yahoo.com' },
+#    { 'name': 'AOL', 'url': 'http://openid.aol.com/<username>' },
+#    { 'name': 'Flickr', 'url': 'http://www.flickr.com/<username>' },
+#    { 'name': 'MyOpenID', 'url': 'https://www.myopenid.com' }]

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/configuration.py
----------------------------------------------------------------------
diff --git a/airflow/configuration.py b/airflow/configuration.py
index 30fa7fe..a14ca7d 100644
--- a/airflow/configuration.py
+++ b/airflow/configuration.py
@@ -422,6 +422,16 @@ log.info("Reading the config from %s", AIRFLOW_CONFIG)
 conf = AirflowConfigParser()
 conf.read(AIRFLOW_CONFIG)
 
+if conf.getboolean('webserver', 'rbac'):
+    with open(os.path.join(_templates_dir, 'default_webserver_config.py')) as f:
+        DEFAULT_WEBSERVER_CONFIG = f.read()
+
+    WEBSERVER_CONFIG = AIRFLOW_HOME + '/webserver_config.py'
+
+    if not os.path.isfile(WEBSERVER_CONFIG):
+        log.info('Creating new FAB webserver config file in: %s', WEBSERVER_CONFIG)
+        with open(WEBSERVER_CONFIG, 'w') as f:
+            f.write(DEFAULT_WEBSERVER_CONFIG)
 
 def load_test_config():
     """

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/models.py
----------------------------------------------------------------------
diff --git a/airflow/models.py b/airflow/models.py
index aa10ad5..baf101f 100755
--- a/airflow/models.py
+++ b/airflow/models.py
@@ -1047,26 +1047,43 @@ class TaskInstance(Base, LoggingMixin):
     def log_url(self):
         iso = quote(self.execution_date.isoformat())
         BASE_URL = configuration.get('webserver', 'BASE_URL')
-        return BASE_URL + (
-            "/admin/airflow/log"
-            "?dag_id={self.dag_id}"
-            "&task_id={self.task_id}"
-            "&execution_date={iso}"
-        ).format(**locals())
+        if settings.RBAC:
+            return BASE_URL + (
+                "/log/list/"
+                "?_flt_3_dag_id={self.dag_id}"
+                "&_flt_3_task_id={self.task_id}"
+                "&_flt_3_execution_date={iso}"
+            ).format(**locals())
+        else:
+            return BASE_URL + (
+                "/admin/airflow/log"
+                "?dag_id={self.dag_id}"
+                "&task_id={self.task_id}"
+                "&execution_date={iso}"
+            ).format(**locals())
 
     @property
     def mark_success_url(self):
         iso = quote(self.execution_date.isoformat())
         BASE_URL = configuration.get('webserver', 'BASE_URL')
-        return BASE_URL + (
-            "/admin/airflow/action"
-            "?action=success"
-            "&task_id={self.task_id}"
-            "&dag_id={self.dag_id}"
-            "&execution_date={iso}"
-            "&upstream=false"
-            "&downstream=false"
-        ).format(**locals())
+        if settings.RBAC:
+            return BASE_URL + (
+                "/success"
+                "?task_id={self.task_id}"
+                "&dag_id={self.dag_id}"
+                "&execution_date={iso}"
+                "&upstream=false"
+                "&downstream=false"
+            ).format(**locals())
+        else:
+            return BASE_URL + (
+                "/admin/airflow/success"
+                "?task_id={self.task_id}"
+                "&dag_id={self.dag_id}"
+                "&execution_date={iso}"
+                "&upstream=false"
+                "&downstream=false"
+            ).format(**locals())
 
     @provide_session
     def current_state(self, session=None):
@@ -4197,19 +4214,20 @@ class Variable(Base, LoggingMixin):
         return '{} : {}'.format(self.key, self._val)
 
     def get_val(self):
+        log = LoggingMixin().log
         if self._val and self.is_encrypted:
             try:
                 fernet = get_fernet()
             except:
-                raise AirflowException(
-                    "Can't decrypt _val for key={}, FERNET_KEY configuration \
-                    missing".format(self.key))
+                log.error("Can't decrypt _val for key={}, FERNET_KEY "
+                          "configuration missing".format(self.key))
+                return None
             try:
                 return fernet.decrypt(bytes(self._val, 'utf-8')).decode()
             except:
-                raise AirflowException(
-                    "Can't decrypt _val for key={}, invalid token or value"
-                    .format(self.key))
+                log.error("Can't decrypt _val for key={}, invalid token "
+                          "or value".format(self.key))
+                return None
         else:
             return self._val
 

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/settings.py
----------------------------------------------------------------------
diff --git a/airflow/settings.py b/airflow/settings.py
index 929663b..06748ce 100644
--- a/airflow/settings.py
+++ b/airflow/settings.py
@@ -32,6 +32,7 @@ from airflow.utils.sqlalchemy import setup_event_handlers
 
 log = logging.getLogger(__name__)
 
+RBAC = conf.getboolean('webserver', 'rbac')
 
 TIMEZONE = pendulum.timezone('UTC')
 try:
@@ -84,7 +85,6 @@ ___  ___ |  / _  /   _  __/ _  / / /_/ /_ |/ |/ /
  _/_/  |_/_/  /_/    /_/    /_/  \____/____/|__/
  """
 
-BASE_LOG_URL = '/admin/airflow/log'
 LOGGING_LEVEL = logging.INFO
 
 # the prefix to append to gunicorn worker processes after init

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/utils/db.py
----------------------------------------------------------------------
diff --git a/airflow/utils/db.py b/airflow/utils/db.py
index 6c7f3c0..ed8aa4b 100644
--- a/airflow/utils/db.py
+++ b/airflow/utils/db.py
@@ -80,7 +80,7 @@ def merge_conn(conn, session=None):
         session.commit()
 
 
-def initdb():
+def initdb(rbac):
     session = settings.Session()
 
     from airflow import models
@@ -303,6 +303,11 @@ def initdb():
         session.add(chart)
         session.commit()
 
+    if rbac:
+        from flask_appbuilder.security.sqla import models
+        from flask_appbuilder.models.sqla import Base
+        Base.metadata.create_all(settings.engine)
+
 
 def upgradedb():
     # alembic adds significant import time, so we import it lazily
@@ -320,11 +325,12 @@ def upgradedb():
     command.upgrade(config, 'heads')
 
 
-def resetdb():
+def resetdb(rbac):
     '''
     Clear out the database
     '''
     from airflow import models
+
     # alembic adds significant import time, so we import it lazily
     from alembic.migration import MigrationContext
 
@@ -334,4 +340,11 @@ def resetdb():
     mc = MigrationContext.configure(settings.engine)
     if mc._version.exists(settings.engine):
         mc._version.drop(settings.engine)
-    initdb()
+
+    if rbac:
+        # drop rbac security tables
+        from flask_appbuilder.security.sqla import models
+        from flask_appbuilder.models.sqla import Base
+        Base.metadata.drop_all(settings.engine)
+
+    initdb(rbac)

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www/views.py
----------------------------------------------------------------------
diff --git a/airflow/www/views.py b/airflow/www/views.py
index 2f56175..83e567a 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -2303,9 +2303,10 @@ class VariableView(wwwutils.DataProfilingMixin, AirflowModelView):
     def hidden_field_formatter(view, context, model, name):
         if wwwutils.should_hide_value_for_key(model.key):
             return Markup('*' * 8)
-        try:
-            return getattr(model, name)
-        except AirflowException:
+        val = getattr(model, name)
+        if val:
+            return val
+        else:
             return Markup('<span class="label label-danger">Invalid</span>')
 
     form_columns = (

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/__init__.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/__init__.py b/airflow/www_rbac/__init__.py
new file mode 100644
index 0000000..9d7677a
--- /dev/null
+++ b/airflow/www_rbac/__init__.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+#
+# 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.

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/api/__init__.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/api/__init__.py b/airflow/www_rbac/api/__init__.py
new file mode 100644
index 0000000..759b563
--- /dev/null
+++ b/airflow/www_rbac/api/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+#

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/api/experimental/__init__.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/api/experimental/__init__.py b/airflow/www_rbac/api/experimental/__init__.py
new file mode 100644
index 0000000..759b563
--- /dev/null
+++ b/airflow/www_rbac/api/experimental/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+#

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/api/experimental/endpoints.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/api/experimental/endpoints.py b/airflow/www_rbac/api/experimental/endpoints.py
new file mode 100644
index 0000000..31cd958
--- /dev/null
+++ b/airflow/www_rbac/api/experimental/endpoints.py
@@ -0,0 +1,231 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+import airflow.api
+
+from airflow.api.common.experimental import pool as pool_api
+from airflow.api.common.experimental import trigger_dag as trigger
+from airflow.api.common.experimental.get_task import get_task
+from airflow.api.common.experimental.get_task_instance import get_task_instance
+from airflow.exceptions import AirflowException
+from airflow.utils.log.logging_mixin import LoggingMixin
+from airflow.utils import timezone
+from airflow.www_rbac.app import csrf
+
+from flask import g, Blueprint, jsonify, request, url_for
+
+_log = LoggingMixin().log
+
+requires_authentication = airflow.api.api_auth.requires_authentication
+
+api_experimental = Blueprint('api_experimental', __name__)
+
+
+@csrf.exempt
+@api_experimental.route('/dags/<string:dag_id>/dag_runs', methods=['POST'])
+@requires_authentication
+def trigger_dag(dag_id):
+    """
+    Trigger a new dag run for a Dag with an execution date of now unless
+    specified in the data.
+    """
+    data = request.get_json(force=True)
+
+    run_id = None
+    if 'run_id' in data:
+        run_id = data['run_id']
+
+    conf = None
+    if 'conf' in data:
+        conf = data['conf']
+
+    execution_date = None
+    if 'execution_date' in data and data['execution_date'] is not None:
+        execution_date = data['execution_date']
+
+        # Convert string datetime into actual datetime
+        try:
+            execution_date = timezone.parse(execution_date)
+        except ValueError:
+            error_message = (
+                'Given execution date, {}, could not be identified '
+                'as a date. Example date format: 2015-11-16T14:34:15+00:00'
+                .format(execution_date))
+            _log.info(error_message)
+            response = jsonify({'error': error_message})
+            response.status_code = 400
+
+            return response
+
+    try:
+        dr = trigger.trigger_dag(dag_id, run_id, conf, execution_date)
+    except AirflowException as err:
+        _log.error(err)
+        response = jsonify(error="{}".format(err))
+        response.status_code = 404
+        return response
+
+    if getattr(g, 'user', None):
+        _log.info("User {} created {}".format(g.user, dr))
+
+    response = jsonify(message="Created {}".format(dr))
+    return response
+
+
+@api_experimental.route('/test', methods=['GET'])
+@requires_authentication
+def test():
+    return jsonify(status='OK')
+
+
+@api_experimental.route('/dags/<string:dag_id>/tasks/<string:task_id>', methods=['GET'])
+@requires_authentication
+def task_info(dag_id, task_id):
+    """Returns a JSON with a task's public instance variables. """
+    try:
+        info = get_task(dag_id, task_id)
+    except AirflowException as err:
+        _log.info(err)
+        response = jsonify(error="{}".format(err))
+        response.status_code = 404
+        return response
+
+    # JSONify and return.
+    fields = {k: str(v)
+              for k, v in vars(info).items()
+              if not k.startswith('_')}
+    return jsonify(fields)
+
+
+@api_experimental.route(
+    '/dags/<string:dag_id>/dag_runs/<string:execution_date>/tasks/<string:task_id>',
+    methods=['GET'])
+@requires_authentication
+def task_instance_info(dag_id, execution_date, task_id):
+    """
+    Returns a JSON with a task instance's public instance variables.
+    The format for the exec_date is expected to be
+    "YYYY-mm-DDTHH:MM:SS", for example: "2016-11-16T11:34:15". This will
+    of course need to have been encoded for URL in the request.
+    """
+
+    # Convert string datetime into actual datetime
+    try:
+        execution_date = timezone.parse(execution_date)
+    except ValueError:
+        error_message = (
+            'Given execution date, {}, could not be identified '
+            'as a date. Example date format: 2015-11-16T14:34:15+00:00'
+            .format(execution_date))
+        _log.info(error_message)
+        response = jsonify({'error': error_message})
+        response.status_code = 400
+
+        return response
+
+    try:
+        info = get_task_instance(dag_id, task_id, execution_date)
+    except AirflowException as err:
+        _log.info(err)
+        response = jsonify(error="{}".format(err))
+        response.status_code = 404
+        return response
+
+    # JSONify and return.
+    fields = {k: str(v)
+              for k, v in vars(info).items()
+              if not k.startswith('_')}
+    return jsonify(fields)
+
+
+@api_experimental.route('/latest_runs', methods=['GET'])
+@requires_authentication
+def latest_dag_runs():
+    """Returns the latest DagRun for each DAG formatted for the UI. """
+    from airflow.models import DagRun
+    dagruns = DagRun.get_latest_runs()
+    payload = []
+    for dagrun in dagruns:
+        if dagrun.execution_date:
+            payload.append({
+                'dag_id': dagrun.dag_id,
+                'execution_date': dagrun.execution_date.isoformat(),
+                'start_date': ((dagrun.start_date or '') and
+                               dagrun.start_date.isoformat()),
+                'dag_run_url': url_for('Airflow.graph', dag_id=dagrun.dag_id,
+                                       execution_date=dagrun.execution_date)
+            })
+    return jsonify(items=payload)  # old flask versions dont support jsonifying arrays
+
+
+@api_experimental.route('/pools/<string:name>', methods=['GET'])
+@requires_authentication
+def get_pool(name):
+    """Get pool by a given name."""
+    try:
+        pool = pool_api.get_pool(name=name)
+    except AirflowException as e:
+        _log.error(e)
+        response = jsonify(error="{}".format(e))
+        response.status_code = getattr(e, 'status', 500)
+        return response
+    else:
+        return jsonify(pool.to_json())
+
+
+@api_experimental.route('/pools', methods=['GET'])
+@requires_authentication
+def get_pools():
+    """Get all pools."""
+    try:
+        pools = pool_api.get_pools()
+    except AirflowException as e:
+        _log.error(e)
+        response = jsonify(error="{}".format(e))
+        response.status_code = getattr(e, 'status', 500)
+        return response
+    else:
+        return jsonify([p.to_json() for p in pools])
+
+
+@csrf.exempt
+@api_experimental.route('/pools', methods=['POST'])
+@requires_authentication
+def create_pool():
+    """Create a pool."""
+    params = request.get_json(force=True)
+    try:
+        pool = pool_api.create_pool(**params)
+    except AirflowException as e:
+        _log.error(e)
+        response = jsonify(error="{}".format(e))
+        response.status_code = getattr(e, 'status', 500)
+        return response
+    else:
+        return jsonify(pool.to_json())
+
+
+@csrf.exempt
+@api_experimental.route('/pools/<string:name>', methods=['DELETE'])
+@requires_authentication
+def delete_pool(name):
+    """Delete pool."""
+    try:
+        pool = pool_api.delete_pool(name=name)
+    except AirflowException as e:
+        _log.error(e)
+        response = jsonify(error="{}".format(e))
+        response.status_code = getattr(e, 'status', 500)
+        return response
+    else:
+        return jsonify(pool.to_json())

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/app.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/app.py b/airflow/www_rbac/app.py
new file mode 100644
index 0000000..9f4e142
--- /dev/null
+++ b/airflow/www_rbac/app.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+#
+import socket
+import six
+
+from flask import Flask
+from flask_appbuilder import AppBuilder, SQLA
+from flask_caching import Cache
+from flask_wtf.csrf import CSRFProtect
+from six.moves.urllib.parse import urlparse
+from werkzeug.wsgi import DispatcherMiddleware
+
+from airflow import settings
+from airflow import configuration as conf
+from airflow.logging_config import configure_logging
+
+
+app = None
+appbuilder = None
+csrf = CSRFProtect()
+
+
+def create_app(config=None, testing=False, app_name="Airflow"):
+    global app, appbuilder
+    app = Flask(__name__)
+    app.secret_key = conf.get('webserver', 'SECRET_KEY')
+
+    airflow_home_path = conf.get('core', 'AIRFLOW_HOME')
+    webserver_config_path = airflow_home_path + '/webserver_config.py'
+    app.config.from_pyfile(webserver_config_path, silent=True)
+    app.config['APP_NAME'] = app_name
+    app.config['TESTING'] = testing
+
+    csrf.init_app(app)
+
+    db = SQLA(app)
+
+    from airflow import api
+    api.load_auth()
+    api.api_auth.init_app(app)
+
+    cache = Cache(app=app, config={'CACHE_TYPE': 'filesystem', 'CACHE_DIR': '/tmp'})  # noqa
+
+    from airflow.www_rbac.blueprints import routes
+    app.register_blueprint(routes)
+
+    configure_logging()
+
+    with app.app_context():
+        appbuilder = AppBuilder(
+            app,
+            db.session,
+            security_manager_class=app.config.get('SECURITY_MANAGER_CLASS'),
+            base_template='appbuilder/baselayout.html')
+
+        def init_views(appbuilder):
+            from airflow.www_rbac import views
+            appbuilder.add_view_no_menu(views.Airflow())
+            appbuilder.add_view_no_menu(views.DagModelView())
+            appbuilder.add_view_no_menu(views.ConfigurationView())
+            appbuilder.add_view_no_menu(views.VersionView())
+            appbuilder.add_view(views.DagRunModelView,
+                                "DAG Runs",
+                                category="Browse",
+                                category_icon="fa-globe")
+            appbuilder.add_view(views.JobModelView,
+                                "Jobs",
+                                category="Browse")
+            appbuilder.add_view(views.LogModelView,
+                                "Logs",
+                                category="Browse")
+            appbuilder.add_view(views.SlaMissModelView,
+                                "SLA Misses",
+                                category="Browse")
+            appbuilder.add_view(views.TaskInstanceModelView,
+                                "Task Instances",
+                                category="Browse")
+            appbuilder.add_link("Configurations",
+                                href='/configuration',
+                                category="Admin",
+                                category_icon="fa-user")
+            appbuilder.add_view(views.ConnectionModelView,
+                                "Connections",
+                                category="Admin")
+            appbuilder.add_view(views.PoolModelView,
+                                "Pools",
+                                category="Admin")
+            appbuilder.add_view(views.VariableModelView,
+                                "Variables",
+                                category="Admin")
+            appbuilder.add_view(views.XComModelView,
+                                "XComs",
+                                category="Admin")
+            appbuilder.add_link("Documentation",
+                                href='https://airflow.apache.org/',
+                                category="Docs",
+                                category_icon="fa-cube")
+            appbuilder.add_link("Github",
+                                href='https://github.com/apache/incubator-airflow',
+                                category="Docs")
+            appbuilder.add_link('Version',
+                                href='/version',
+                                category='About',
+                                category_icon='fa-th')
+
+            # Garbage collect old permissions/views after they have been modified.
+            # Otherwise, when the name of a view or menu is changed, the framework
+            # will add the new Views and Menus names to the backend, but will not
+            # delete the old ones.
+            appbuilder.security_cleanup()
+
+        init_views(appbuilder)
+
+        from airflow.www_rbac.security import init_roles
+        init_roles(appbuilder)
+
+        from airflow.www_rbac.api.experimental import endpoints as e
+        # required for testing purposes otherwise the module retains
+        # a link to the default_auth
+        if app.config['TESTING']:
+            if six.PY2:
+                reload(e) # noqa
+            else:
+                import importlib
+                importlib.reload(e)
+
+        app.register_blueprint(e.api_experimental, url_prefix='/api/experimental')
+
+        @app.context_processor
+        def jinja_globals():
+            return {
+                'hostname': socket.getfqdn(),
+            }
+
+        @app.teardown_appcontext
+        def shutdown_session(exception=None):
+            settings.Session.remove()
+
+    return app, appbuilder
+
+
+def root_app(env, resp):
+    resp(b'404 Not Found', [(b'Content-Type', b'text/plain')])
+    return [b'Apache Airflow is not at this location']
+
+
+def cached_app(config=None, testing=False):
+    global app, appbuilder
+    if not app or not appbuilder:
+        base_url = urlparse(conf.get('webserver', 'base_url'))[2]
+        if not base_url or base_url == '/':
+            base_url = ""
+
+        app, _ = create_app(config, testing)
+        app = DispatcherMiddleware(root_app, {base_url: app})
+    return app
+
+
+def cached_appbuilder(config=None, testing=False):
+    global appbuilder
+    cached_app(config, testing)
+    return appbuilder

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/blueprints.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/blueprints.py b/airflow/www_rbac/blueprints.py
new file mode 100644
index 0000000..43ae838
--- /dev/null
+++ b/airflow/www_rbac/blueprints.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+#
+from flask import Markup, Blueprint, redirect
+import markdown
+
+routes = Blueprint('routes', __name__)
+
+
+@routes.route('/')
+def index():
+    return redirect('/home')
+
+
+@routes.route('/health')
+def health():
+    """ We can add an array of tests here to check the server's health """
+    content = Markup(markdown.markdown("The server is healthy!"))
+    return content

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/decorators.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/decorators.py b/airflow/www_rbac/decorators.py
new file mode 100644
index 0000000..22da021
--- /dev/null
+++ b/airflow/www_rbac/decorators.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+
+import gzip
+import functools
+import pendulum
+from io import BytesIO as IO
+from flask import after_this_request, request, g
+from airflow import models, settings
+
+
+def action_logging(f):
+    '''
+    Decorator to log user actions
+    '''
+    @functools.wraps(f)
+    def wrapper(*args, **kwargs):
+        session = settings.Session()
+        if g.user.is_anonymous():
+            user = 'anonymous'
+        else:
+            user = g.user.username
+
+        log = models.Log(
+            event=f.__name__,
+            task_instance=None,
+            owner=user,
+            extra=str(list(request.args.items())),
+            task_id=request.args.get('task_id'),
+            dag_id=request.args.get('dag_id'))
+
+        if 'execution_date' in request.args:
+            log.execution_date = pendulum.parse(
+                request.args.get('execution_date'))
+
+        session.add(log)
+        session.commit()
+
+        return f(*args, **kwargs)
+
+    return wrapper
+
+
+def gzipped(f):
+    '''
+    Decorator to make a view compressed
+    '''
+    @functools.wraps(f)
+    def view_func(*args, **kwargs):
+        @after_this_request
+        def zipper(response):
+            accept_encoding = request.headers.get('Accept-Encoding', '')
+
+            if 'gzip' not in accept_encoding.lower():
+                return response
+
+            response.direct_passthrough = False
+
+            if (response.status_code < 200 or response.status_code >= 300 or
+                    'Content-Encoding' in response.headers):
+                return response
+            gzip_buffer = IO()
+            gzip_file = gzip.GzipFile(mode='wb',
+                                      fileobj=gzip_buffer)
+            gzip_file.write(response.data)
+            gzip_file.close()
+
+            response.data = gzip_buffer.getvalue()
+            response.headers['Content-Encoding'] = 'gzip'
+            response.headers['Vary'] = 'Accept-Encoding'
+            response.headers['Content-Length'] = len(response.data)
+
+            return response
+
+        return f(*args, **kwargs)
+
+    return view_func

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/forms.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/forms.py b/airflow/www_rbac/forms.py
new file mode 100644
index 0000000..2faef3b
--- /dev/null
+++ b/airflow/www_rbac/forms.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+#
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+from __future__ import unicode_literals
+
+from airflow import models
+from airflow.utils import timezone
+
+from flask_appbuilder.forms import DynamicForm
+from flask_appbuilder.fieldwidgets import (BS3TextFieldWidget, BS3TextAreaFieldWidget,
+                                           BS3PasswordFieldWidget, Select2Widget,
+                                           DateTimePickerWidget)
+from flask_babel import lazy_gettext
+from flask_wtf import Form
+
+from wtforms import validators
+from wtforms.fields import (IntegerField, SelectField, TextAreaField, PasswordField,
+                            StringField, DateTimeField, BooleanField)
+
+
+class DateTimeForm(Form):
+    # Date filter form needed for gantt and graph view
+    execution_date = DateTimeField(
+        "Execution date", widget=DateTimePickerWidget())
+
+
+class DateTimeWithNumRunsForm(Form):
+    # Date time and number of runs form for tree view, task duration
+    # and landing times
+    base_date = DateTimeField(
+        "Anchor date", widget=DateTimePickerWidget(), default=timezone.utcnow())
+    num_runs = SelectField("Number of runs", default=25, choices=(
+        (5, "5"),
+        (25, "25"),
+        (50, "50"),
+        (100, "100"),
+        (365, "365"),
+    ))
+
+
+class DagRunForm(DynamicForm):
+    dag_id = StringField(
+        lazy_gettext('Dag Id'),
+        validators=[validators.DataRequired()],
+        widget=BS3TextFieldWidget())
+    start_date = DateTimeField(
+        lazy_gettext('Start Date'),
+        widget=DateTimePickerWidget())
+    end_date = DateTimeField(
+        lazy_gettext('End Date'),
+        widget=DateTimePickerWidget())
+    run_id = StringField(
+        lazy_gettext('Run Id'),
+        widget=BS3TextFieldWidget())
+    state = SelectField(
+        lazy_gettext('State'),
+        choices=(('success', 'success'), ('running', 'running'), ('failed', 'failed'),),
+        widget=Select2Widget())
+    execution_date = DateTimeField(
+        lazy_gettext('Execution Date'),
+        widget=DateTimePickerWidget())
+    external_trigger = BooleanField(
+        lazy_gettext('External Trigger'))
+
+
+class ConnectionForm(DynamicForm):
+    conn_id = StringField(
+        lazy_gettext('Conn Id'),
+        widget=BS3TextFieldWidget())
+    conn_type = SelectField(
+        lazy_gettext('Conn Type'),
+        choices=(models.Connection._types),
+        widget=Select2Widget())
+    host = StringField(
+        lazy_gettext('Host'),
+        widget=BS3TextFieldWidget())
+    schema = StringField(
+        lazy_gettext('Schema'),
+        widget=BS3TextFieldWidget())
+    login = StringField(
+        lazy_gettext('Login'),
+        widget=BS3TextFieldWidget())
+    password = PasswordField(
+        lazy_gettext('Password'),
+        widget=BS3PasswordFieldWidget())
+    port = IntegerField(
+        lazy_gettext('Port'),
+        validators=[validators.Optional()],
+        widget=BS3TextFieldWidget())
+    extra = TextAreaField(
+        lazy_gettext('Extra'),
+        widget=BS3TextAreaFieldWidget())
+
+    # Used to customized the form, the forms elements get rendered
+    # and results are stored in the extra field as json. All of these
+    # need to be prefixed with extra__ and then the conn_type ___ as in
+    # extra__{conn_type}__name. You can also hide form elements and rename
+    # others from the connection_form.js file
+    extra__jdbc__drv_path = StringField(
+        lazy_gettext('Driver Path'),
+        widget=BS3TextFieldWidget())
+    extra__jdbc__drv_clsname = StringField(
+        lazy_gettext('Driver Class'),
+        widget=BS3TextFieldWidget())
+    extra__google_cloud_platform__project = StringField(
+        lazy_gettext('Project Id'),
+        widget=BS3TextFieldWidget())
+    extra__google_cloud_platform__key_path = StringField(
+        lazy_gettext('Keyfile Path'),
+        widget=BS3TextFieldWidget())
+    extra__google_cloud_platform__keyfile_dict = PasswordField(
+        lazy_gettext('Keyfile JSON'),
+        widget=BS3PasswordFieldWidget())
+    extra__google_cloud_platform__scope = StringField(
+        lazy_gettext('Scopes (comma separated)'),
+        widget=BS3TextFieldWidget())

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/security.py
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/security.py b/airflow/www_rbac/security.py
new file mode 100644
index 0000000..49806c1
--- /dev/null
+++ b/airflow/www_rbac/security.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+#
+# 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.
+
+from flask_appbuilder.security.sqla import models as sqla_models
+
+###########################################################################
+#                               VIEW MENUS
+###########################################################################
+viewer_vms = [
+    'Airflow',
+    'DagModelView',
+    'Browse',
+    'DAG Runs',
+    'DagRunModelView',
+    'Task Instances',
+    'TaskInstanceModelView',
+    'SLA Misses',
+    'SlaMissModelView',
+    'Jobs',
+    'JobModelView',
+    'Logs',
+    'LogModelView',
+    'Docs',
+    'Documentation',
+    'Github',
+    'About',
+    'Version',
+    'VersionView',
+]
+
+user_vms = viewer_vms
+
+op_vms = [
+    'Admin',
+    'Configurations',
+    'ConfigurationView',
+    'Connections',
+    'ConnectionModelView',
+    'Pools',
+    'PoolModelView',
+    'Variables',
+    'VariableModelView',
+    'XComs',
+    'XComModelView',
+]
+
+###########################################################################
+#                               PERMISSIONS
+###########################################################################
+
+viewer_perms = [
+    'menu_access',
+    'can_index',
+    'can_list',
+    'can_show',
+    'can_chart',
+    'can_dag_stats',
+    'can_dag_details',
+    'can_task_stats',
+    'can_code',
+    'can_log',
+    'can_tries',
+    'can_graph',
+    'can_tree',
+    'can_task',
+    'can_task_instances',
+    'can_xcom',
+    'can_gantt',
+    'can_landing_times',
+    'can_duration',
+    'can_blocked',
+    'can_rendered',
+    'can_pickle_info',
+    'can_version',
+]
+
+user_perms = [
+    'can_dagrun_clear',
+    'can_run',
+    'can_trigger',
+    'can_add',
+    'can_edit',
+    'can_delete',
+    'can_paused',
+    'can_refresh',
+    'can_success',
+    'muldelete',
+    'set_failed',
+    'set_running',
+    'set_success',
+    'clear',
+]
+
+op_perms = [
+    'can_conf',
+    'can_varimport',
+]
+
+###########################################################################
+#                     DEFAULT ROLE CONFIGURATIONS
+###########################################################################
+
+ROLE_CONFIGS = [
+    {
+        'role': 'Viewer',
+        'perms': viewer_perms,
+        'vms': viewer_vms,
+    },
+    {
+        'role': 'User',
+        'perms': viewer_perms + user_perms,
+        'vms': viewer_vms + user_vms,
+    },
+    {
+        'role': 'Op',
+        'perms': viewer_perms + user_perms + op_perms,
+        'vms': viewer_vms + user_vms + op_vms,
+    },
+]
+
+
+def init_role(sm, role_name, role_vms, role_perms):
+    sm_session = sm.get_session
+    pvms = sm_session.query(sqla_models.PermissionView).all()
+    pvms = [p for p in pvms if p.permission and p.view_menu]
+
+    valid_perms = [p.permission.name for p in pvms]
+    valid_vms = [p.view_menu.name for p in pvms]
+    invalid_perms = [p for p in role_perms if p not in valid_perms]
+    if invalid_perms:
+        raise Exception('The following permissions are not valid: {}'
+                        .format(invalid_perms))
+    invalid_vms = [v for v in role_vms if v not in valid_vms]
+    if invalid_vms:
+        raise Exception('The following view menus are not valid: {}'
+                        .format(invalid_vms))
+
+    role = sm.add_role(role_name)
+    role_pvms = []
+    for pvm in pvms:
+        if pvm.view_menu.name in role_vms and pvm.permission.name in role_perms:
+            role_pvms.append(pvm)
+    role_pvms = list(set(role_pvms))
+    role.permissions = role_pvms
+    sm_session.merge(role)
+    sm_session.commit()
+
+
+def init_roles(appbuilder):
+    for config in ROLE_CONFIGS:
+        name = config['role']
+        vms = config['vms']
+        perms = config['perms']
+        init_role(appbuilder.sm, name, vms, perms)
+
+
+def is_view_only(user, appbuilder):
+    if user.is_anonymous():
+        anonymous_role = appbuilder.sm.auth_role_public
+        return anonymous_role == 'Viewer'
+
+    user_roles = user.roles
+    return len(user_roles) == 1 and user_roles[0].name == 'Viewer'

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/05e1861e/airflow/www_rbac/static/airflow.gif
----------------------------------------------------------------------
diff --git a/airflow/www_rbac/static/airflow.gif b/airflow/www_rbac/static/airflow.gif
new file mode 100644
index 0000000..1889b86
Binary files /dev/null and b/airflow/www_rbac/static/airflow.gif differ