mirror of
https://github.com/valitydev/redash.git
synced 2024-11-07 01:25:16 +00:00
Merge pull request #1113 from whummer/feat/share-access-permissions
Add: share modify/access permissions for queries and dashboard
This commit is contained in:
commit
2f090435a5
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,3 +27,4 @@ node_modules
|
||||
.tmp
|
||||
.sass-cache
|
||||
rd_ui/app/bower_components
|
||||
npm-debug.log
|
||||
|
22
migrations/0026_add_access_control_tables.py
Normal file
22
migrations/0026_add_access_control_tables.py
Normal 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
|
||||
|
@ -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]);
|
||||
})();
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
]);
|
||||
})();
|
||||
|
@ -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) {
|
||||
$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]);
|
||||
})();
|
||||
|
@ -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 {
|
||||
|
@ -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,12 +28,12 @@
|
||||
isArray: true,
|
||||
url: "api/dashboards/recent",
|
||||
transformResponse: transform
|
||||
|
||||
}});
|
||||
|
||||
resource.prototype.canEdit = function() {
|
||||
return currentUser.hasPermission('admin') || currentUser.canEdit(this);
|
||||
}
|
||||
return currentUser.canEdit(this) || this.can_edit;
|
||||
};
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
39
rd_ui/app/views/dialogs/manage_permissions.html
Normal file
39
rd_ui/app/views/dialogs/manage_permissions.html
Normal file
@ -0,0 +1,39 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="close()"><span aria-hidden="true">×</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"> {{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>
|
@ -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>
|
||||
|
@ -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>',
|
||||
|
@ -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'],
|
||||
dashboard = models.Dashboard.create(name=dashboard_properties['name'],
|
||||
org=self.current_org,
|
||||
user=self.current_user,
|
||||
layout='[]')
|
||||
dashboard.save()
|
||||
return dashboard.to_dict()
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
|
||||
|
96
redash/handlers/permissions.py
Normal file
96
redash/handlers/permissions.py
Normal 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}
|
@ -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
|
||||
|
||||
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):
|
||||
|
255
redash/models.py
255
redash/models.py
@ -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():
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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):
|
||||
|
203
tests/handlers/test_permissions.py
Normal file
203
tests/handlers/test_permissions.py
Normal 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)
|
@ -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()
|
||||
|
60
tests/models/test_base_versioned_model.py
Normal file
60
tests/models/test_base_versioned_model.py
Normal 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)
|
81
tests/models/test_changes.py
Normal file
81
tests/models/test_changes.py
Normal 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)
|
62
tests/models/test_permissions.py
Normal file
62
tests/models/test_permissions.py
Normal 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
|
5
tests/models/test_queries.py
Normal file
5
tests/models/test_queries.py
Normal file
@ -0,0 +1,5 @@
|
||||
from tests import BaseTestCase
|
||||
|
||||
|
||||
# Add tests for change tracking
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user