Skip to content

Commit

Permalink
Merge branch 'feature_group_permissions' (updated version of #208)
Browse files Browse the repository at this point in the history
  • Loading branch information
arikfr committed May 13, 2014
2 parents 2c34ecd + 4e00698 commit 459309e
Show file tree
Hide file tree
Showing 18 changed files with 212 additions and 42 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ rd_ui/dist
Berksfile.lock
redash/dump.rdb
.env
.ruby-version
.ruby-version
venv
17 changes: 9 additions & 8 deletions manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ def check_settings():
@database_manager.command
def create_tables():
"""Creates the database tables."""
from redash.models import create_db
from redash.models import create_db, init_db

create_db(True, False)
init_db()

@database_manager.command
def drop_tables():
Expand All @@ -83,19 +84,19 @@ def drop_tables():
@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")
@users_manager.option('--password', dest='password', default=None, help="Password for users who don't use Google Auth (leave blank for prompt).")
@users_manager.option('--permissions', dest='permissions', default=models.User.DEFAULT_PERMISSIONS, help="Comma seperated list of permissions (leave blank for default).")
def create(email, name, permissions, is_admin=False, google_auth=False, password=None):
@users_manager.option('--groups', dest='groups', default=models.Group.DEFAULT_PERMISSIONS, help="Comma seperated list of groups (leave blank for default).")
def create(email, name, groups, is_admin=False, google_auth=False, password=None):
print "Creating user (%s, %s)..." % (email, name)
print "Admin: %r" % is_admin
print "Login with Google Auth: %r\n" % google_auth
if isinstance(permissions, basestring):
permissions = permissions.split(',')
permissions.remove('') # in case it was empty string
if isinstance(groups, basestring):
groups= groups.split(',')
groups.remove('') # in case it was empty string

if is_admin:
permissions += ['admin']
groups += ['admin']

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


if __name__ == '__main__':
db.connect_db()
migrator = Migrator(db.database)

if not models.Group.table_exists():
print "Creating groups table..."
models.Group.create_table()

with db.database.transaction():
models.Group.insert(name='admin', permissions=['admin'], tables=['*']).execute()
models.Group.insert(name='api', permissions=['view_query'], tables=['*']).execute()
models.Group.insert(name='default', permissions=models.Group.DEFAULT_PERMISSIONS, tables=['*']).execute()

migrator.add_column(models.User, models.User.groups, 'groups')

models.User.update(groups=['admin', 'default']).where(peewee.SQL("is_admin = true")).execute()
models.User.update(groups=['admin', 'default']).where(peewee.SQL("'admin' = any(permissions)")).execute()
models.User.update(groups=['default']).where(peewee.SQL("is_admin = false")).execute()

migrator.drop_column(models.User, 'permissions')
migrator.drop_column(models.User, 'is_admin')

db.close_db(None)
4 changes: 2 additions & 2 deletions rd_ui/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<div class="collapse navbar-collapse navbar-ex1-collapse">
<ul class="nav navbar-nav">
<li class="active" ng-show="pageTitle"><a class="page-title" ng-bind="pageTitle"></a></li>
<li class="dropdown">
<li class="dropdown" ng-show="groupedDashboards.length > 0 || otherDashboards.length > 0 || currentUser.hasPermission('create_dashboard')">
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><span class="glyphicon glyphicon-th-large"></span> <b class="caret"></b></a>
<ul class="dropdown-menu">
<span ng-repeat="(name, group) in groupedDashboards">
Expand All @@ -52,7 +52,7 @@
<li ng-repeat="dashboard in otherDashboards">
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
</li>
<li class="divider" ng-show="currentUser.hasPermission('create_dashboard')"></li>
<li class="divider" ng-show="currentUser.hasPermission('create_dashboard') && (groupedDashboards.length > 0 || otherDashboards.length > 0)"></li>
<li><a data-toggle="modal" href="#new_dashboard_dialog" ng-show="currentUser.hasPermission('create_dashboard')">New Dashboard</a></li>
</ul>
</li>
Expand Down
4 changes: 4 additions & 0 deletions rd_ui/app/scripts/visualizations/cohort.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@
} else {
var sortedData = _.sortBy($scope.queryResult.getData(), "date");
var grouped = _.groupBy(sortedData, "date");
var maxColumns = _.reduce(grouped, function(memo, data){
return (data.length > memo)? data.length : memo;
}, 0);
var data = _.map(grouped, function(values, date) {
var row = [values[0].total];
_.each(values, function(value) { row.push(value.value); });
_.each(_.range(values.length, maxColumns), function() { row.push(null); });
return row;
});

Expand Down
1 change: 1 addition & 0 deletions rd_ui/bower.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"gridster": "0.2.0",
"mousetrap": "~1.4.6",
"angular-ui-select2": "~0.0.5",
"jquery-ui": "~1.10.4",
"underscore.string": "~2.3.3",
"marked": "~0.3.2",
"bucky": "~0.2.6"
Expand Down
2 changes: 1 addition & 1 deletion redash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import events
from redash import settings, utils

__version__ = '0.3.6'
__version__ = '0.3.7'


def setup_logging():
Expand Down
3 changes: 1 addition & 2 deletions redash/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ def create_and_login_user(app, user):
user_object.save()
except models.User.DoesNotExist:
logger.debug("Creating user object (%r)", user.name)
user_object = models.User.create(name=user.name, email=user.email,
is_admin=(user.email in settings.ADMINS))
user_object = models.User.create(name=user.name, email=user.email, groups = ['default'])

login_user(user_object, remember=True)

Expand Down
21 changes: 20 additions & 1 deletion redash/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from redash import app, auth, api, redis_connection, data_manager
from redash import models

import logging

@app.route('/ping', methods=['GET'])
def ping():
Expand All @@ -44,10 +45,10 @@ def index(**kwargs):

user = {
'gravatar_url': gravatar_url,
'is_admin': current_user.is_admin,
'id': current_user.id,
'name': current_user.name,
'email': current_user.email,
'groups': current_user.groups,
'permissions': current_user.permissions
}

Expand Down Expand Up @@ -359,6 +360,24 @@ class QueryResultListAPI(BaseResource):
def post(self):
params = request.json

if settings.FEATURE_TABLES_PERMISSIONS:
metadata = utils.SQLMetaData(params['query'])

if metadata.has_non_select_dml_statements or metadata.has_ddl_statements:
return {
'job': {
'error': 'Only SELECT statements are allowed'
}
}

if len(metadata.used_tables - current_user.allowed_tables) > 0 and '*' not in current_user.allowed_tables:
logging.warning('Permission denied for user %s to table %s', self.current_user.name, metadata.used_tables)
return {
'job': {
'error': 'Access denied for table(s): %s' % (metadata.used_tables)
}
}

models.ActivityLog(
user=self.current_user,
type=models.ActivityLog.QUERY_EXECUTION,
Expand Down
59 changes: 52 additions & 7 deletions redash/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import datetime
from flask.ext.peewee.utils import slugify
from flask.ext.login import UserMixin, AnonymousUserMixin
import itertools
from passlib.apps import custom_app_context as pwd_context
import peewee
from playhouse.postgres_ext import ArrayField
Expand Down Expand Up @@ -31,16 +32,38 @@ def permissions(self):
return ['view_query']


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

id = peewee.PrimaryKeyField()
name = peewee.CharField(max_length=100)
permissions = ArrayField(peewee.CharField, default=DEFAULT_PERMISSIONS)
tables = ArrayField(peewee.CharField)
created_at = peewee.DateTimeField(default=datetime.datetime.now)

class Meta:
db_table = 'groups'

def to_dict(self):
return {
'id': self.id,
'name': self.name,
'permissions': self.permissions,
'tables': self.tables,
'created_at': self.created_at
}

def __unicode__(self):
return unicode(self.id)


class User(BaseModel, UserMixin):
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)
groups = ArrayField(peewee.CharField, default=['default'])

class Meta:
db_table = 'users'
Expand All @@ -49,10 +72,28 @@ def to_dict(self):
return {
'id': self.id,
'name': self.name,
'email': self.email,
'is_admin': self.is_admin
'email': self.email
}

def __init__(self, *args, **kwargs):
super(User, self).__init__(*args, **kwargs)
self._allowed_tables = None

@property
def permissions(self):
# TODO: this should be cached.
return list(itertools.chain(*[g.permissions for g in
Group.select().where(Group.name << self.groups)]))

@property
def allowed_tables(self):
# TODO: cache this as weel
if self._allowed_tables is None:
self._allowed_tables = set([t.lower() for t in itertools.chain(*[g.tables for g in
Group.select().where(Group.name << self.groups)])])

return self._allowed_tables

def __unicode__(self):
return '%r, %r' % (self.name, self.email)

Expand Down Expand Up @@ -87,7 +128,6 @@ def to_dict(self):
def __unicode__(self):
return unicode(self.id)


class DataSource(BaseModel):
id = peewee.PrimaryKeyField()
name = peewee.CharField()
Expand Down Expand Up @@ -378,7 +418,12 @@ def to_dict(self):
def __unicode__(self):
return u"%s" % self.id

all_models = (DataSource, User, QueryResult, Query, Dashboard, Visualization, Widget, ActivityLog)
all_models = (DataSource, User, QueryResult, Query, Dashboard, Visualization, Widget, ActivityLog, Group)


def init_db():
Group.insert(name='admin', permissions=['admin'], tables=['*']).execute()
Group.insert(name='default', permissions=Group.DEFAULT_PERMISSIONS, tables=['*']).execute()


def create_db(create_tables, drop_tables):
Expand Down
7 changes: 4 additions & 3 deletions redash/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ def parse_boolean(str):
GOOGLE_APPS_DOMAIN = os.environ.get("REDASH_GOOGLE_APPS_DOMAIN", "")
GOOGLE_OPENID_ENABLED = parse_boolean(os.environ.get("REDASH_GOOGLE_OPENID_ENABLED", "true"))
PASSWORD_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_PASSWORD_LOGIN_ENABLED", "false"))
# Email addresses of admin users (comma separated)
ADMINS = array_from_string(os.environ.get("REDASH_ADMINS", ''))
ALLOWED_EXTERNAL_USERS = array_from_string(os.environ.get("REDASH_ALLOWED_EXTERNAL_USERS", ''))
STATIC_ASSETS_PATH = fix_assets_path(os.environ.get("REDASH_STATIC_ASSETS_PATH", "../rd_ui/app/"))
WORKERS_COUNT = int(os.environ.get("REDASH_WORKERS_COUNT", "2"))
Expand All @@ -69,4 +67,7 @@ def parse_boolean(str):
EVENTS_LOG_PATH = os.environ.get("REDASH_EVENTS_LOG_PATH", "")
EVENTS_CONSOLE_OUTPUT = parse_boolean(os.environ.get("REDASH_EVENTS_CONSOLE_OUTPUT", "false"))
CLIENT_SIDE_METRICS = parse_boolean(os.environ.get("REDASH_CLIENT_SIDE_METRICS", "false"))
ANALYTICS = os.environ.get("REDASH_ANALYTICS", "")
ANALYTICS = os.environ.get("REDASH_ANALYTICS", "")

# Features:
FEATURE_TABLES_PERMISSIONS = parse_boolean(os.environ.get("REDASH_FEATURE_TABLES_PERMISSIONS", "false"))
52 changes: 52 additions & 0 deletions redash/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,62 @@
import json
import re
import hashlib
import sqlparse

COMMENTS_REGEX = re.compile("/\*.*?\*/")


class SQLMetaData(object):
TABLE_SELECTION_KEYWORDS = ('FROM', 'JOIN', 'LEFT JOIN', 'FULL JOIN', 'RIGHT JOIN', 'CROSS JOIN', 'INNER JOIN',
'OUTER JOIN', 'LEFT OUTER JOIN', 'RIGHT OUTER JOIN', 'FULL OUTER JOIN')

def __init__(self, sql):
self.sql = sql
self.parsed_sql = sqlparse.parse(self.sql)

self.has_ddl_statements = self._find_ddl_statements()
self.has_non_select_dml_statements = self._find_dml_statements()
self.used_tables = self._find_tables()

def _find_ddl_statements(self):
for statement in self.parsed_sql:
if len([x for x in statement.flatten() if x.ttype == sqlparse.tokens.DDL]):
return True

return False

def _find_tables(self):
tables = set()
for statement in self.parsed_sql:
tables.update(self.extract_table_names(statement.tokens))

return tables

def extract_table_names(self, tokens):
tables = set()
tokens = [t for t in tokens if t.ttype not in (sqlparse.tokens.Whitespace, sqlparse.tokens.Newline)]

for i in range(len(tokens)):
if tokens[i].is_group():
tables.update(self.extract_table_names(tokens[i].tokens))
else:
if tokens[i].ttype == sqlparse.tokens.Keyword and tokens[i].normalized in self.TABLE_SELECTION_KEYWORDS:
if isinstance(tokens[i + 1], sqlparse.sql.Identifier):
tables.add(tokens[i + 1].value)

if isinstance(tokens[i + 1], sqlparse.sql.IdentifierList):
tables.update(set([t.value for t in tokens[i+1].get_identifiers()]))
return tables

def _find_dml_statements(self):
for statement in self.parsed_sql:
for token in statement.flatten():
if token.ttype == sqlparse.tokens.DML and token.normalized != 'SELECT':
return True

return False


def gen_query_hash(sql):
"""Returns hash of the given query after stripping all comments, line breaks and multiple
spaces, and lower casing all text.
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ wtf-peewee==0.2.2
Flask-Script==0.6.6
honcho==0.5.0
statsd==2.1.2
gunicorn==18.0
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
class BaseTestCase(TestCase):
def setUp(self):
redash.models.create_db(True, True)
redash.models.init_db()

def tearDown(self):
db.close_db(None)
Expand Down
2 changes: 1 addition & 1 deletion tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __call__(self):

user_factory = ModelFactory(redash.models.User,
name='John Doe', email=Sequence('test{}@example.com'),
is_admin=False)
groups=['default'])


data_source_factory = ModelFactory(redash.models.DataSource,
Expand Down
Loading

0 comments on commit 459309e

Please sign in to comment.