Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: share modify/access permissions for queries and dashboard #1113

Merged
merged 29 commits into from Oct 28, 2016
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2a68820
UI for sharing edit permissions
Jun 9, 2016
903ba0c
add backend API and tests for managing access permissions.
whummer Jun 9, 2016
91a46ea
add optimistic locking for concurrent changes to queries by different…
whummer Jun 10, 2016
95dca53
Finalised UI for sharing permissions
Jun 10, 2016
6b540e0
check for shared permissions in dashboard UI
whummer Jun 10, 2016
e0672f4
add optimistic locking for dashboard editing
whummer Jun 12, 2016
c0c4f45
fix handling of latest_version in query view
whummer Jun 15, 2016
60a79cb
address code review comments
whummer Sep 8, 2016
b748eb1
"Simplify" migration code
arikfr Oct 24, 2016
19e5a0a
Naming and indentation fixes
arikfr Oct 24, 2016
b9ab913
Apply review to models code
arikfr Oct 24, 2016
00a77f8
snake_case to camelCase
arikfr Oct 24, 2016
7ba5a20
Remove unused method
arikfr Oct 24, 2016
9f3bbfe
Renames
arikfr Oct 24, 2016
c51477a
Add tests outline
arikfr Oct 24, 2016
edea6f3
WIP:
arikfr Oct 24, 2016
8b09112
Use new helper for dashboards API
arikfr Oct 24, 2016
bb96702
Update dashboard testS
arikfr Oct 25, 2016
40cc592
Make sure error are logged in tests
arikfr Oct 25, 2016
f34471e
Return 409 when dashboard can't be updated due to conflict
arikfr Oct 25, 2016
6218421
Tests for the permissions API (and rewrite)
arikfr Oct 26, 2016
8245a66
Return permission state with query object
arikfr Oct 26, 2016
028393b
Return dashboard permission status with dashboard object
arikfr Oct 26, 2016
df17759
Fix tests
arikfr Oct 26, 2016
9cb9bdb
Record events for permission changes
arikfr Oct 26, 2016
6c5dd09
Add change tracking and fix tests
arikfr Oct 26, 2016
2592959
Use version for partial query updates
arikfr Oct 26, 2016
52b87ef
Add feature flag for the permissions control feature
arikfr Oct 26, 2016
fd9dc4b
Update controller/view name
arikfr Oct 26, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -27,3 +27,4 @@ node_modules
.tmp
.sass-cache
rd_ui/app/bower_components
npm-debug.log
22 changes: 22 additions & 0 deletions migrations/0026_add_access_control_tables.py
@@ -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

69 changes: 68 additions & 1 deletion rd_ui/app/scripts/controllers/controllers.js
Expand Up @@ -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 SharePermissionsCtrl = 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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of queries, we need to filter here for users who have full access to the query data source. Ideally it's something we will let the backend do, to avoid replicating logic in the UI.

We can postpone implementing this to the end, as it doesn't change the interface behavior.

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('SharePermissionsCtrl', ['$scope', '$http', '$modalInstance', 'User', SharePermissionsCtrl]);
})();
15 changes: 14 additions & 1 deletion rd_ui/app/scripts/controllers/dashboard.js
Expand Up @@ -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;
Expand Down Expand Up @@ -114,7 +115,19 @@
$scope.$parent.reloadDashboards();
});
}
}
};

$scope.showSharePermissionsModal = 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/share_permissions.html',
controller: 'SharePermissionsCtrl'
});
};

$scope.toggleFullscreen = function() {
$scope.isFullscreen = !$scope.isFullscreen;
Expand Down
15 changes: 11 additions & 4 deletions rd_ui/app/scripts/controllers/query_source.js
@@ -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:
Expand All @@ -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();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
]);
})();
34 changes: 26 additions & 8 deletions rd_ui/app/scripts/controllers/query_view.js
@@ -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) {
Expand Down Expand Up @@ -66,6 +66,7 @@

$scope.dataSource = {};
$scope.query = $route.current.locals.query;
$scope.showPermissionsControl = clientConfig.showPermissionsControl;

var updateSchema = function() {
$scope.hasSchema = false;
Expand Down Expand Up @@ -129,19 +130,26 @@
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({}, {
successMessage: 'Query saved',
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;
}

Expand Down Expand Up @@ -339,9 +347,19 @@
}
$scope.selectedTab = hash || DEFAULT_TAB;
});
};

$scope.showSharePermissionsModal = 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/share_permissions.html',
controller: 'SharePermissionsCtrl'
})
};
};
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]);
})();
15 changes: 12 additions & 3 deletions rd_ui/app/scripts/directives/dashboard_directives.js
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 6 additions & 5 deletions rd_ui/app/scripts/services/dashboards.js
@@ -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) {
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions rd_ui/app/views/dashboard.html
Expand Up @@ -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="showSharePermissionsModal()">Manage Permissions</a></li>
<li ng-if="!dashboard.is_archived"><a ng-click="archiveDashboard()">Archive Dashboard</a></li>
</ul>
</div>
Expand Down
39 changes: 39 additions & 0 deletions rd_ui/app/views/dialogs/share_permissions.html
@@ -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>
5 changes: 3 additions & 2 deletions rd_ui/app/views/query.html
Expand Up @@ -41,11 +41,11 @@ <h4 class="modal-title">Query Archive</h4>
<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"
Expand Down Expand Up @@ -125,6 +125,7 @@ <h3>
</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="showSharePermissionsModal()">Manage Permissions</a></li>
<li ng-if="query.id != undefined"><a ng-click="showApiKey()">Show API Key</a></li>
</ul>
</div>
Expand Down
4 changes: 4 additions & 0 deletions redash/handlers/api.py
Expand Up @@ -4,6 +4,7 @@

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
Expand Down Expand Up @@ -71,6 +72,9 @@ def json_representation(data, code, headers=None):
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>',
Expand Down