Merge pull request #1113 from whummer/feat/share-access-permissions

Add: share modify/access permissions for queries and dashboard
This commit is contained in:
Arik Fraimovich 2016-10-28 19:11:03 +03:00 committed by GitHub
commit 2f090435a5
32 changed files with 1252 additions and 168 deletions

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ node_modules
.tmp
.sass-cache
rd_ui/app/bower_components
npm-debug.log

View File

@ -0,0 +1,22 @@
from redash.models import db, Change, AccessPermission, Query, Dashboard
from playhouse.migrate import PostgresqlMigrator, migrate
if __name__ == '__main__':
if not Change.table_exists():
Change.create_table()
if not AccessPermission.table_exists():
AccessPermission.create_table()
migrator = PostgresqlMigrator(db.database)
try:
migrate(
migrator.add_column('queries', 'version', Query.version),
migrator.add_column('dashboards', 'version', Dashboard.version)
)
except Exception as ex:
print "Error while adding version column to queries/dashboards. Maybe it already exists?"
print ex

View File

@ -156,9 +156,76 @@
$scope.recentDashboards = Dashboard.recent();
};
// Controller for modal window share_permissions, works for both query and dashboards, needs apiAccess set in scope
var ManagePermissionsCtrl = function ($scope, $http, $modalInstance, User) {
$scope.grantees = [];
$scope.newGrantees = {};
// List users that are granted permissions
var loadGrantees = function() {
$http.get($scope.apiAccess).success(function(result) {
$scope.grantees = [];
for(var access_type in result) {
result[access_type].forEach(function(grantee) {
var item = grantee;
item['access_type'] = access_type;
$scope.grantees.push(item);
})
}
});
};
loadGrantees();
// Search for user
$scope.findUser = function(search) {
if (search == "") {
return;
}
if ($scope.foundUsers === undefined) {
User.query(function(users) {
var existingIds = _.map($scope.grantees, function(m) { return m.id; });
_.each(users, function(user) { user.alreadyGrantee = _.contains(existingIds, user.id); });
$scope.foundUsers = users;
});
}
};
// Add new user to grantees list
$scope.addGrantee = function(user) {
$scope.newGrantees.selected = undefined;
var body = {'access_type': 'modify', 'user_id': user.id};
$http.post($scope.apiAccess, body).success(function() {
user.alreadyGrantee = true;
loadGrantees();
});
};
// Remove user from grantees list
$scope.removeGrantee = function(user) {
var body = {'access_type': 'modify', 'user_id': user.id};
$http({ url: $scope.apiAccess, method: 'DELETE',
data: body, headers: {"Content-Type": "application/json"}
}).success(function() {
$scope.grantees = _.filter($scope.grantees, function(m) { return m != user });
if ($scope.foundUsers) {
_.each($scope.foundUsers, function(u) { if (u.id == user.id) { u.alreadyGrantee = false }; });
}
});
};
$scope.close = function() {
$modalInstance.close();
}
};
angular.module('redash.controllers', [])
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
.controller('IndexCtrl', ['$scope', 'Events', 'Dashboard', 'Query', IndexCtrl])
.controller('MainCtrl', ['$scope', '$location', 'Dashboard', MainCtrl])
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl]);
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl])
.controller('ManagePermissionsCtrl', ['$scope', '$http', '$modalInstance', 'User', ManagePermissionsCtrl]);
})();

View File

@ -13,6 +13,7 @@
$scope.refreshEnabled = false;
$scope.isFullscreen = false;
$scope.refreshRate = 60;
$scope.showPermissionsControl = clientConfig.showPermissionsControl;
var renderDashboard = function (dashboard) {
$scope.$parent.pageTitle = dashboard.name;
@ -114,7 +115,19 @@
$scope.$parent.reloadDashboards();
});
}
}
};
$scope.showManagePermissionsModal = function() {
// Create scope for share permissions dialog and pass api path to it
var scope = $scope.$new();
$scope.apiAccess = 'api/dashboards/' + $scope.dashboard.id + '/acl';
$modal.open({
scope: scope,
templateUrl: '/views/dialogs/manage_permissions.html',
controller: 'ManagePermissionsCtrl'
});
};
$scope.toggleFullscreen = function() {
$scope.isFullscreen = !$scope.isFullscreen;

View File

@ -1,7 +1,7 @@
(function() {
'use strict';
function QuerySourceCtrl(Events, growl, $controller, $scope, $location, Query, Visualization, KeyboardShortcuts) {
function QuerySourceCtrl(Events, growl, $controller, $scope, $location, $http, Query, Visualization, KeyboardShortcuts) {
// extends QueryViewCtrl
$controller('QueryViewCtrl', {$scope: $scope});
// TODO:
@ -17,7 +17,7 @@
saveQuery = $scope.saveQuery;
$scope.sourceMode = true;
$scope.canEdit = currentUser.canEdit($scope.query);// TODO: bring this back? || clientConfig.allowAllToEditQueries;
$scope.canEdit = currentUser.canEdit($scope.query) || $scope.query.can_edit;// TODO: bring this back? || clientConfig.allowAllToEditQueries;
$scope.isDirty = false;
$scope.base_url = $location.protocol()+"://"+$location.host()+":"+$location.port();
@ -60,11 +60,18 @@
savePromise.then(function(savedQuery) {
queryText = savedQuery.query;
$scope.isDirty = $scope.query.query !== queryText;
// update to latest version number
$scope.query.version = savedQuery.version;
if (isNewQuery) {
// redirect to new created query (keep hash)
$location.path(savedQuery.getSourceLink());
}
}, function(error) {
if(error.status == 409) {
growl.addErrorMessage('It seems like the query has been modified by another user. ' +
'Please copy/backup your changes and reload this page.', {ttl: -1});
}
});
return savePromise;
@ -114,7 +121,7 @@
}
angular.module('redash.controllers').controller('QuerySourceCtrl', [
'Events', 'growl', '$controller', '$scope', '$location', 'Query',
'Visualization', 'KeyboardShortcuts', QuerySourceCtrl
'Events', 'growl', '$controller', '$scope', '$location', '$http',
'Query', 'Visualization', 'KeyboardShortcuts', QuerySourceCtrl
]);
})();

View File

@ -1,7 +1,7 @@
(function() {
'use strict';
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, $modal, Query, DataSource) {
function QueryViewCtrl($scope, Events, $route, $routeParams, $http, $location, notifications, growl, $modal, Query, DataSource, User) {
var DEFAULT_TAB = 'table';
var getQueryResult = function(maxAge) {
@ -66,6 +66,7 @@
$scope.dataSource = {};
$scope.query = $route.current.locals.query;
$scope.showPermissionsControl = clientConfig.showPermissionsControl;
var updateSchema = function() {
$scope.hasSchema = false;
@ -129,8 +130,9 @@
return;
}
data.id = $scope.query.id;
data.version = $scope.query.version;
} else {
data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options", "latest_query_data_id"]);
data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options", "latest_query_data_id", "version"]);
}
options = _.extend({}, {
@ -138,10 +140,16 @@
errorMessage: 'Query could not be saved'
}, options);
return Query.save(data, function() {
return Query.save(data, function(updatedQuery) {
growl.addSuccessMessage(options.successMessage);
}, function(httpResponse) {
growl.addErrorMessage(options.errorMessage);
$scope.query.version = updatedQuery.version;
}, function(error) {
if(error.status == 409) {
growl.addErrorMessage('It seems like the query has been modified by another user. ' +
'Please copy/backup your changes and reload this page.', {ttl: -1});
} else {
growl.addErrorMessage(options.errorMessage);
}
}).$promise;
}
@ -339,9 +347,19 @@
}
$scope.selectedTab = hash || DEFAULT_TAB;
});
};
$scope.showManagePermissionsModal = function() {
// Create scope for share permissions dialog and pass api path to it
var scope = $scope.$new();
$scope.apiAccess = 'api/queries/' + $routeParams.queryId + '/acl';
$modal.open({
scope: scope,
templateUrl: '/views/dialogs/manage_permissions.html',
controller: 'ManagePermissionsCtrl'
})
};
};
angular.module('redash.controllers')
.controller('QueryViewCtrl',
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', '$modal', 'Query', 'DataSource', QueryViewCtrl]);
.controller('QueryViewCtrl', ['$scope', 'Events', '$route', '$routeParams', '$http', '$location', 'notifications', 'growl', '$modal', 'Query', 'DataSource', 'User', QueryViewCtrl]);
})();

View File

@ -3,8 +3,8 @@
var directives = angular.module('redash.directives');
directives.directive('editDashboardForm', ['Events', '$http', '$location', '$timeout', 'Dashboard',
function(Events, $http, $location, $timeout, Dashboard) {
directives.directive('editDashboardForm', ['Events', '$http', '$location', '$timeout', 'Dashboard', 'growl',
function(Events, $http, $location, $timeout, Dashboard, growl) {
return {
restrict: 'E',
scope: {
@ -81,10 +81,19 @@
$scope.dashboard.layout = layout;
layout = JSON.stringify(layout);
Dashboard.save({slug: $scope.dashboard.id, name: $scope.dashboard.name, layout: layout}, function(dashboard) {
Dashboard.save({slug: $scope.dashboard.id, name: $scope.dashboard.name,
version: $scope.dashboard.version, layout: layout}, function(dashboard) {
$scope.dashboard = dashboard;
$scope.saveInProgress = false;
$(element).modal('hide');
}, function(error) {
$scope.saveInProgress = false;
if(error.status == 403) {
growl.addErrorMessage("Unable to save dashboard: Permission denied.");
} else if(error.status == 409) {
growl.addErrorMessage('It seems like the dashboard has been modified by another user. ' +
'Please copy/backup your changes and reload this page.', {ttl: -1});
}
});
Events.record(currentUser, 'edit', 'dashboard', $scope.dashboard.id);
} else {

View File

@ -1,5 +1,6 @@
(function () {
var Dashboard = function($resource, $http, Widget) {
var transformSingle = function(dashboard) {
dashboard.widgets = _.map(dashboard.widgets, function (row) {
return _.map(row, function (widget) {
@ -27,13 +28,13 @@
isArray: true,
url: "api/dashboards/recent",
transformResponse: transform
}});
}});
resource.prototype.canEdit = function() {
return currentUser.canEdit(this) || this.can_edit;
};
resource.prototype.canEdit = function() {
return currentUser.hasPermission('admin') || currentUser.canEdit(this);
}
return resource;
return resource;
}
angular.module('redash.services')

View File

@ -21,6 +21,7 @@
<ul class="dropdown-menu pull-right" dropdown-menu>
<li><a data-toggle="modal" hash-link hash="edit_dashboard_dialog">Edit Dashboard</a></li>
<li><a data-toggle="modal" hash-link hash="add_query_dialog">Add Widget</a></li>
<li ng-if="showPermissionsControl"><a ng-click="showManagePermissionsModal()">Manage Permissions</a></li>
<li ng-if="!dashboard.is_archived"><a ng-click="archiveDashboard()">Archive Dashboard</a></li>
</ul>
</div>

View File

@ -0,0 +1,39 @@
<div class="modal-header">
<button type="button" class="close" aria-label="Close" ng-click="close()"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">Manage Permissions</h4>
</div>
<div class="modal-body">
<div style="overflow: auto; height: 300px">
<ui-select ng-model="newGrantee.selected" on-select="addGrantee($item)">
<ui-select-match placeholder="Add New User"></ui-select-match>
<ui-select-choices repeat="user in foundUsers | filter:$select.search"
refresh="findUser($select.search)"
refresh-delay="0"
ui-disable-choice="user.alreadyGrantee">
<div>
<img ng-src="{{user.gravatar_url}}" height="24px">&nbsp;{{user.name}}
<small ng-if="user.alreadyGrantee">(already has permission)</small>
</div>
</ui-select-choices>
</ui-select>
<br/>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th></th>
<th>User</th>
<th>Permission</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="grantee in grantees">
<td width="50px"><img ng-src="{{grantee.gravatar_url}}" height="40px"/></td>
<td>{{grantee.name}} </td>
<td>{{grantee.access_type}}</td>
<td><button class="pull-right btn btn-sm btn-danger" ng-click="removeGrantee(grantee)">Remove</button></td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -41,11 +41,11 @@
<div class="row bg-white p-10 m-b-10">
<div class="col-sm-9">
<h3>
<edit-in-place editable="isQueryOwner" done="saveName" ignore-blanks="true" value="query.name"></edit-in-place>
<edit-in-place editable="canEdit" done="saveName" ignore-blanks="true" value="query.name"></edit-in-place>
</h3>
<p>
<em>
<edit-in-place editable="isQueryOwner"
<edit-in-place editable="canEdit"
done="saveDescription"
editor="textarea"
placeholder="No description"
@ -125,6 +125,7 @@
</button>
<ul class="dropdown-menu pull-right" dropdown-menu>
<li ng-if="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin'))"><a hash-link hash="archive-confirmation-modal" data-toggle="modal">Archive Query</a></li>
<li ng-if="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin')) && showPermissionsControl"><a ng-click="showManagePermissionsModal()">Manage Permissions</a></li>
<li ng-if="query.id != undefined"><a ng-click="showApiKey()">Show API Key</a></li>
</ul>
</div>

View File

@ -4,6 +4,7 @@ from flask import make_response
from redash.utils import json_dumps
from redash.handlers.base import org_scoped_rule
from redash.handlers.permissions import ObjectPermissionsListResource, CheckPermissionResource
from redash.handlers.alerts import AlertResource, AlertListResource, AlertSubscriptionListResource, AlertSubscriptionResource
from redash.handlers.dashboards import DashboardListResource, RecentDashboardsResource, DashboardResource, DashboardShareResource
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource, DataSourceTestResource
@ -71,6 +72,9 @@ api.add_org_resource(MyQueriesResource, '/api/queries/my', endpoint='my_queries'
api.add_org_resource(QueryRefreshResource, '/api/queries/<query_id>/refresh', endpoint='query_refresh')
api.add_org_resource(QueryResource, '/api/queries/<query_id>', endpoint='query')
api.add_org_resource(ObjectPermissionsListResource, '/api/<object_type>/<object_id>/acl', endpoint='object_permissions')
api.add_org_resource(CheckPermissionResource, '/api/<object_type>/<object_id>/acl/<access_type>', endpoint='check_permissions')
api.add_org_resource(QueryResultListResource, '/api/query_results', endpoint='query_results')
api.add_org_resource(QueryResultResource,
'/api/query_results/<query_result_id>',

View File

@ -1,10 +1,12 @@
from flask import request, url_for
from flask_restful import abort
from funcy import distinct, take
from funcy import distinct, take, project
from itertools import chain
from redash import models
from redash.permissions import require_permission, require_admin_or_owner
from redash.models import ConflictDetectedError
from redash.permissions import require_permission, require_admin_or_owner, require_object_modify_permission, can_modify
from redash.handlers.base import BaseResource, get_object_or_404
@ -24,18 +26,18 @@ class DashboardListResource(BaseResource):
@require_permission('list_dashboards')
def get(self):
dashboards = [d.to_dict() for d in models.Dashboard.all(self.current_org, self.current_user.groups, self.current_user)]
return dashboards
@require_permission('create_dashboard')
def post(self):
dashboard_properties = request.get_json(force=True)
dashboard = models.Dashboard(name=dashboard_properties['name'],
org=self.current_org,
user=self.current_user,
layout='[]')
dashboard.save()
return dashboard.to_dict()
dashboard = models.Dashboard.create(name=dashboard_properties['name'],
org=self.current_org,
user=self.current_user,
layout='[]')
result = dashboard.to_dict()
return result
class DashboardResource(BaseResource):
@ -49,6 +51,8 @@ class DashboardResource(BaseResource):
response['public_url'] = url_for('redash.public_dashboard', token=api_key.api_key, org_slug=self.current_org.slug, _external=True)
response['api_key'] = api_key.api_key
response['can_edit'] = can_modify(dashboard, self.current_user)
return response
@require_permission('edit_dashboard')
@ -56,17 +60,25 @@ class DashboardResource(BaseResource):
dashboard_properties = request.get_json(force=True)
# TODO: either convert all requests to use slugs or ids
dashboard = models.Dashboard.get_by_id_and_org(dashboard_slug, self.current_org)
dashboard.layout = dashboard_properties['layout']
dashboard.name = dashboard_properties['name']
dashboard.save()
return dashboard.to_dict(with_widgets=True, user=self.current_user)
require_object_modify_permission(dashboard, self.current_user)
updates = project(dashboard_properties, ('name', 'layout', 'version'))
updates['changed_by'] = self.current_user
try:
dashboard.update_instance(**updates)
except ConflictDetectedError:
abort(409)
result = dashboard.to_dict(with_widgets=True, user=self.current_user)
return result
@require_permission('edit_dashboard')
def delete(self, dashboard_slug):
dashboard = models.Dashboard.get_by_slug_and_org(dashboard_slug, self.current_org)
dashboard.is_archived = True
dashboard.save()
dashboard.save(changed_by=self.current_user)
return dashboard.to_dict(with_widgets=True, user=self.current_user)
@ -78,6 +90,12 @@ class DashboardShareResource(BaseResource):
api_key = models.ApiKey.create_for_object(dashboard, self.current_user)
public_url = url_for('redash.public_dashboard', token=api_key.api_key, org_slug=self.current_org.slug, _external=True)
self.record_event({
'action': 'activate_api_key',
'object_id': dashboard.id,
'object_type': 'dashboard',
})
return {'public_url': public_url, 'api_key': api_key.api_key}
def delete(self, dashboard_id):
@ -89,4 +107,10 @@ class DashboardShareResource(BaseResource):
api_key.active = False
api_key.save()
self.record_event({
'action': 'deactivate_api_key',
'object_id': dashboard.id,
'object_type': 'dashboard',
})

View File

@ -0,0 +1,96 @@
from collections import defaultdict
from redash.handlers.base import BaseResource, get_object_or_404
from redash.models import AccessPermission, Query, Dashboard, User
from redash.permissions import require_admin_or_owner, ACCESS_TYPES
from flask import request
from flask_restful import abort
model_to_types = {
'queries': Query,
'dashboards': Dashboard
}
def get_model_from_type(type):
model = model_to_types.get(type)
if model is None:
abort(404)
return model
class ObjectPermissionsListResource(BaseResource):
def get(self, object_type, object_id):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
# TODO: include grantees in search to avoid N+1 queries
permissions = AccessPermission.find(obj)
result = defaultdict(list)
for perm in permissions:
result[perm.access_type].append(perm.grantee.to_dict())
return result
def post(self, object_type, object_id):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
require_admin_or_owner(obj.user_id)
req = request.get_json(True)
access_type = req['access_type']
if access_type not in ACCESS_TYPES:
abort(400, message='Unknown access type.')
try:
grantee = User.get_by_id_and_org(req['user_id'], self.current_org)
except User.DoesNotExist:
abort(400, message='User not found.')
permission = AccessPermission.grant(obj, access_type, grantee, self.current_user)
self.record_event({
'action': 'grant_permission',
'object_id': object_id,
'object_type': object_type,
'access_type': access_type,
'grantee': grantee.id
})
return permission.to_dict()
def delete(self, object_type, object_id):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
require_admin_or_owner(obj.user_id)
req = request.get_json(True)
grantee = req['user_id']
access_type = req['access_type']
AccessPermission.revoke(obj, grantee, access_type)
self.record_event({
'action': 'revoke_permission',
'object_id': object_id,
'object_type': object_type,
'access_type': access_type,
'grantee': grantee
})
class CheckPermissionResource(BaseResource):
def get(self, object_type, object_id, access_type):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
has_access = AccessPermission.exists(obj, access_type, self.current_user)
return {'response': has_access}

View File

@ -9,7 +9,8 @@ from itertools import chain
from redash.handlers.base import routes, org_scoped_rule, paginate
from redash.handlers.query_results import run_query
from redash import models
from redash.permissions import require_permission, require_access, require_admin_or_owner, not_view_only, view_only
from redash.permissions import require_permission, require_access, require_admin_or_owner, not_view_only, view_only, \
require_object_modify_permission, can_modify
from redash.handlers.base import BaseResource, get_object_or_404
from redash.utils import collect_parameters_from_request
@ -93,9 +94,10 @@ class QueryResource(BaseResource):
@require_permission('edit_query')
def post(self, query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_admin_or_owner(query.user_id)
query_def = request.get_json(force=True)
require_object_modify_permission(query, self.current_user)
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user', 'last_modified_by', 'org']:
query_def.pop(field, None)
@ -106,26 +108,34 @@ class QueryResource(BaseResource):
query_def['data_source'] = query_def.pop('data_source_id')
query_def['last_modified_by'] = self.current_user
query_def['changed_by'] = self.current_user
query.update_instance(**query_def)
try:
query.update_instance(**query_def)
except models.ConflictDetectedError:
abort(409)
return query.to_dict(with_visualizations=True)
# old_query = copy.deepcopy(query.to_dict())
# new_change = query.update_instance_tracked(changing_user=self.current_user, old_object=old_query, **query_def)
# abort(409) # HTTP 'Conflict' status code
result = query.to_dict(with_visualizations=True)
return result
@require_permission('view_query')
def get(self, query_id):
q = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(q.groups, self.current_user, view_only)
if q:
return q.to_dict(with_visualizations=True)
else:
abort(404, message="Query not found.")
result = q.to_dict(with_visualizations=True)
result['can_edit'] = can_modify(q, self.current_user)
return result
# TODO: move to resource of its own? (POST /queries/{id}/archive)
def delete(self, query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_admin_or_owner(query.user_id)
query.archive()
query.archive(self.current_user)
class QueryRefreshResource(BaseResource):

View File

@ -19,7 +19,7 @@ from redash import utils, settings, redis_connection
from redash.query_runner import get_query_runner, get_configuration_schema_for_query_runner_type
from redash.destinations import get_destination, get_configuration_schema_for_destination_type
from redash.metrics.database import MeteredPostgresqlExtDatabase, MeteredModel
from redash.utils import generate_token
from redash.utils import generate_token, json_dumps
from redash.utils.configuration import ConfigurationContainer
@ -69,7 +69,7 @@ def cast(self, as_type):
class JSONField(peewee.TextField):
def db_value(self, value):
return json.dumps(value)
return json_dumps(value)
def python_value(self, value):
if not value:
@ -118,10 +118,117 @@ class ModelTimestampsMixin(BaseModel):
def pre_save(self, created):
super(ModelTimestampsMixin, self).pre_save(created)
self.updated_at = datetime.datetime.now()
def _simple_value(v):
if isinstance(v, BaseModel):
return v.id
return v
class ChangeTrackingMixin(object):
skipped_fields = ('id', 'created_at', 'updated_at', 'version')
def prepared(self):
super(ChangeTrackingMixin, self).prepared()
setattr(self, '_clean_values', {})
def __setattr__(self, key, value):
if hasattr(self, '_clean_values') and key in self._field_names():
previous = getattr(self, key)
self._clean_values[key] = previous
super(ChangeTrackingMixin, self).__setattr__(key, value)
@property
def changes(self):
changes = {}
if not hasattr(self, '_clean_values'):
setattr(self, '_clean_values', {})
for field in self._meta.get_fields():
self._clean_values[field] = None
for k, v in self._clean_values.iteritems():
if k not in self.skipped_fields:
changes[k] = {'previous': _simple_value(v), 'current': _simple_value(getattr(self, k))}
return changes
def save(self, *args, **kwargs):
changed_by = kwargs.pop('changed_by', None)
pk_value = self._get_pk_value()
created = kwargs.get('force_insert', False) or not bool(pk_value)
if created and changed_by is None:
changed_by = self.user
ret = super(ChangeTrackingMixin, self).save(*args, **kwargs)
if changed_by:
Change.log_change(changed_by, self)
self._clean_values = {}
return ret
def update_instance(self, **kwargs):
changed_by = kwargs.pop('changed_by', None)
ret = super(ChangeTrackingMixin, self).update_instance(**kwargs)
if changed_by:
Change.log_change(changed_by, self)
return ret
def _field_names(self):
return [f.name for f in self._meta.get_fields()]
class ConflictDetectedError(Exception):
pass
class BaseVersionedModel(BaseModel):
version = peewee.IntegerField(default=1)
def save(self, *args, **kwargs):
pk_value = self._get_pk_value()
created = kwargs.get('force_insert', False) or not bool(pk_value)
if created:
# Since this is an `INSERT`, just call regular save method.
return super(BaseVersionedModel, self).save()
# Update any data that has changed and bump the version counter.
self.pre_save(False)
field_data = dict(self._data)
current_version = field_data.pop('version', 0)
field_data = self._prune_fields(field_data, self.dirty_fields)
# if not field_data:
# raise ValueError('No changes have been made.')
ModelClass = type(self)
field_data['version'] = ModelClass.version + 1 # Atomic increment
query = ModelClass.update(**field_data).where(
(ModelClass.version == current_version) &
(ModelClass.id == self.id))
nrows = query.execute()
if nrows == 0:
# It looks like another process has updated the version number.
raise ConflictDetectedError() # Raise exception? Return False?
else:
self.version += 1 # Update in-memory version number.
self._dirty.clear()
self.post_save(False)
return nrows
class BelongsToOrgMixin(object):
@classmethod
def get_by_id_and_org(cls, object_id, org):
@ -140,7 +247,6 @@ class PermissionsCheckMixin(object):
return has_permissions
class AnonymousUser(AnonymousUserMixin, PermissionsCheckMixin):
@property
def permissions(self):
@ -167,6 +273,9 @@ class ApiUser(UserMixin, PermissionsCheckMixin):
def permissions(self):
return ['view_query']
def has_access(self, obj, access_type):
return False
class Organization(ModelTimestampsMixin, BaseModel):
SETTING_GOOGLE_APPS_DOMAINS = 'google_apps_domains'
@ -344,6 +453,9 @@ class User(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin, UserMixin, Permis
self.groups = map(lambda g: g.id, groups)
self.save()
def has_access(self, obj, access_type):
return AccessPermission.exists(obj, access_type, grantee=self)
class ConfigurationField(peewee.TextField):
def db_value(self, value):
@ -577,7 +689,7 @@ def should_schedule_next(previous_iteration, now, schedule):
return now > next_iteration
class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
class Query(ChangeTrackingMixin, ModelTimestampsMixin, BaseVersionedModel, BelongsToOrgMixin):
id = peewee.PrimaryKeyField()
org = peewee.ForeignKeyField(Organization, related_name="queries")
data_source = peewee.ForeignKeyField(DataSource, null=True)
@ -610,7 +722,8 @@ class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
'updated_at': self.updated_at,
'created_at': self.created_at,
'data_source_id': self.data_source_id,
'options': self.options
'options': self.options,
'version': self.version
}
if with_user:
@ -633,7 +746,7 @@ class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
return d
def archive(self):
def archive(self, user=None):
self.is_archived = True
self.schedule = None
@ -644,7 +757,7 @@ class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
for alert in self.alerts:
alert.delete_instance(recursive=True)
self.save()
self.save(changed_by=user)
@classmethod
def all_queries(cls, groups, drafts=False):
@ -736,6 +849,20 @@ class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
if created:
self._create_default_visualizations()
def update_instance_tracked(self, changing_user, old_object=None, *args, **kwargs):
self.version += 1
self.update_instance(*args, **kwargs)
# save Change record
new_change = Change.save_change(user=changing_user, old_object=old_object, new_object=self)
return new_change
def tracked_save(self, changing_user, old_object=None, *args, **kwargs):
self.version += 1
self.save(*args, **kwargs)
# save Change record
new_change = Change.save_change(user=changing_user, old_object=old_object, new_object=self)
return new_change
def _create_default_visualizations(self):
table_visualization = Visualization(query=self, name="Table",
description='',
@ -766,6 +893,104 @@ class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
return unicode(self.id)
class AccessPermission(BaseModel):
id = peewee.PrimaryKeyField()
object_type = peewee.CharField(index=True)
object_id = peewee.IntegerField(index=True)
object = GFKField('object_type', 'object_id')
access_type = peewee.CharField()
grantor = peewee.ForeignKeyField(User, related_name='grantor')
grantee = peewee.ForeignKeyField(User, related_name='grantee')
class Meta:
db_table = 'access_permissions'
@classmethod
def grant(cls, obj, access_type, grantee, grantor):
return cls.get_or_create(object_type=obj._meta.db_table, object_id=obj.id, access_type=access_type, grantee=grantee, grantor=grantor)[0]
@classmethod
def revoke(cls, obj, grantee, access_type=None):
query = cls._query(cls.delete(), obj, access_type, grantee)
return query.execute()
@classmethod
def find(cls, obj, access_type=None, grantee=None, grantor=None):
return cls._query(cls.select(cls), obj, access_type, grantee, grantor)
@classmethod
def exists(cls, obj, access_type, grantee):
return cls.find(obj, access_type, grantee).count() > 0
@classmethod
def _query(cls, base_query, obj, access_type=None, grantee=None, grantor=None):
q = base_query.where(cls.object_type == obj._meta.db_table) \
.where(cls.object_id == obj.id)
if access_type:
q = q.where(AccessPermission.access_type == access_type)
if grantee:
q = q.where(AccessPermission.grantee == grantee)
if grantor:
q = q.where(AccessPermission.grantor == grantor)
return q
def to_dict(self):
d = {
'id': self.id,
'object_id': self.object_id,
'object_type': self.object_type,
'access_type': self.access_type,
'grantor': self.grantor_id,
'grantee': self.grantee_id
}
return d
class Change(BaseModel):
id = peewee.PrimaryKeyField()
object_id = peewee.CharField(index=True)
object_type = peewee.CharField(index=True)
object_version = peewee.IntegerField(default=0)
object = GFKField('object_type', 'object_id')
user = peewee.ForeignKeyField(User, related_name='changes')
change = JSONField()
created_at = DateTimeTZField(default=datetime.datetime.now)
class Meta:
db_table = 'changes'
def to_dict(self, full=True):
d = {
'id': self.id,
'object_id': self.object_id,
'object_type': self.object_type,
'change_type': self.change_type,
'object_version': self.object_version,
'change': self.change,
'created_at': self.created_at
}
if full:
d['user'] = self.user.to_dict()
else:
d['user_id'] = self.user_id
return d
@classmethod
def log_change(cls, changed_by, obj):
return cls.create(object=obj, object_version=obj.version, user=changed_by, change=obj.changes)
@classmethod
def last_change(cls, obj):
return cls.select().where(cls.object_type==obj._meta.db_table, cls.object_id==obj.id).limit(1).first()
class Alert(ModelTimestampsMixin, BaseModel):
UNKNOWN_STATE = 'unknown'
OK_STATE = 'ok'
@ -843,7 +1068,7 @@ class Alert(ModelTimestampsMixin, BaseModel):
return self.query.groups
class Dashboard(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
class Dashboard(ChangeTrackingMixin, ModelTimestampsMixin, BaseVersionedModel, BelongsToOrgMixin):
id = peewee.PrimaryKeyField()
org = peewee.ForeignKeyField(Organization, related_name="dashboards")
slug = peewee.CharField(max_length=140, index=True)
@ -905,7 +1130,8 @@ class Dashboard(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
'widgets': widgets_layout,
'is_archived': self.is_archived,
'updated_at': self.updated_at,
'created_at': self.created_at
'created_at': self.created_at,
'version': self.version
}
@classmethod
@ -954,6 +1180,13 @@ class Dashboard(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
def get_by_slug_and_org(cls, slug, org):
return cls.get(cls.slug == slug, cls.org==org)
def tracked_save(self, changing_user, old_object=None, *args, **kwargs):
self.version += 1
self.save(*args, **kwargs)
# save Change record
new_change = Change.save_change(user=changing_user, old_object=old_object, new_object=self)
return new_change
def save(self, *args, **kwargs):
if not self.slug:
self.slug = utils.slugify(self.name)
@ -1234,7 +1467,7 @@ class QuerySnippet(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
return d
all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, Event, NotificationDestination, AlertSubscription, ApiKey)
all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, Event, NotificationDestination, AlertSubscription, ApiKey, AccessPermission, Change)
def init_db():

View File

@ -1,11 +1,17 @@
from flask_login import current_user
from flask_restful import abort
import functools
from funcy import any, flatten
from funcy import flatten
view_only = True
not_view_only = False
ACCESS_TYPE_VIEW = 'view'
ACCESS_TYPE_MODIFY = 'modify'
ACCESS_TYPE_DELETE = 'delete'
ACCESS_TYPES = (ACCESS_TYPE_VIEW, ACCESS_TYPE_MODIFY, ACCESS_TYPE_DELETE)
def has_access(object_groups, user, need_view_only):
if 'admin' in user.permissions:
@ -73,3 +79,12 @@ def require_permission_or_owner(permission, object_owner_id):
def require_admin_or_owner(object_owner_id):
if not is_admin_or_owner(object_owner_id):
abort(403, message="You don't have permission to edit this resource.")
def can_modify(obj, user):
return is_admin_or_owner(obj.user_id) or user.has_access(obj, ACCESS_TYPE_MODIFY)
def require_object_modify_permission(obj, user):
if not can_modify(obj, user):
abort(403)

View File

@ -209,6 +209,7 @@ FEATURE_ALLOW_ALL_TO_EDIT_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE
VERSION_CHECK = parse_boolean(os.environ.get("REDASH_VERSION_CHECK", "true"))
FEATURE_DISABLE_REFRESH_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_DISABLE_REFRESH_QUERIES", "false"))
FEATURE_SHOW_QUERY_RESULTS_COUNT = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_QUERY_RESULTS_COUNT", "true"))
FEATURE_SHOW_PERMISSIONS_CONTROL = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_PERMISSIONS_CONTROL", "false"))
# BigQuery
BIGQUERY_HTTP_TIMEOUT = int(os.environ.get("REDASH_BIGQUERY_HTTP_TIMEOUT", "600"))
@ -223,6 +224,7 @@ ALLOW_PARAMETERS_IN_EMBEDS = parse_boolean(os.environ.get("REDASH_ALLOW_PARAMETE
### Common Client config
COMMON_CLIENT_CONFIG = {
'allowScriptsInUserInput': ALLOW_SCRIPTS_IN_USER_INPUT,
'showPermissionsControl': FEATURE_SHOW_PERMISSIONS_CONTROL,
'dateFormat': DATE_FORMAT,
'dateTimeFormat': "{0} HH:mm".format(DATE_FORMAT),
'allowAllToEditQueries': FEATURE_ALLOW_ALL_TO_EDIT_QUERIES,

View File

@ -8,7 +8,6 @@ os.environ['REDASH_GOOGLE_CLIENT_ID'] = "dummy"
os.environ['REDASH_GOOGLE_CLIENT_SECRET'] = "dummy"
os.environ['REDASH_MULTI_ORG'] = "true"
import logging
from unittest import TestCase
import datetime

View File

@ -1,6 +1,7 @@
import redash.models
from redash.utils import gen_query_hash, utcnow
from redash.utils.configuration import ConfigurationContainer
from redash.permissions import ACCESS_TYPE_MODIFY
class ModelFactory(object):
@ -82,6 +83,13 @@ query_with_params_factory = ModelFactory(redash.models.Query,
data_source=data_source_factory.create,
org=1)
access_permission_factory = ModelFactory(redash.models.AccessPermission,
object_id=query_factory.create,
object_type=redash.models.Query.__name__,
access_type=ACCESS_TYPE_MODIFY,
grantor=user_factory.create,
grantee=user_factory.create)
alert_factory = ModelFactory(redash.models.Alert,
name=Sequence('Alert {}'),
query=query_factory.create,
@ -127,12 +135,28 @@ alert_subscription_factory = ModelFactory(redash.models.AlertSubscription,
class Factory(object):
def __init__(self):
self.org, self.admin_group, self.default_group = redash.models.init_db()
self.org.domain = "org0.example.org"
self.org.save()
self._data_source = None
self._user = None
self.data_source = data_source_factory.create(org=self.org)
self.user = self.create_user()
redash.models.DataSourceGroup.create(group=self.default_group, data_source=self.data_source)
@property
def user(self):
if self._user is None:
self._user = self.create_user()
return self._user
@property
def data_source(self):
if self._data_source is None:
self._data_source = data_source_factory.create(org=self.org)
redash.models.DataSourceGroup.create(group=self.default_group, data_source=self._data_source)
return self._data_source
def _init_org(self):
if self._org is None:
self._org, self._admin_group, self._default_group = redash.models.init_db()
self.org.update_instance(domain='org0.example.org')
def create_org(self, **kwargs):
org = org_factory.create(**kwargs)
@ -240,6 +264,13 @@ class Factory(object):
args.update(kwargs)
return query_with_params_factory.create(**args)
def create_access_permission(self, **kwargs):
args = {
'grantor': self.user
}
args.update(kwargs)
return access_permission_factory.create(**args)
def create_query_result(self, **kwargs):
args = {
'data_source': self.data_source,

View File

@ -5,6 +5,8 @@ from tests.factories import user_factory
from redash.utils import json_dumps
from redash.wsgi import app
app.config['TESTING'] = True
def authenticate_request(c, user):
with c.session_transaction() as sess:

View File

@ -52,6 +52,6 @@ class TestInvitePost(BaseTestCase):
password = 'test1234'
response = post_request('/invite/{}'.format(token), data={'password': password}, org=self.factory.org)
self.assertEqual(response.status_code, 302)
self.factory.user = User.get_by_id(self.factory.user.id)
self.assertTrue(self.factory.user.verify_password(password))
user = User.get_by_id(self.factory.user.id)
self.assertTrue(user.verify_password(password))

View File

@ -1,5 +1,110 @@
import json
from tests import BaseTestCase
from redash.models import ApiKey
from redash.models import ApiKey, Dashboard, AccessPermission
from redash.permissions import ACCESS_TYPE_MODIFY
class TestDashboardListResource(BaseTestCase):
def test_create_new_dashboard(self):
dashboard_name = 'Test Dashboard'
rv = self.make_request('post', '/api/dashboards', data={'name': dashboard_name})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'Test Dashboard')
self.assertEquals(rv.json['user_id'], self.factory.user.id)
self.assertEquals(rv.json['layout'], [])
class TestDashboardResourceGet(BaseTestCase):
def test_get_dashboard(self):
d1 = self.factory.create_dashboard()
rv = self.make_request('get', '/api/dashboards/{0}'.format(d1.slug))
self.assertEquals(rv.status_code, 200)
expected = d1.to_dict(with_widgets=True)
actual = json.loads(rv.data)
self.assertResponseEqual(expected, actual)
def test_get_dashboard_filters_unauthorized_widgets(self):
dashboard = self.factory.create_dashboard()
restricted_ds = self.factory.create_data_source(group=self.factory.create_group())
query = self.factory.create_query(data_source=restricted_ds)
vis = self.factory.create_visualization(query=query)
restricted_widget = self.factory.create_widget(visualization=vis, dashboard=dashboard)
widget = self.factory.create_widget(dashboard=dashboard)
dashboard.layout = '[[{}, {}]]'.format(widget.id, restricted_widget.id)
dashboard.save()
rv = self.make_request('get', '/api/dashboards/{0}'.format(dashboard.slug))
self.assertEquals(rv.status_code, 200)
self.assertTrue(rv.json['widgets'][0][1]['restricted'])
self.assertNotIn('restricted', rv.json['widgets'][0][0])
def test_get_non_existing_dashboard(self):
rv = self.make_request('get', '/api/dashboards/not_existing')
self.assertEquals(rv.status_code, 404)
class TestDashboardResourcePost(BaseTestCase):
def test_update_dashboard(self):
d = self.factory.create_dashboard()
new_name = 'New Name'
rv = self.make_request('post', '/api/dashboards/{0}'.format(d.id),
data={'name': new_name, 'layout': '[]'})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], new_name)
def test_raises_error_in_case_of_conflict(self):
d = self.factory.create_dashboard()
d.name = 'Updated'
d.save()
new_name = 'New Name'
rv = self.make_request('post', '/api/dashboards/{0}'.format(d.id),
data={'name': new_name, 'layout': '[]', 'version': d.version - 1})
self.assertEqual(rv.status_code, 409)
def test_overrides_existing_if_no_version_specified(self):
d = self.factory.create_dashboard()
d.name = 'Updated'
d.save()
new_name = 'New Name'
rv = self.make_request('post', '/api/dashboards/{0}'.format(d.id),
data={'name': new_name, 'layout': '[]'})
self.assertEqual(rv.status_code, 200)
def test_works_for_non_owner_with_permission(self):
d = self.factory.create_dashboard()
user = self.factory.create_user()
new_name = 'New Name'
rv = self.make_request('post', '/api/dashboards/{0}'.format(d.id),
data={'name': new_name, 'layout': '[]', 'version': d.version}, user=user)
self.assertEqual(rv.status_code, 403)
AccessPermission.grant(obj=d, access_type=ACCESS_TYPE_MODIFY, grantee=user, grantor=d.user)
rv = self.make_request('post', '/api/dashboards/{0}'.format(d.id),
data={'name': new_name, 'layout': '[]', 'version': d.version}, user=user)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.json['name'], new_name)
class TestDashboardResourceDelete(BaseTestCase):
def test_delete_dashboard(self):
d = self.factory.create_dashboard()
rv = self.make_request('delete', '/api/dashboards/{0}'.format(d.slug))
self.assertEquals(rv.status_code, 200)
d = Dashboard.get_by_slug_and_org(d.slug, d.org)
self.assertTrue(d.is_archived)
class TestDashboardShareResourcePost(BaseTestCase):

View File

@ -0,0 +1,203 @@
from tests import BaseTestCase
from redash.models import AccessPermission
from redash.permissions import ACCESS_TYPE_MODIFY
class TestObjectPermissionsListGet(BaseTestCase):
def test_returns_empty_list_when_no_permissions(self):
query = self.factory.create_query()
user = self.factory.user
rv = self.make_request('get', '/api/queries/{}/acl'.format(query.id), user=user)
self.assertEqual(rv.status_code, 200)
self.assertEqual({}, rv.json)
def test_returns_permissions(self):
query = self.factory.create_query()
user = self.factory.user
AccessPermission.grant(obj=query, access_type=ACCESS_TYPE_MODIFY,
grantor=self.factory.user, grantee=self.factory.user)
rv = self.make_request('get', '/api/queries/{}/acl'.format(query.id), user=user)
self.assertEqual(rv.status_code, 200)
self.assertIn('modify', rv.json)
self.assertEqual(user.id, rv.json['modify'][0]['id'])
def test_returns_404_for_outside_of_organization_users(self):
query = self.factory.create_query()
user = self.factory.create_user(org=self.factory.create_org())
rv = self.make_request('get', '/api/queries/{}/acl'.format(query.id), user=user)
self.assertEqual(rv.status_code, 404)
class TestObjectPermissionsListPost(BaseTestCase):
def test_creates_permission_if_the_user_is_an_owner(self):
query = self.factory.create_query()
other_user = self.factory.create_user()
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': other_user.id
}
rv = self.make_request('post', '/api/queries/{}/acl'.format(query.id), user=query.user, data=data)
self.assertEqual(200, rv.status_code)
self.assertTrue(AccessPermission.exists(query, ACCESS_TYPE_MODIFY, other_user))
def test_returns_403_if_the_user_isnt_owner(self):
query = self.factory.create_query()
other_user = self.factory.create_user()
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': other_user.id
}
rv = self.make_request('post', '/api/queries/{}/acl'.format(query.id), user=other_user, data=data)
self.assertEqual(403, rv.status_code)
def test_returns_400_if_the_grantee_isnt_from_organization(self):
query = self.factory.create_query()
other_user = self.factory.create_user(org=self.factory.create_org())
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': other_user.id
}
rv = self.make_request('post', '/api/queries/{}/acl'.format(query.id), user=query.user, data=data)
self.assertEqual(400, rv.status_code)
def test_returns_404_if_the_user_from_different_org(self):
query = self.factory.create_query()
other_user = self.factory.create_user(org=self.factory.create_org())
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': other_user.id
}
rv = self.make_request('post', '/api/queries/{}/acl'.format(query.id), user=other_user, data=data)
self.assertEqual(404, rv.status_code)
def test_accepts_only_correct_access_types(self):
query = self.factory.create_query()
other_user = self.factory.create_user()
data = {
'access_type': 'random string',
'user_id': other_user.id
}
rv = self.make_request('post', '/api/queries/{}/acl'.format(query.id), user=query.user, data=data)
self.assertEqual(400, rv.status_code)
class TestObjectPermissionsListDelete(BaseTestCase):
def test_removes_permission(self):
query = self.factory.create_query()
user = self.factory.user
other_user = self.factory.create_user()
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': other_user.id
}
AccessPermission.grant(obj=query, access_type=ACCESS_TYPE_MODIFY, grantor=self.factory.user, grantee=other_user)
rv = self.make_request('delete', '/api/queries/{}/acl'.format(query.id), user=user, data=data)
self.assertEqual(rv.status_code, 200)
self.assertFalse(AccessPermission.exists(query, ACCESS_TYPE_MODIFY, other_user))
def test_removes_permission_created_by_another_user(self):
query = self.factory.create_query()
other_user = self.factory.create_user()
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': other_user.id
}
AccessPermission.grant(obj=query, access_type=ACCESS_TYPE_MODIFY, grantor=self.factory.user, grantee=other_user)
rv = self.make_request('delete', '/api/queries/{}/acl'.format(query.id), user=self.factory.create_admin(),
data=data)
self.assertEqual(rv.status_code, 200)
self.assertFalse(AccessPermission.exists(query, ACCESS_TYPE_MODIFY, other_user))
def test_returns_404_for_outside_of_organization_users(self):
query = self.factory.create_query()
user = self.factory.create_user(org=self.factory.create_org())
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': user.id
}
rv = self.make_request('delete', '/api/queries/{}/acl'.format(query.id), user=user, data=data)
self.assertEqual(rv.status_code, 404)
def test_returns_403_for_non_owner(self):
query = self.factory.create_query()
user = self.factory.create_user()
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': user.id
}
rv = self.make_request('delete', '/api/queries/{}/acl'.format(query.id), user=user, data=data)
self.assertEqual(rv.status_code, 403)
def test_returns_200_even_if_there_is_no_permission(self):
query = self.factory.create_query()
user = self.factory.create_user()
data = {
'access_type': ACCESS_TYPE_MODIFY,
'user_id': user.id
}
rv = self.make_request('delete', '/api/queries/{}/acl'.format(query.id), user=query.user, data=data)
self.assertEqual(rv.status_code, 200)
class TestCheckPermissionsGet(BaseTestCase):
def test_returns_true_for_existing_permission(self):
query = self.factory.create_query()
other_user = self.factory.create_user()
AccessPermission.grant(obj=query, access_type=ACCESS_TYPE_MODIFY, grantor=self.factory.user, grantee=other_user)
rv = self.make_request('get', '/api/queries/{}/acl/{}'.format(query.id, ACCESS_TYPE_MODIFY), user=other_user)
self.assertEqual(rv.status_code, 200)
self.assertEqual(True, rv.json['response'])
def test_returns_false_for_existing_permission(self):
query = self.factory.create_query()
other_user = self.factory.create_user()
rv = self.make_request('get', '/api/queries/{}/acl/{}'.format(query.id, ACCESS_TYPE_MODIFY), user=other_user)
self.assertEqual(rv.status_code, 200)
self.assertEqual(False, rv.json['response'])
def test_returns_404_for_outside_of_org_users(self):
query = self.factory.create_query()
other_user = self.factory.create_user(org=self.factory.create_org())
rv = self.make_request('get', '/api/queries/{}/acl/{}'.format(query.id, ACCESS_TYPE_MODIFY), user=other_user)
self.assertEqual(rv.status_code, 404)

View File

@ -1,48 +1,19 @@
from redash import models
from tests import BaseTestCase
from tests.test_handlers import AuthenticationTestMixin
from redash import models
from redash.permissions import ACCESS_TYPE_MODIFY
class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = ['/api/queries']
super(QueryAPITest, self).setUp()
def test_update_query(self):
admin = self.factory.create_admin()
query = self.factory.create_query()
rv = self.make_request('post', '/api/queries/{0}'.format(query.id), data={'name': 'Testing'}, user=admin)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.json['name'], 'Testing')
self.assertEqual(rv.json['last_modified_by']['id'], admin.id)
def test_create_query(self):
query_data = {
'name': 'Testing',
'query': 'SELECT 1',
'schedule': "3600",
'data_source_id': self.factory.data_source.id
}
rv = self.make_request('post', '/api/queries', data=query_data)
self.assertEquals(rv.status_code, 200)
self.assertDictContainsSubset(query_data, rv.json)
self.assertEquals(rv.json['user']['id'], self.factory.user.id)
self.assertIsNotNone(rv.json['api_key'])
self.assertIsNotNone(rv.json['query_hash'])
query = models.Query.get_by_id(rv.json['id'])
self.assertEquals(len(list(query.visualizations)), 1)
class TestQueryResourceGet(BaseTestCase):
def test_get_query(self):
query = self.factory.create_query()
rv = self.make_request('get', '/api/queries/{0}'.format(query.id))
self.assertEquals(rv.status_code, 200)
self.assertResponseEqual(rv.json, query.to_dict(with_visualizations=True))
expected = query.to_dict(with_visualizations=True)
expected['can_edit'] = True
self.assertResponseEqual(expected, rv.json)
def test_get_all_queries(self):
queries = [self.factory.create_query() for _ in range(10)]
@ -78,6 +49,69 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
self.assertEquals(rv.status_code, 200)
class TestQueryResourcePost(BaseTestCase):
def test_update_query(self):
admin = self.factory.create_admin()
query = self.factory.create_query()
rv = self.make_request('post', '/api/queries/{0}'.format(query.id), data={'name': 'Testing'}, user=admin)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.json['name'], 'Testing')
self.assertEqual(rv.json['last_modified_by']['id'], admin.id)
def test_raises_error_in_case_of_conflict(self):
q = self.factory.create_query()
q.name = "Another Name"
q.save()
rv = self.make_request('post', '/api/queries/{0}'.format(q.id), data={'name': 'Testing', 'version': q.version - 1}, user=self.factory.user)
self.assertEqual(rv.status_code, 409)
def test_overrides_existing_if_no_version_specified(self):
q = self.factory.create_query()
q.name = "Another Name"
q.save()
rv = self.make_request('post', '/api/queries/{0}'.format(q.id), data={'name': 'Testing'}, user=self.factory.user)
self.assertEqual(rv.status_code, 200)
def test_works_for_non_owner_with_permission(self):
query = self.factory.create_query()
user = self.factory.create_user()
rv = self.make_request('post', '/api/queries/{0}'.format(query.id), data={'name': 'Testing'}, user=user)
self.assertEqual(rv.status_code, 403)
models.AccessPermission.grant(obj=query, access_type=ACCESS_TYPE_MODIFY, grantee=user, grantor=query.user)
rv = self.make_request('post', '/api/queries/{0}'.format(query.id), data={'name': 'Testing'}, user=user)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.json['name'], 'Testing')
self.assertEqual(rv.json['last_modified_by']['id'], user.id)
class TestQueryListResourcePost(BaseTestCase):
def test_create_query(self):
query_data = {
'name': 'Testing',
'query': 'SELECT 1',
'schedule': "3600",
'data_source_id': self.factory.data_source.id
}
rv = self.make_request('post', '/api/queries', data=query_data)
self.assertEquals(rv.status_code, 200)
self.assertDictContainsSubset(query_data, rv.json)
self.assertEquals(rv.json['user']['id'], self.factory.user.id)
self.assertIsNotNone(rv.json['api_key'])
self.assertIsNotNone(rv.json['query_hash'])
query = models.Query.get_by_id(rv.json['id'])
self.assertEquals(len(list(query.visualizations)), 1)
class QueryRefreshTest(BaseTestCase):
def setUp(self):
super(QueryRefreshTest, self).setUp()

View File

@ -0,0 +1,60 @@
import peewee
from mock import patch
from tests import BaseTestCase
from redash.models import ChangeTrackingMixin, BaseVersionedModel, ConflictDetectedError
class TestModel(BaseVersionedModel):
value = peewee.IntegerField()
class Meta:
db_table = 'test_mode'
class TestModelTestCase(BaseTestCase):
def setUp(self):
super(TestModelTestCase, self).setUp()
TestModel.create_table()
def tearDown(self):
super(TestModelTestCase, self).tearDown()
TestModel.drop_table()
class TestBaseVersionedModel(TestModelTestCase):
def test_creates_first_instance_with_version_0(self):
t = TestModel(value=123)
t.save()
self.assertIsNotNone(t.id)
self.assertEqual(t.version, 1)
self.assertEqual(t.value, 123)
def test_fails_when_there_is_version_conflict(self):
t = TestModel(value=123)
t.save()
t1 = TestModel.get(TestModel.id==t.id)
t2 = TestModel.get(TestModel.id==t.id)
t1.value = 124
t1.save()
self.assertRaises(ConflictDetectedError, lambda: t2.save())
def test_calls_save_hooks(self):
t = TestModel(value=123)
with patch(__name__ + '.TestModel.pre_save') as pre_save_mock, patch(__name__ + '.TestModel.post_save') as post_save_mock:
t.save()
pre_save_mock.assert_called_once_with(True)
post_save_mock.assert_called_once_with(True)
t.value = 124
with patch(__name__ + '.TestModel.pre_save') as pre_save_mock, patch(__name__ + '.TestModel.post_save') as post_save_mock:
t.save()
pre_save_mock.assert_called_once_with(False)
post_save_mock.assert_called_once_with(False)

View File

@ -0,0 +1,81 @@
from tests import BaseTestCase
from redash.models import Query, Change, ChangeTrackingMixin
def create_object(factory):
obj = Query(name='Query',
description='',
query='SELECT 1',
user=factory.user,
data_source=factory.data_source,
org=factory.org)
return obj
class TestChangesProperty(BaseTestCase):
def test_returns_initial_state(self):
obj = create_object(self.factory)
for k, change in obj.changes.iteritems():
self.assertIsNone(change['previous'])
def test_returns_no_changes_after_save(self):
obj = create_object(self.factory)
obj.save()
self.assertEqual({}, obj.changes)
class TestLogChange(BaseTestCase):
def obj(self):
obj = Query(name='Query',
description='',
query='SELECT 1',
user=self.factory.user,
data_source=self.factory.data_source,
org=self.factory.org)
return obj
def test_properly_logs_first_creation(self):
obj = create_object(self.factory)
obj.save(changed_by=self.factory.user)
change = Change.last_change(obj)
self.assertIsNotNone(change)
self.assertEqual(change.object_version, 1)
def test_skips_unnecessary_fields(self):
obj = create_object(self.factory)
obj.save(changed_by=self.factory.user)
change = Change.last_change(obj)
self.assertIsNotNone(change)
self.assertEqual(change.object_version, 1)
for field in ChangeTrackingMixin.skipped_fields:
self.assertNotIn(field, change.change)
def test_properly_log_modification(self):
obj = create_object(self.factory)
obj.save(changed_by=self.factory.user)
obj.update_instance(name='Query 2', description='description', changed_by=self.factory.user)
change = Change.last_change(obj)
self.assertIsNotNone(change)
self.assertEqual(change.object_version, 2)
self.assertEqual(change.object_version, obj.version)
self.assertIn('name', change.change)
self.assertIn('description', change.change)
def test_logs_create_method(self):
q = Query.create(name='Query', description='', query='', user=self.factory.user,
data_source=self.factory.data_source, org=self.factory.org)
change = Change.last_change(q)
self.assertIsNotNone(change)
self.assertEqual(q.user, change.user)

View File

@ -0,0 +1,62 @@
from tests import BaseTestCase
from redash.models import AccessPermission
from redash.permissions import ACCESS_TYPE_MODIFY, ACCESS_TYPE_VIEW
class TestAccessPermissionGrant(BaseTestCase):
def test_creates_correct_object(self):
q = self.factory.create_query()
permission = AccessPermission.grant(obj=q, access_type=ACCESS_TYPE_MODIFY,
grantor=self.factory.user,
grantee=self.factory.user)
self.assertEqual(permission.object, q)
self.assertEqual(permission.grantor, self.factory.user)
self.assertEqual(permission.grantee, self.factory.user)
self.assertEqual(permission.access_type, ACCESS_TYPE_MODIFY)
def test_returns_existing_object_if_exists(self):
q = self.factory.create_query()
permission1 = AccessPermission.grant(obj=q, access_type=ACCESS_TYPE_MODIFY,
grantor=self.factory.user,
grantee=self.factory.user)
permission2 = AccessPermission.grant(obj=q, access_type=ACCESS_TYPE_MODIFY,
grantor=self.factory.user,
grantee=self.factory.user)
self.assertEqual(permission1.id, permission2.id)
class TestAccessPermissionRevoke(BaseTestCase):
def test_deletes_nothing_when_no_permission_exists(self):
q = self.factory.create_query()
self.assertEqual(0, AccessPermission.revoke(q, self.factory.user, ACCESS_TYPE_MODIFY))
def test_deletes_permission(self):
q = self.factory.create_query()
permission = AccessPermission.grant(obj=q, access_type=ACCESS_TYPE_MODIFY,
grantor=self.factory.user,
grantee=self.factory.user)
self.assertEqual(1, AccessPermission.revoke(q, self.factory.user, ACCESS_TYPE_MODIFY))
def test_deletes_all_permissions_if_no_type_given(self):
q = self.factory.create_query()
permission = AccessPermission.grant(obj=q, access_type=ACCESS_TYPE_MODIFY,
grantor=self.factory.user,
grantee=self.factory.user)
permission = AccessPermission.grant(obj=q, access_type=ACCESS_TYPE_VIEW,
grantor=self.factory.user,
grantee=self.factory.user)
self.assertEqual(2, AccessPermission.revoke(q, self.factory.user))
class TestAccessPermissionFind(BaseTestCase):
pass
class TestAccessPermissionExists(BaseTestCase):
pass

View File

@ -0,0 +1,5 @@
from tests import BaseTestCase
# Add tests for change tracking

View File

@ -6,6 +6,7 @@ from redash.tasks import refresh_schemas
class TestRefreshSchemas(BaseTestCase):
def test_calls_refresh_of_all_data_sources(self):
self.factory.data_source # trigger creation
with patch('redash.models.DataSource.get_schema') as get_schema:
refresh_schemas()
get_schema.assert_called_with(refresh=True)

View File

@ -96,7 +96,7 @@ class TestHMACAuthentication(BaseTestCase):
def test_no_query_id(self):
with app.test_client() as c:
rv = c.get('/api/queries', query_string={'api_key': self.api_key})
rv = c.get('/{}/api/queries'.format(self.query.org.slug), query_string={'api_key': self.api_key})
self.assertIsNone(hmac_load_user_from_request(request))
def test_user_api_key(self):

View File

@ -71,68 +71,6 @@ class StatusTest(BaseTestCase):
self.assertEqual(rv.status_code, 302)
class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = ['/api/dashboards']
super(DashboardAPITest, self).setUp()
def test_get_dashboard(self):
d1 = self.factory.create_dashboard()
rv = self.make_request('get', '/api/dashboards/{0}'.format(d1.slug))
self.assertEquals(rv.status_code, 200)
expected = d1.to_dict(with_widgets=True)
actual = json.loads(rv.data)
self.assertResponseEqual(expected, actual)
def test_get_dashboard_filters_unauthorized_widgets(self):
dashboard = self.factory.create_dashboard()
restricted_ds = self.factory.create_data_source(group=self.factory.create_group())
query = self.factory.create_query(data_source=restricted_ds)
vis = self.factory.create_visualization(query=query)
restricted_widget = self.factory.create_widget(visualization=vis, dashboard=dashboard)
widget = self.factory.create_widget(dashboard=dashboard)
dashboard.layout = '[[{}, {}]]'.format(widget.id, restricted_widget.id)
dashboard.save()
rv = self.make_request('get', '/api/dashboards/{0}'.format(dashboard.slug))
self.assertEquals(rv.status_code, 200)
self.assertTrue(rv.json['widgets'][0][1]['restricted'])
self.assertNotIn('restricted', rv.json['widgets'][0][0])
def test_get_non_existing_dashboard(self):
rv = self.make_request('get', '/api/dashboards/not_existing')
self.assertEquals(rv.status_code, 404)
def test_create_new_dashboard(self):
dashboard_name = 'Test Dashboard'
rv = self.make_request('post', '/api/dashboards', data={'name': dashboard_name})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'Test Dashboard')
self.assertEquals(rv.json['user_id'], self.factory.user.id)
self.assertEquals(rv.json['layout'], [])
def test_update_dashboard(self):
d = self.factory.create_dashboard()
new_name = 'New Name'
rv = self.make_request('post', '/api/dashboards/{0}'.format(d.id),
data={'name': new_name, 'layout': '[]'})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], new_name)
def test_delete_dashboard(self):
d = self.factory.create_dashboard()
rv = self.make_request('delete', '/api/dashboards/{0}'.format(d.slug))
self.assertEquals(rv.status_code, 200)
d = models.Dashboard.get_by_slug_and_org(d.slug, d.org)
self.assertTrue(d.is_archived)
class VisualizationResourceTest(BaseTestCase):
def test_create_visualization(self):
query = self.factory.create_query()