Skip to content

Commit

Permalink
Merge pull request #133 from EverythingMe/feature_roles
Browse files Browse the repository at this point in the history
Feature: basic permissions system
  • Loading branch information
arikfr committed Mar 12, 2014
2 parents 08b6141 + 97b163b commit cb74a2c
Show file tree
Hide file tree
Showing 12 changed files with 126 additions and 24 deletions.
10 changes: 7 additions & 3 deletions manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,18 @@ def drop_tables():

@users_manager.option('email', help="User's email")
@users_manager.option('name', help="User's full name")
@users_manager.option('--admin', dest='is_admin', default=False, help="set user as admin")
@users_manager.option('--google', dest='google_auth', default=False, help="user uses Google Auth to login")
@users_manager.option('--admin', dest='is_admin', action="store_true", default=False, help="set user as admin")
@users_manager.option('--google', dest='google_auth', action="store_true", default=False, help="user uses Google Auth to login")
def create(email, name, is_admin=False, google_auth=False):
print "Creating user (%s, %s)..." % (email, name)
print "Admin: %r" % is_admin
print "Login with Google Auth: %r\n" % google_auth

user = models.User(email=email, name=name, is_admin=is_admin)
permissions = models.User.DEFAULT_PERMISSIONS
if is_admin:
permissions += ['admin']

user = models.User(email=email, name=name, permissions=permissions)
if not google_auth:
password = prompt_pass("Password")
user.hash_password(password)
Expand Down
13 changes: 13 additions & 0 deletions migrations/add_permissions_to_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from playhouse.migrate import Migrator
from redash import db
from redash import models


if __name__ == '__main__':
db.connect_db()
migrator = Migrator(db.database)
with db.database.transaction():
migrator.add_column(models.User, models.User.permissions, 'permissions')
models.User.update(permissions=['admin'] + models.User.DEFAULT_PERMISSIONS).where(models.User.is_admin == True).execute()

db.close_db(None)
10 changes: 7 additions & 3 deletions rd_ui/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@
<li ng-repeat="dashboard in otherDashboards">
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
</li>
<li class="divider"></li>
<li><a data-toggle="modal" href="#new_dashboard_dialog">New Dashboard</a></li>
<li class="divider" ng-show="currentUser.hasPermission('create_dashboard')"></li>
<li><a data-toggle="modal" href="#new_dashboard_dialog" ng-show="currentUser.hasPermission('create_dashboard')">New Dashboard</a></li>
</ul>
</li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Queries <b class="caret"></b></a>
<ul class="dropdown-menu">
<li><a href="/queries/new">New Query</a></li>
<li ng-show="currentUser.hasPermission('create_query')"><a href="/queries/new">New Query</a></li>
<li><a href="/queries">Queries</a></li>
</ul>
</li>
Expand Down Expand Up @@ -135,6 +135,10 @@
return user_id && (user_id == currentUser.id);
};

currentUser.hasPermission = function(permission) {
return this.permissions.indexOf(permission) != -1;
}

{{ analytics|safe }}
</script>

Expand Down
2 changes: 1 addition & 1 deletion rd_ui/app/scripts/services/dashboards.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
var Dashboard = function($resource) {
var resource = $resource('/api/dashboards/:slug', {slug: '@slug'});
resource.prototype.canEdit = function() {
return currentUser.is_admin || currentUser.canEdit(this);
return currentUser.hasPermission('admin') || currentUser.canEdit(this);
}
return resource;
}
Expand Down
4 changes: 2 additions & 2 deletions rd_ui/app/views/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ <h2>Dashboards</h2>
<div class="list-group" ng-repeat="(name, dashboards) in allDashboards">
<div class="list-group-item active">
{{name}}
<button type="button" class="btn btn-sm btn-link" data-toggle="modal" href="#new_dashboard_dialog" tooltip="New Dashboard"><span class="glyphicon glyphicon-plus-sign"></span></button>
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-sm btn-link" data-toggle="modal" href="#new_dashboard_dialog" tooltip="New Dashboard"><span class="glyphicon glyphicon-plus-sign"></span></button>
</div>
<div class="list-group-item" ng-repeat="dashboard in dashboards" >
<button type="button" class="close delete-button" aria-hidden="true" ng-show="dashboard.canEdit()" ng-click="archiveDashboard(dashboard)" tooltip="Delete Dashboard">&times;</button>
<a ng-href="/dashboard/{{dashboard.slug}}">{{dashboard.name}}</a>
</div>
</div>

<div ng-show="currentUser.is_admin">
<div ng-show="currentUser.hasPermission('admin')">
<div class="list-group">
<div class="list-group-item active">Admin</div>
<a href="/admin/status" class="list-group-item">Status</a>
Expand Down
2 changes: 1 addition & 1 deletion rd_ui/app/views/queryview.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ <h2>
</em>
</p>
</div>
<div class="col-lg-2" ng-hide="isNewQuery">
<div class="col-lg-2" ng-hide="isNewQuery || !currentUser.hasPermission('view_source')">
<a ng-href="{{sourceHref}}" ng-click="toggleSource()" class="hidden-xs pull-right">
<span ng-show="isSourceVisible">Hide Source</span>
<span ng-show="!isSourceVisible">View Source</span>
Expand Down
15 changes: 15 additions & 0 deletions rd_ui/app/views/visualizations/cohort_editor.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="form-group">
<label class="control-label">Time Label</label>
<input type="text" class="form-control" ng-model="cohortOptions.timeLabel">
<label class="control-label">People Label</label>
<input type="text" class="form-control" ng-model="cohortOptions.peopleLabel">

<label class="control-label">Bucket Column</label>
<select ng-model="bucket_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
<label class="control-label">Bucket Total Value Column</label>
<select ng-model="total_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
<label class="control-label">Day Number Column</label>
<select ng-model="value_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
<label class="control-label">Day Value Column</label>
<select ng-model="day_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
</div>
18 changes: 10 additions & 8 deletions redash/authentication.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import functools
import hashlib
import hmac
from flask import current_app, request, make_response, g, redirect, url_for
from flask.ext.googleauth import GoogleAuth, login
from flask.ext.login import LoginManager, login_user, current_user
import time
import logging

from flask import request, make_response, redirect, url_for
from flask.ext.googleauth import GoogleAuth, login
from flask.ext.login import LoginManager, login_user, current_user
from werkzeug.contrib.fixers import ProxyFix

from models import AnonymousUser
from redash import models, settings


login_manager = LoginManager()
logger = logging.getLogger('authentication')


def sign(key, path, expires):
if not key:
return None
Expand Down Expand Up @@ -39,14 +44,10 @@ def api_key_authentication():

return False

@staticmethod
def is_user_logged_in():
return current_user.is_authenticated()

def required(self, fn):
@functools.wraps(fn)
def decorated(*args, **kwargs):
if self.is_user_logged_in():
if current_user.is_authenticated():
return fn(*args, **kwargs)

if self.api_key_authentication():
Expand Down Expand Up @@ -98,6 +99,7 @@ def setup_authentication(app):
openid_auth._OPENID_ENDPOINT = "https://www.google.com/a/%s/o8/ud?be=o8" % settings.GOOGLE_APPS_DOMAIN

login_manager.init_app(app)
login_manager.anonymous_user = AnonymousUser
app.wsgi_app = ProxyFix(app.wsgi_app)
app.secret_key = settings.COOKIE_SECRET

Expand Down
16 changes: 15 additions & 1 deletion redash/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from flask_login import current_user, login_user, logout_user

import sqlparse
from permissions import require_permission
from redash import settings, utils
from redash import data

Expand Down Expand Up @@ -45,7 +46,8 @@ def index(**kwargs):
'is_admin': current_user.is_admin,
'id': current_user.id,
'name': current_user.name,
'email': current_user.email
'email': current_user.email,
'permissions': current_user.permissions
}

return render_template("index.html", user=json.dumps(user), analytics=settings.ANALYTICS)
Expand Down Expand Up @@ -83,6 +85,7 @@ def logout():

@app.route('/status.json')
@auth.required
@require_permission('admin')
def status_api():
status = {}
info = redis_connection.info()
Expand Down Expand Up @@ -131,6 +134,7 @@ def get(self):

return dashboards

@require_permission('create_dashboard')
def post(self):
dashboard_properties = request.get_json(force=True)
dashboard = models.Dashboard(name=dashboard_properties['name'],
Expand All @@ -149,6 +153,7 @@ def get(self, dashboard_slug=None):

return dashboard.to_dict(with_widgets=True)

@require_permission('edit_dashboard')
def post(self, dashboard_slug):
dashboard_properties = request.get_json(force=True)
# TODO: either convert all requests to use slugs or ids
Expand All @@ -159,6 +164,7 @@ def post(self, dashboard_slug):

return dashboard.to_dict(with_widgets=True)

@require_permission('edit_dashboard')
def delete(self, dashboard_slug):
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
dashboard.is_archived = True
Expand All @@ -169,6 +175,7 @@ def delete(self, dashboard_slug):


class WidgetListAPI(BaseResource):
@require_permission('edit_dashboard')
def post(self):
widget_properties = request.get_json(force=True)
widget_properties['options'] = json.dumps(widget_properties['options'])
Expand Down Expand Up @@ -200,6 +207,7 @@ def post(self):


class WidgetAPI(BaseResource):
@require_permission('edit_dashboard')
def delete(self, widget_id):
widget = models.Widget.get(models.Widget.id == widget_id)
# TODO: reposition existing ones
Expand All @@ -216,6 +224,7 @@ def delete(self, widget_id):


class QueryListAPI(BaseResource):
@require_permission('create_query')
def post(self):
query_def = request.get_json(force=True)
# id, created_at, api_key
Expand All @@ -235,6 +244,7 @@ def get(self):


class QueryAPI(BaseResource):
@require_permission('edit_query')
def post(self, query_id):
query_def = request.get_json(force=True)
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user']:
Expand All @@ -261,6 +271,7 @@ def get(self, query_id):


class VisualizationListAPI(BaseResource):
@require_permission('edit_query')
def post(self):
kwargs = request.get_json(force=True)
kwargs['options'] = json.dumps(kwargs['options'])
Expand All @@ -273,6 +284,7 @@ def post(self):


class VisualizationAPI(BaseResource):
@require_permission('edit_query')
def post(self, visualization_id):
kwargs = request.get_json(force=True)
if 'options' in kwargs:
Expand All @@ -286,6 +298,7 @@ def post(self, visualization_id):

return vis.to_dict(with_query=False)

@require_permission('edit_query')
def delete(self, visualization_id):
vis = models.Visualization.get(models.Visualization.id == visualization_id)
vis.delete_instance()
Expand All @@ -295,6 +308,7 @@ def delete(self, visualization_id):


class QueryResultListAPI(BaseResource):
@require_permission('execute_query')
def post(self):
params = request.json

Expand Down
13 changes: 12 additions & 1 deletion redash/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import time
import datetime
from flask.ext.peewee.utils import slugify
from flask.ext.login import UserMixin
from flask.ext.login import UserMixin, AnonymousUserMixin
from passlib.apps import custom_app_context as pwd_context
import peewee
from playhouse.postgres_ext import ArrayField
from redash import db, utils


Expand All @@ -15,12 +16,22 @@ def get_by_id(cls, model_id):
return cls.get(cls.id == model_id)


class AnonymousUser(AnonymousUserMixin):
@property
def permissions(self):
return []


class User(BaseModel, UserMixin):
DEFAULT_PERMISSIONS = ['create_dashboard', 'create_query', 'edit_dashboard', 'edit_query',
'view_source', 'execute_query']

id = peewee.PrimaryKeyField()
name = peewee.CharField(max_length=320)
email = peewee.CharField(max_length=320, index=True, unique=True)
password_hash = peewee.CharField(max_length=128, null=True)
is_admin = peewee.BooleanField(default=False)
permissions = ArrayField(peewee.CharField, default=DEFAULT_PERMISSIONS)

class Meta:
db_table = 'users'
Expand Down
27 changes: 27 additions & 0 deletions redash/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import functools
from flask.ext.login import current_user
from flask.ext.restful import abort


class require_permissions(object):
def __init__(self, permissions):
self.permissions = permissions

def __call__(self, fn):
@functools.wraps(fn)
def decorated(*args, **kwargs):
has_permissions = reduce(lambda a, b: a and b,
map(lambda permission: permission in current_user.permissions,
self.permissions),
True)

if has_permissions:
return fn(*args, **kwargs)
else:
abort(403)

return decorated


def require_permission(permission):
return require_permissions((permission,))
20 changes: 16 additions & 4 deletions tests/test_controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,22 @@ def setUp(self):
super(IndexTest, self).setUp()


class StatusTest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = ['/status.json']
super(StatusTest, self).setUp()
class StatusTest(BaseTestCase):
def test_returns_data_for_admin(self):
admin = user_factory.create(permissions=['admin'])
with app.test_client() as c, authenticated_user(c, user=admin):
rv = c.get('/status.json')
self.assertEqual(rv.status_code, 200)

def test_returns_403_for_non_admin(self):
with app.test_client() as c, authenticated_user(c):
rv = c.get('/status.json')
self.assertEqual(rv.status_code, 403)

def test_redirects_non_authenticated_user(self):
with app.test_client() as c:
rv = c.get('/status.json')
self.assertEqual(rv.status_code, 302)


class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
Expand Down

0 comments on commit cb74a2c

Please sign in to comment.