Change multi-org implementation:

To avoid complications with how Google Auth works, when enabling organization
multi-tenancy on a single instance, each organization becomes a "sub folder"
instead of a sub-domain.
This commit is contained in:
Arik Fraimovich 2016-01-03 16:05:29 +02:00
parent f7b57fa580
commit 7c6b95e71d
63 changed files with 403 additions and 244 deletions

View File

@ -8,7 +8,7 @@ if __name__ == '__main__':
with db.database.transaction():
Organization.create_table()
default_org = Organization.create(name="Default", settings={
default_org = Organization.create(name="Default", slug='default', settings={
Organization.SETTING_GOOGLE_APPS_DOMAINS: settings.GOOGLE_APPS_DOMAIN
})

View File

@ -4,6 +4,7 @@
<!--[if IE 8]> <html class="no-js lt-ie9" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" ng-app="redash" ng-controller='MainCtrl'> <!--<![endif]-->
<head>
<base href="{{base_href}}">
<title ng-bind="'{{name}} | ' + pageTitle"></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
@ -52,13 +53,13 @@
<a href="#" ng-bind="name"></a>
<ul class="dropdown-menu">
<li ng-repeat="dashboard in group" role="presentation">
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
<a role="menu-item" ng-href="dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
</li>
</ul>
</li>
</span>
<li ng-repeat="dashboard in otherDashboards">
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
<a role="menu-item" ng-href="dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
</li>
<li class="divider" ng-show="currentUser.hasPermission('create_dashboard') && (groupedDashboards.length > 0 || otherDashboards.length > 0)"></li>
<li><a data-toggle="modal" href="#new_dashboard_dialog" ng-show="currentUser.hasPermission('create_dashboard')">New Dashboard</a></li>
@ -67,12 +68,12 @@
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')" dropdown>
<a href="#" class="dropdown-toggle" dropdown-toggle>Queries <b class="caret"></b></a>
<ul class="dropdown-menu" dropdown-menu>
<li ng-show="currentUser.hasPermission('create_query')"><a href="/queries/new">New Query</a></li>
<li><a href="/queries">Queries</a></li>
<li ng-show="currentUser.hasPermission('create_query')"><a href="queries/new">New Query</a></li>
<li><a href="queries">Queries</a></li>
</ul>
</li>
<li>
<a href="/alerts">Alerts</a>
<a href="alerts">Alerts</a>
</li>
</ul>
<form class="navbar-form navbar-left" role="search" ng-submit="searchQueries()">
@ -83,16 +84,16 @@
</form>
<ul class="nav navbar-nav navbar-right">
<li ng-show="currentUser.hasPermission('admin')">
<a href="/data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
<a href="data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
</li>
<li ng-show="currentUser.hasPermission('list_users')">
<a href="/users" title="Users"><i class="fa fa-users"></i></a>
<a href="users" title="Users"><i class="fa fa-users"></i></a>
</li>
<li class="dropdown" dropdown>
<a href="#" class="dropdown-toggle" dropdown-toggle><span ng-bind="currentUser.name"></span> <span class="caret"></span></a>
<ul class="dropdown-menu" dropdown-menu>
<li style="width:300px">
<a ng-href="/users/{{currentUser.id}}">
<a ng-href="users/{{currentUser.id}}">
<div class="row">
<div class="col-sm-2">
<img ng-src="{{currentUser.gravatar_url}}" size="40px" class="img-circle"/>
@ -106,7 +107,7 @@
<li class="divider">
</li>
<li>
<a href="/logout" target="_self">Log out</a>
<a href="logout" target="_self">Log out</a>
</li>
</ul>
</li>
@ -229,6 +230,7 @@
// TODO: move currentUser & features to be an Angular service
var clientConfig = {{ client_config|safe }};
var currentUser = {{ user|safe }};
var currentOrgSlug = "{{ org_slug }}";
currentUser.canEdit = function(object) {
var user_id = object.user_id || (object.user && object.user.id);
@ -241,6 +243,7 @@
currentUser.isAdmin = currentUser.hasPermission('admin');
{{ analytics|safe }}
</script>

View File

@ -49,7 +49,7 @@
{% if show_google_openid %}
<div class="row">
<a href="/oauth/google?next={{next}}"><img src="/google_login.png" class="login-button"/></a>
<a href="{{ google_auth_url }}"><img src="/google_login.png" class="login-button"/></a>
</div>
<div class="login-or">

View File

@ -29,7 +29,7 @@
{
"label": "Name",
"map": "name",
"cellTemplate": '<a href="/alerts/{{dataRow.id}}">{{dataRow.name}}</a> (<a href="/queries/{{dataRow.query.id}}">query</a>)'
"cellTemplate": '<a href="alerts/{{dataRow.id}}">{{dataRow.name}}</a> (<a href="queries/{{dataRow.query.id}}">query</a>)'
},
{
'label': 'Created By',

View File

@ -13,7 +13,7 @@
{
"label": "Name",
"map": "name",
"cellTemplate": '<a href="/groups/{{dataRow.id}}">{{dataRow.name}}</a>'
"cellTemplate": '<a href="groups/{{dataRow.id}}">{{dataRow.name}}</a>'
}
];
@ -113,7 +113,7 @@
// Clear selection, to clear up the input control.
$scope.newDataSource.selected = undefined;
$http.post('/api/groups/' + $routeParams.groupId + '/data_sources', {'data_source_id': dataSource.id}).success(function(user) {
$http.post('api/groups/' + $routeParams.groupId + '/data_sources', {'data_source_id': dataSource.id}).success(function(user) {
dataSource.view_only = false;
$scope.dataSources.unshift(dataSource);
@ -124,13 +124,13 @@
};
$scope.changePermission = function(dataSource, viewOnly) {
$http.post('/api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id, {view_only: viewOnly}).success(function() {
$http.post('api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id, {view_only: viewOnly}).success(function() {
dataSource.view_only = viewOnly;
});
};
$scope.removeDataSource = function(dataSource) {
$http.delete('/api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id).success(function() {
$http.delete('api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id).success(function() {
$scope.dataSources = _.filter($scope.dataSources, function(ds) { return dataSource != ds; });
});
};
@ -160,14 +160,14 @@
// Clear selection, to clear up the input control.
$scope.newMember.selected = undefined;
$http.post('/api/groups/' + $routeParams.groupId + '/members', {'user_id': user.id}).success(function() {
$http.post('api/groups/' + $routeParams.groupId + '/members', {'user_id': user.id}).success(function() {
$scope.members.unshift(user);
user.alreadyMember = true;
});
};
$scope.removeMember = function(member) {
$http.delete('/api/groups/' + $routeParams.groupId + '/members/' + member.id).success(function() {
$http.delete('api/groups/' + $routeParams.groupId + '/members/' + member.id).success(function() {
$scope.members = _.filter($scope.members, function(m) { return m != member });
if ($scope.foundUsers) {
@ -191,7 +191,7 @@
{
"label": "Name",
"map": "name",
"cellTemplate": '<img src="{{dataRow.gravatar_url}}" height="40px"/> <a href="/users/{{dataRow.id}}">{{dataRow.name}}</a>'
"cellTemplate": '<img src="{{dataRow.gravatar_url}}" height="40px"/> <a href="users/{{dataRow.id}}">{{dataRow.name}}</a>'
},
{
'label': 'Joined',

View File

@ -81,7 +81,7 @@
$scope.dashboard.layout = layout;
layout = JSON.stringify(layout);
$http.post('/api/dashboards/' + $scope.dashboard.id, {
$http.post('api/dashboards/' + $scope.dashboard.id, {
'name': $scope.dashboard.name,
'layout': layout
}).success(function(response) {
@ -94,7 +94,7 @@
Events.record(currentUser, 'edit', 'dashboard', $scope.dashboard.id);
} else {
$http.post('/api/dashboards', {
$http.post('api/dashboards', {
'name': $scope.dashboard.name
}).success(function(response) {
$(element).modal('hide');

View File

@ -34,7 +34,7 @@
});
});
$http.get('/api/data_sources/types').success(function (types) {
$http.get('api/data_sources/types').success(function (types) {
setType(types);
$scope.dataSourceTypes = types;

View File

@ -40,7 +40,7 @@
}
}]);
directives.directive('rdTab', function () {
directives.directive('rdTab', ['$location', function ($location) {
return {
restrict: 'E',
scope: {
@ -48,9 +48,10 @@
'name': '@'
},
transclude: true,
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="{{basePath}}#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
replace: true,
link: function (scope) {
scope.basePath = $location.path().substring(1);
scope.$watch(function () {
return scope.$parent.selectedTab
}, function (tab) {
@ -58,7 +59,7 @@
});
}
}
});
}]);
directives.directive('rdTabs', ['$location', function ($location) {
return {
@ -67,9 +68,11 @@
tabsCollection: '=',
selectedTab: '='
},
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="{{basePath}}#{{tab.key}}">{{tab.name}}</a></li></ul>',
replace: true,
link: function ($scope, element, attrs) {
$scope.basePath = $location.path().substring(1);
console.log($location.path, $location);
$scope.selectTab = function (tabKey) {
$scope.selectedTab = _.find($scope.tabsCollection, function (tab) {
return tab.key == tabKey;

View File

@ -10,7 +10,7 @@
},
template: '<small><span class="glyphicon glyphicon-link"></span></small> <a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
link: function(scope, element) {
scope.link = '/queries/' + scope.query.id;
scope.link = 'queries/' + scope.query.id;
if (scope.visualization) {
if (scope.visualization.type === 'TABLE') {
// link to hard-coded table tab instead of the (hidden) visualization tab
@ -29,10 +29,10 @@
restrict: 'E',
template: '<span ng-show="query.id && canViewSource">\
<a ng-show="!sourceMode"\
ng-href="/queries/{{query.id}}/source#{{selectedTab}}">Show Source\
ng-href="queries/{{query.id}}/source#{{selectedTab}}">Show Source\
</a>\
<a ng-show="sourceMode"\
ng-href="/queries/{{query.id}}#{{selectedTab}}">Hide Source\
ng-href="queries/{{query.id}}#{{selectedTab}}">Hide Source\
</a>\
</span>'
}
@ -50,7 +50,7 @@
if (scope.queryResult.getId() == null) {
element.attr('href', '');
} else {
element.attr('href', '/api/queries/' + scope.query.id + '/results/' + scope.queryResult.getId() + '.csv');
element.attr('href', 'api/queries/' + scope.query.id + '/results/' + scope.queryResult.getId() + '.csv');
element.attr('download', scope.query.name.replace(" ", "_") + moment(scope.queryResult.getUpdatedAt()).format("_YYYY_MM_DD") + ".csv");
}
});

View File

@ -1,10 +1,10 @@
(function () {
var Dashboard = function($resource) {
var resource = $resource('/api/dashboards/:slug', {slug: '@slug'}, {
var resource = $resource('api/dashboards/:slug', {slug: '@slug'}, {
recent: {
method: 'get',
isArray: true,
url: "/api/dashboards/recent"
url: "api/dashboards/recent"
}});
resource.prototype.canEdit = function() {

View File

@ -24,8 +24,8 @@
};
var QueryResult = function ($resource, $timeout, $q) {
var QueryResultResource = $resource('/api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
var Job = $resource('/api/jobs/:id', {id: '@id'});
var QueryResultResource = $resource('api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
var Job = $resource('api/jobs/:id', {id: '@id'});
var updateFunction = function (props) {
angular.extend(this, props);
@ -414,17 +414,17 @@
};
var Query = function ($resource, QueryResult, DataSource) {
var Query = $resource('/api/queries/:id', {id: '@id'},
var Query = $resource('api/queries/:id', {id: '@id'},
{
search: {
method: 'get',
isArray: true,
url: "/api/queries/search"
url: "api/queries/search"
},
recent: {
method: 'get',
isArray: true,
url: "/api/queries/recent"
url: "api/queries/recent"
}});
Query.newQuery = function () {
@ -546,10 +546,10 @@
var actions = {
'get': {'method': 'GET', 'cache': false, 'isArray': false},
'query': {'method': 'GET', 'cache': false, 'isArray': true},
'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': '/api/data_sources/:id/schema'}
'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/data_sources/:id/schema'}
};
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, actions);
var DataSourceResource = $resource('api/data_sources/:id', {id: '@id'}, actions);
return DataSourceResource;
};
@ -577,7 +577,7 @@
'delete': {method: 'DELETE', transformResponse: transform}
};
var UserResource = $resource('/api/users/:id', {id: '@id'}, actions);
var UserResource = $resource('api/users/:id', {id: '@id'}, actions);
return UserResource;
};
@ -586,15 +586,15 @@
var actions = {
'get': {'method': 'GET', 'cache': false, 'isArray': false},
'query': {'method': 'GET', 'cache': false, 'isArray': true},
'members': {'method': 'GET', 'cache': true, 'isArray': true, 'url': '/api/groups/:id/members'},
'dataSources': {'method': 'GET', 'cache': true, 'isArray': true, 'url': '/api/groups/:id/data_sources'}
'members': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/members'},
'dataSources': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/data_sources'}
};
var resource = $resource('/api/groups/:id', {id: '@id'}, actions);
var resource = $resource('api/groups/:id', {id: '@id'}, actions);
return resource;
};
var AlertSubscription = function ($resource) {
var resource = $resource('/api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
var resource = $resource('api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
return resource;
};
@ -613,13 +613,13 @@
}].concat($http.defaults.transformRequest)
}
};
var resource = $resource('/api/alerts/:id', {id: '@id'}, actions);
var resource = $resource('api/alerts/:id', {id: '@id'}, actions);
return resource;
};
var Widget = function ($resource, Query) {
var WidgetResource = $resource('/api/widgets/:id', {id: '@id'});
var WidgetResource = $resource('api/widgets/:id', {id: '@id'});
WidgetResource.prototype.getQuery = function () {
if (!this.query && this.visualization) {

View File

@ -26,7 +26,7 @@
var events = this.events;
this.events = [];
$http.post('/api/events', events);
$http.post('api/events', events);
}, 1000);

View File

@ -44,7 +44,7 @@
}
this.$get = ['$resource', function ($resource) {
var Visualization = $resource('/api/visualizations/:id', {id: '@id'});
var Visualization = $resource('api/visualizations/:id', {id: '@id'});
Visualization.visualizations = this.visualizations;
Visualization.visualizationTypes = this.visualizationTypes;
Visualization.renderVisualizationsTemplate = this.getSwitchTemplate('renderTemplate');

View File

@ -1,6 +1,6 @@
<div class="container">
<ol class="breadcrumb">
<li><a href="/alerts">Alerts</a></li>
<li><a href="alerts">Alerts</a></li>
<li class="active">{{alert.name || getDefaultName() || "New"}}</li>
</ol>
<div class="row">

View File

@ -5,7 +5,7 @@
<div class="row">
<div class="col-md-12">
<p>
<a href="/alerts/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert</a>
<a href="alerts/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert</a>
</p>
<smart-table rows="alerts" columns="gridColumns"

View File

@ -46,7 +46,7 @@
tooltip-placement="bottom">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
<span class="pull-right">
<a class="btn btn-default btn-xs" ng-href="/queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')"><span class="glyphicon glyphicon-link"></span></a>
<a class="btn btn-default btn-xs" ng-href="queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')"><span class="glyphicon glyphicon-link"></span></a>
<button type="button" class="btn btn-default btn-xs" ng-show="dashboard.canEdit()" ng-click="deleteWidget()" title="Remove Widget"><span class="glyphicon glyphicon-trash"></span></button>
</span>

View File

@ -1,6 +1,6 @@
<div class="container">
<ol class="breadcrumb">
<li><a href="/data_sources">Data Sources</a></li>
<li><a href="data_sources">Data Sources</a></li>
<li class="active">{{dataSource.name || "New"}}</li>
</ol>
<div class="row">

View File

@ -9,7 +9,7 @@
<i class="fa fa-database"></i> {{dataSource.name}}
<button class="btn btn-xs btn-danger pull-right" ng-click="deleteDataSource($event, dataSource)">Delete</button>
</div>
<a ng-href="/data_sources/new" class="list-group-item">
<a ng-href="data_sources/new" class="list-group-item">
<i class="fa fa-plus"></i> Add Data Source
</a>
</div>

View File

@ -1,7 +1,7 @@
<div class="container">
<ul class="nav nav-tabs">
<li role="presentation"><a href="/users">Users</a></li>
<li role="presentation" class="active"><a href="/groups">Groups</a></li>
<li role="presentation"><a href="users">Users</a></li>
<li role="presentation" class="active"><a href="groups">Groups</a></li>
</ul>
<div class="row voffset1">

View File

@ -1,7 +1,7 @@
<div class="container">
<ul class="nav nav-tabs">
<li role="presentation"><a href="/users">Users</a></li>
<li role="presentation" class="active"><a href="/groups">Groups</a></li>
<li role="presentation"><a href="users">Users</a></li>
<li role="presentation" class="active"><a href="groups">Groups</a></li>
</ul>
<group-name group="group"></group-name>
@ -9,8 +9,8 @@
<div class="row">
<div class="col-lg-4">
<ul class="nav nav-pills">
<li role="presentation" class="active"><a href="/groups/{{group.id}}">Members</a></li>
<li role="presentation" ng-if="currentUser.isAdmin"><a href="/groups/{{group.id}}/data_sources">Data Sources</a></li>
<li role="presentation" class="active"><a href="groups/{{group.id}}">Members</a></li>
<li role="presentation" ng-if="currentUser.isAdmin"><a href="groups/{{group.id}}/data_sources">Data Sources</a></li>
</ul>
</div>

View File

@ -1,7 +1,7 @@
<div class="container">
<ul class="nav nav-tabs">
<li role="presentation"><a href="/users">Users</a></li>
<li role="presentation" class="active"><a href="/groups">Groups</a></li>
<li role="presentation"><a href="users">Users</a></li>
<li role="presentation" class="active"><a href="groups">Groups</a></li>
</ul>
<group-name group="group"></group-name>
@ -9,8 +9,8 @@
<div class="row">
<div class="col-lg-4">
<ul class="nav nav-pills">
<li role="presentation"><a href="/groups/{{group.id}}">Members</a></li>
<li role="presentation" class="active"><a href="/groups/{{group.id}}/data_sources">Data Sources</a></li>
<li role="presentation"><a href="groups/{{group.id}}">Members</a></li>
<li role="presentation" class="active"><a href="groups/{{group.id}}/data_sources">Data Sources</a></li>
</ul>
</div>
<div class="col-lg-8">

View File

@ -7,14 +7,14 @@
</div>
<div class="list-group-item" ng-repeat="dashboard in dashboards" >
<button type="button" class="close delete-button" aria-hidden="true" ng-show="dashboard.canEdit()" ng-click="archiveDashboard(dashboard)" tooltip="Delete Dashboard">&times;</button>
<a ng-href="/dashboard/{{dashboard.slug}}">{{dashboard.name}}</a>
<a ng-href="dashboard/{{dashboard.slug}}">{{dashboard.name}}</a>
</div>
</div>
<div ng-show="currentUser.hasPermission('super_admin')">
<div class="list-group">
<div class="list-group-item active">Admin</div>
<a href="/admin/status" class="list-group-item">Status</a>
<a href="admin/status" class="list-group-item">Status</a>
</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
<div class="container">
<div class="row">
<p>
<a href="/queries/new" class="btn btn-default">New Query</a>
<a href="queries/new" class="btn btn-default">New Query</a>
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-default" data-toggle="modal" href="#new_dashboard_dialog">New Dashboard</button>
<a href="/alerts/new" class="btn btn-default">New Alert</a>
<a href="alerts/new" class="btn btn-default">New Alert</a>
</p>
</div>
@ -12,7 +12,7 @@
<div class="list-group-item active">
Recent Dashboards
</div>
<a ng-href="/dashboard/{{dashboard.slug}}" class="list-group-item" ng-repeat="dashboard in recentDashboards">
<a ng-href="dashboard/{{dashboard.slug}}" class="list-group-item" ng-repeat="dashboard in recentDashboards">
{{dashboard.name}}
</a>
</div>
@ -21,14 +21,14 @@
<div class="list-group-item active">
Recent Queries
</div>
<a ng-href="/queries/{{query.id}}" class="list-group-item" ng-repeat="query in recentQueries">{{query.name}}</a>
<a ng-href="queries/{{query.id}}" class="list-group-item" ng-repeat="query in recentQueries">{{query.name}}</a>
</div>
</div>
<div ng-show="currentUser.hasPermission('admin')" class="row">
<div class="list-group">
<div class="list-group-item active">Admin</div>
<a href="/admin/status" class="list-group-item">Status</a>
<a href="admin/status" class="list-group-item">Status</a>
</div>
</div>
</div>

View File

@ -1 +1 @@
<a ng-href="/queries/{{dataRow.id}}">{{dataRow.name}}</a>
<a ng-href="queries/{{dataRow.id}}">{{dataRow.name}}</a>

View File

@ -4,7 +4,7 @@
<div style="width: 100%; position:absolute; top:50px; z-index:2000">
<div class="well well-lg" style="width: 70%; margin: auto;">
You don't have permission to create new queries on any of the data sources available to you.
You can either <a href="/queries">browse existing queries</a>, or ask for additional permissions from your re:dash admin.
You can either <a href="queries">browse existing queries</a>, or ask for additional permissions from your re:dash admin.
</div>
</div>
</div>

View File

@ -1,13 +1,13 @@
<div class="container">
<ul class="nav nav-tabs">
<li role="presentation" class="active"><a href="/users">Users</a></li>
<li role="presentation"><a href="/groups">Groups</a></li>
<li role="presentation" class="active"><a href="users">Users</a></li>
<li role="presentation"><a href="groups">Groups</a></li>
</ul>
<div class="row voffset1">
<div class="col-md-12">
<p ng-if="currentUser.hasPermission('admin')">
<a href="/users/new" class="btn btn-default"><i class="fa fa-plus"></i> New User</a>
<a href="users/new" class="btn btn-default"><i class="fa fa-plus"></i> New User</a>
</p>
<smart-table rows="users" columns="gridColumns"

View File

@ -1,7 +1,7 @@
<div class="container">
<ul class="nav nav-tabs">
<li role="presentation" class="active"><a href="/users">Users</a></li>
<li role="presentation"><a href="/groups">Groups</a></li>
<li role="presentation" class="active"><a href="users">Users</a></li>
<li role="presentation"><a href="groups">Groups</a></li>
</ul>
<form class="form" name="userForm" ng-submit="saveUser()" novalidate>

View File

@ -1,7 +1,7 @@
<div class="container">
<ul class="nav nav-tabs">
<li role="presentation" class="active"><a href="/users">Users</a></li>
<li role="presentation"><a href="/groups">Groups</a></li>
<li role="presentation" class="active"><a href="users">Users</a></li>
<li role="presentation"><a href="groups">Groups</a></li>
</ul>
<h2>{{user.name}}</h2>

View File

@ -19,7 +19,7 @@
<span class="label label-default">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
<span class="pull-right">
<a class="btn btn-default btn-xs" ng-href="/queries/{{query.id}}#{{widget.visualization.id}}" target="_blank"><span class="glyphicon glyphicon-link"></span></a>
<a class="btn btn-default btn-xs" ng-href="queries/{{query.id}}#{{widget.visualization.id}}" target="_blank"><span class="glyphicon glyphicon-link"></span></a>
</span>
<span class="pull-right">

View File

@ -3,13 +3,14 @@ import hmac
import time
import logging
from flask import redirect, request, jsonify
from flask.ext.login import LoginManager
from flask.ext.login import user_logged_in
from redash import models, settings
from redash.authentication import google_oauth, saml_auth
from redash.authentication.org_resolving import current_org
from redash.authentication.helper import get_login_url
from redash.tasks import record_event
login_manager = LoginManager()
@ -28,7 +29,10 @@ def sign(key, path, expires):
@login_manager.user_loader
def load_user(user_id):
return models.User.get_by_id_and_org(user_id, current_org.id)
try:
return models.User.get_by_id_and_org(user_id, current_org.id)
except models.User.DoesNotExist:
return None
def hmac_load_user_from_request(request):
@ -61,11 +65,12 @@ def get_user_from_api_key(api_key, query_id):
return None
user = None
try:
user = models.User.get_by_api_key(api_key)
user = models.User.get_by_api_key_and_org(api_key, current_org.id)
except models.User.DoesNotExist:
if query_id:
query = models.Query.get_by_id(query_id)
query = models.Query.get_by_id_and_org(query_id, current_org.id)
if query and query.api_key == api_key:
user = models.ApiUser(api_key, query.org, query.groups.keys())
@ -101,10 +106,22 @@ def log_user_logged_in(app, user):
record_event.delay(event)
@login_manager.unauthorized_handler
def redirect_to_login():
if request.is_xhr or '/api/' in request.path:
response = jsonify({'message': "Couldn't find resource. Please login and try again."})
response.status_code = 404
return response
login_url = get_login_url(next=request.url, external=False)
return redirect(login_url)
def setup_authentication(app):
login_manager.init_app(app)
login_manager.anonymous_user = models.AnonymousUser
login_manager.login_view = 'login'
app.secret_key = settings.COOKIE_SECRET
app.register_blueprint(google_oauth.blueprint)
app.register_blueprint(saml_auth.blueprint)

View File

@ -1,7 +1,7 @@
import logging
from flask.ext.login import login_user
import requests
from flask import redirect, url_for, Blueprint, flash, request
from flask import redirect, url_for, Blueprint, flash, request, session
from flask_oauthlib.client import OAuth
from redash import models, settings
from redash.authentication.org_resolving import current_org
@ -62,10 +62,16 @@ def create_and_login_user(org, name, email):
login_user(user_object, remember=True)
@blueprint.route('/<org_slug>/oauth/google', endpoint="authorize_org")
def org_login(org_slug):
session['org_slug'] = current_org.slug
return redirect(url_for(".authorize", next=request.args.get('next', None)))
@blueprint.route('/oauth/google', endpoint="authorize")
def login():
next = request.args.get('next', '/')
callback = url_for('.callback', _external=True)
next = request.args.get('next', url_for("index", org_slug=session.get('org_slug')))
logger.debug("Callback url: %s", callback)
logger.debug("Next is: %s", next)
return google_remote_app().authorize(callback=callback, state=next)
@ -86,13 +92,18 @@ def authorized():
flash("Validation error. Please retry.")
return redirect(url_for('login'))
if not verify_profile(current_org, profile):
logger.warning("User tried to login with unauthorized domain name: %s (org: %s)", profile['email'], current_org)
if 'org_slug' in session:
org = models.Organization.get_by_slug(session.pop('org_slug'))
else:
org = current_org
if not verify_profile(org, profile):
logger.warning("User tried to login with unauthorized domain name: %s (org: %s)", profile['email'], org)
flash("Your Google Apps domain name isn't allowed.")
return redirect(url_for('login'))
return redirect(url_for('login', org_slug=org.slug))
create_and_login_user(current_org.id, profile['name'], profile['email'])
create_and_login_user(org.id, profile['name'], profile['email'])
next = request.args.get('state', '/')
next = request.args.get('state') or url_for("index", org_slug=org.slug)
return redirect(next)

View File

@ -0,0 +1,13 @@
from redash import settings
from redash.authentication.org_resolving import current_org
from flask import url_for, request
# TODO: move this back to authentication/__init__.py after resolving circular depdency between redash.wsgi and redash.handler
def get_login_url(external=False, next="/"):
if settings.MULTI_ORG:
login_url = url_for('login', org_slug=current_org.slug, next=next, _external=external)
else:
login_url = url_for('login', next=next, _external=external)
return login_url

View File

@ -4,26 +4,19 @@ single_org strategy, which assumes you have a single Organization in your instal
"""
import logging
from redash import settings
from redash.models import Organization
from werkzeug.local import LocalProxy
from flask import request
def single_org(request):
return Organization.select().first()
def multi_org(request):
return Organization.get_by_domain(request.host)
def _get_current_org():
org = globals()[settings.ORG_RESOLVING](request)
logging.debug("Current organization: %s (resolved with: %s)", org, settings.ORG_RESOLVING)
slug = request.view_args.get('org_slug', 'default')
org = Organization.get_by_slug(slug)
logging.debug("Current organization: %s (slug: %s)", org, slug)
return org
# TODO: move to authentication
current_org = LocalProxy(_get_current_org)

View File

@ -1,11 +1,19 @@
from flask import jsonify
from flask import jsonify, url_for
from flask_login import login_required
from redash import settings
from redash.wsgi import app
from redash.permissions import require_super_admin
from redash.monitor import get_status
def org_scoped_rule(rule):
if settings.MULTI_ORG:
return "/<org_slug:org_slug>{}".format(rule)
return rule
@app.route('/ping', methods=['GET'])
def ping():
return 'PONG.'

View File

@ -115,7 +115,7 @@ class AlertSubscriptionResource(BaseResource):
'object_type': 'alert'
})
api.add_resource(AlertResource, '/api/alerts/<alert_id>', endpoint='alert')
api.add_resource(AlertSubscriptionListResource, '/api/alerts/<alert_id>/subscriptions', endpoint='alert_subscriptions')
api.add_resource(AlertSubscriptionResource, '/api/alerts/<alert_id>/subscriptions/<subscriber_id>', endpoint='alert_subscription')
api.add_resource(AlertListResource, '/api/alerts', endpoint='alerts')
api.add_org_resource(AlertResource, '/api/alerts/<alert_id>', endpoint='alert')
api.add_org_resource(AlertSubscriptionListResource, '/api/alerts/<alert_id>/subscriptions', endpoint='alert_subscriptions')
api.add_org_resource(AlertSubscriptionResource, '/api/alerts/<alert_id>/subscriptions/<subscriber_id>', endpoint='alert_subscription')
api.add_org_resource(AlertListResource, '/api/alerts', endpoint='alerts')

View File

@ -1,16 +1,19 @@
from flask import render_template, request, redirect, session, url_for, flash
from flask import render_template, request, redirect, url_for, flash
from flask_login import current_user, login_user, logout_user
from redash import models, settings
from redash.wsgi import app
from redash.handlers import org_scoped_rule
from redash.authentication.org_resolving import current_org
from redash.authentication.helper import get_login_url
@app.route('/login', methods=['GET', 'POST'])
def login():
next_path = request.args.get('next', '/')
@app.route(org_scoped_rule('/login'), methods=['GET', 'POST'])
def login(org_slug=None):
index_url = url_for("index", org_slug=org_slug)
next_path = request.args.get('next', index_url)
if current_user.is_authenticated():
if current_user.is_authenticated:
return redirect(next_path)
if not settings.PASSWORD_LOGIN_ENABLED:
@ -31,19 +34,22 @@ def login():
except models.User.DoesNotExist:
flash("Wrong email or password.")
if settings.MULTI_ORG:
google_auth_url = url_for('google_oauth.authorize_org', next=next_path, org_slug=current_org.slug)
else:
google_auth_url = url_for('google_oauth.authorize', next=next_path)
return render_template("login.html",
name=settings.NAME,
analytics=settings.ANALYTICS,
next=next_path,
username=request.form.get('username', ''),
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
google_auth_url=google_auth_url,
show_saml_login=settings.SAML_LOGIN_ENABLED)
@app.route('/logout')
def logout():
@app.route(org_scoped_rule('/logout'))
def logout(org_slug=None):
logout_user()
# TODO(@arikfr): need to check if this is really needed.
session.pop('openid', None)
return redirect('/login')
return redirect(get_login_url())

View File

@ -12,6 +12,11 @@ class BaseResource(Resource):
super(BaseResource, self).__init__(*args, **kwargs)
self._user = None
def dispatch_request(self, *args, **kwargs):
kwargs.pop('org_slug', None)
return super(BaseResource, self).dispatch_request(*args, **kwargs)
@property
def current_user(self):
return current_user._get_current_object()

View File

@ -62,7 +62,7 @@ class DashboardAPI(BaseResource):
return dashboard.to_dict(with_widgets=True, user=self.current_user)
api.add_resource(DashboardListAPI, '/api/dashboards', endpoint='dashboards')
api.add_resource(DashboardRecentAPI, '/api/dashboards/recent', endpoint='recent_dashboards')
api.add_resource(DashboardAPI, '/api/dashboards/<dashboard_slug>', endpoint='dashboard')
api.add_org_resource(DashboardListAPI, '/api/dashboards', endpoint='dashboards')
api.add_org_resource(DashboardRecentAPI, '/api/dashboards/recent', endpoint='recent_dashboards')
api.add_org_resource(DashboardAPI, '/api/dashboards/<dashboard_slug>', endpoint='dashboard')

View File

@ -16,7 +16,7 @@ class DataSourceTypeListAPI(BaseResource):
def get(self):
return [q.to_dict() for q in query_runners.values()]
api.add_resource(DataSourceTypeListAPI, '/api/data_sources/types', endpoint='data_source_types')
api.add_org_resource(DataSourceTypeListAPI, '/api/data_sources/types', endpoint='data_source_types')
class DataSourceAPI(BaseResource):
@ -80,8 +80,8 @@ class DataSourceListAPI(BaseResource):
return datasource.to_dict(all=True)
api.add_resource(DataSourceListAPI, '/api/data_sources', endpoint='data_sources')
api.add_resource(DataSourceAPI, '/api/data_sources/<data_source_id>', endpoint='data_source')
api.add_org_resource(DataSourceListAPI, '/api/data_sources', endpoint='data_sources')
api.add_org_resource(DataSourceAPI, '/api/data_sources/<data_source_id>', endpoint='data_source')
class DataSourceSchemaAPI(BaseResource):
@ -91,4 +91,4 @@ class DataSourceSchemaAPI(BaseResource):
return schema
api.add_resource(DataSourceSchemaAPI, '/api/data_sources/<data_source_id>/schema')
api.add_org_resource(DataSourceSchemaAPI, '/api/data_sources/<data_source_id>/schema')

View File

@ -1,19 +1,20 @@
import logging
from flask import render_template, request, redirect, session, url_for, flash
from flask import render_template
from flask.ext.restful import abort
from flask_login import current_user, login_required
from flask_login import login_required
from redash import models, settings
from redash.wsgi import app
from redash.utils import json_dumps
from redash.handlers import org_scoped_rule
from redash.authentication.org_resolving import current_org
@app.route('/embed/query/<query_id>/visualization/<visualization_id>', methods=['GET'])
@app.route(org_scoped_rule('/embed/query/<query_id>/visualization/<visualization_id>'), methods=['GET'])
@login_required
def embed(query_id, visualization_id):
# TODO: should use some helper here insetad of current_user.org to allow refactoring of org retrieval logic.
query = models.Query.get_by_id_and_org(query_id, current_user.org)
def embed(query_id, visualization_id, org_slug=None):
# TODO: add event for embed access
query = models.Query.get_by_id_and_org(query_id, current_org)
vis = query.visualizations.where(models.Visualization.id == visualization_id).first()
qr = {}

View File

@ -16,7 +16,7 @@ class EventAPI(BaseResource):
record_event.delay(event)
api.add_resource(EventAPI, '/api/events', endpoint='events')
api.add_org_resource(EventAPI, '/api/events', endpoint='events')
class MetricsAPI(BaseResource):

View File

@ -186,9 +186,9 @@ class GroupDataSourceResource(BaseResource):
})
api.add_resource(GroupListResource, '/api/groups', endpoint='groups')
api.add_resource(GroupResource, '/api/groups/<group_id>', endpoint='group')
api.add_resource(GroupMemberListResource, '/api/groups/<group_id>/members', endpoint='group_members')
api.add_resource(GroupMemberResource, '/api/groups/<group_id>/members/<user_id>', endpoint='group_member')
api.add_resource(GroupDataSourceListResource, '/api/groups/<group_id>/data_sources', endpoint='group_data_sources')
api.add_resource(GroupDataSourceResource, '/api/groups/<group_id>/data_sources/<data_source_id>', endpoint='group_data_source')
api.add_org_resource(GroupListResource, '/api/groups', endpoint='groups')
api.add_org_resource(GroupResource, '/api/groups/<group_id>', endpoint='group')
api.add_org_resource(GroupMemberListResource, '/api/groups/<group_id>/members', endpoint='group_members')
api.add_org_resource(GroupMemberResource, '/api/groups/<group_id>/members/<user_id>', endpoint='group_member')
api.add_org_resource(GroupDataSourceListResource, '/api/groups/<group_id>/data_sources', endpoint='group_data_sources')
api.add_org_resource(GroupDataSourceResource, '/api/groups/<group_id>/data_sources/<data_source_id>', endpoint='group_data_source')

View File

@ -105,7 +105,7 @@ class QueryAPI(BaseResource):
query.archive()
api.add_resource(QuerySearchAPI, '/api/queries/search', endpoint='queries_search')
api.add_resource(QueryRecentAPI, '/api/queries/recent', endpoint='recent_queries')
api.add_resource(QueryListAPI, '/api/queries', endpoint='queries')
api.add_resource(QueryAPI, '/api/queries/<query_id>', endpoint='query')
api.add_org_resource(QuerySearchAPI, '/api/queries/search', endpoint='queries_search')
api.add_org_resource(QueryRecentAPI, '/api/queries/recent', endpoint='recent_queries')
api.add_org_resource(QueryListAPI, '/api/queries', endpoint='queries')
api.add_org_resource(QueryAPI, '/api/queries/<query_id>', endpoint='query')

View File

@ -138,8 +138,8 @@ class QueryResultAPI(BaseResource):
return make_response(s.getvalue(), 200, headers)
api.add_resource(QueryResultListAPI, '/api/query_results', endpoint='query_results')
api.add_resource(QueryResultAPI,
api.add_org_resource(QueryResultListAPI, '/api/query_results', endpoint='query_results')
api.add_org_resource(QueryResultAPI,
'/api/query_results/<query_result_id>',
'/api/queries/<query_id>/results.<filetype>',
'/api/queries/<query_id>/results/<query_result_id>.<filetype>',
@ -156,4 +156,4 @@ class JobAPI(BaseResource):
job = QueryTask(job_id=job_id)
job.cancel()
api.add_resource(JobAPI, '/api/jobs/<job_id>', endpoint='job')
api.add_org_resource(JobAPI, '/api/jobs/<job_id>', endpoint='job')

View File

@ -1,33 +1,30 @@
import hashlib
import json
from flask import render_template, send_from_directory, current_app
from flask import render_template, send_from_directory, current_app, url_for, request
from flask_login import current_user, login_required
from redash import settings, __version__, redis_connection
from redash import settings, __version__
from redash.handlers import org_scoped_rule
from redash.wsgi import app
from redash.version_check import get_latest_version
from redash.authentication.org_resolving import current_org
@app.route('/<path:filename>')
def send_static(filename):
if current_app.debug:
cache_timeout = 0
else:
cache_timeout = None
return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout)
@app.route('/admin/<anything>/<whatever>')
@app.route('/admin/<anything>')
@app.route('/dashboard/<anything>')
@app.route('/alerts')
@app.route('/alerts/<pk>')
@app.route('/queries')
@app.route('/data_sources')
@app.route('/data_sources/<pk>')
@app.route('/users')
@app.route('/users/<pk>')
@app.route('/groups')
@app.route('/groups/<pk>')
@app.route('/groups/<pk>/data_sources')
@app.route('/queries/<query_id>')
@app.route('/queries/<query_id>/<anything>')
@app.route('/personal')
@app.route('/')
@login_required
def index(**kwargs):
email_md5 = hashlib.md5(current_user.email.lower()).hexdigest()
gravatar_url = "https://www.gravatar.com/avatar/%s?s=40" % email_md5
@ -51,20 +48,46 @@ def index(**kwargs):
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate'
}
if settings.MULTI_ORG:
base_href = url_for('index', _external=True, org_slug=current_org.slug)
else:
base_href = url_for('index', _external=True)
response = render_template("index.html",
user=json.dumps(user),
base_href=base_href,
name=settings.NAME,
org_slug=current_org.slug,
client_config=json.dumps(client_config),
analytics=settings.ANALYTICS)
return response, 200, headers
@app.route('/<path:filename>')
def send_static(filename):
if current_app.debug:
cache_timeout = 0
else:
cache_timeout = None
def register_static_routes(rules):
# Make sure that / is the first route considered as index.
app.add_url_rule(org_scoped_rule("/"), "index", index)
for rule in rules:
app.add_url_rule(org_scoped_rule(rule), None, index)
rules = ['/admin/<anything>/<whatever>',
'/admin/<anything>',
'/dashboard/<anything>',
'/alerts',
'/alerts/<pk>',
'/queries',
'/data_sources',
'/data_sources/<pk>',
'/users',
'/users/<pk>',
'/groups',
'/groups/<pk>',
'/groups/<pk>/data_sources',
'/queries/<query_id>',
'/queries/<query_id>/<anything>',
'/personal']
register_static_routes(rules)
return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout)

View File

@ -95,7 +95,7 @@ class UserResource(BaseResource):
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
api.add_resource(UserListResource, '/api/users', endpoint='users')
api.add_resource(UserResource, '/api/users/<user_id>', endpoint='user')
api.add_org_resource(UserListResource, '/api/users', endpoint='users')
api.add_org_resource(UserResource, '/api/users/<user_id>', endpoint='user')

View File

@ -47,5 +47,5 @@ class VisualizationResource(BaseResource):
vis.delete_instance()
api.add_resource(VisualizationListResource, '/api/visualizations', endpoint='visualizations')
api.add_resource(VisualizationResource, '/api/visualizations/<visualization_id>', endpoint='visualization')
api.add_org_resource(VisualizationListResource, '/api/visualizations', endpoint='visualizations')
api.add_org_resource(VisualizationResource, '/api/visualizations/<visualization_id>', endpoint='visualization')

View File

@ -51,5 +51,5 @@ class WidgetAPI(BaseResource):
return {'layout': widget.dashboard.layout}
api.add_resource(WidgetListAPI, '/api/widgets', endpoint='widgets')
api.add_resource(WidgetAPI, '/api/widgets/<int:widget_id>', endpoint='widget')
api.add_org_resource(WidgetListAPI, '/api/widgets', endpoint='widgets')
api.add_org_resource(WidgetAPI, '/api/widgets/<int:widget_id>', endpoint='widget')

View File

@ -161,7 +161,7 @@ class Organization(ModelTimestampsMixin, BaseModel):
id = peewee.PrimaryKeyField()
name = peewee.CharField()
domain = peewee.CharField(null=True, unique=True)
slug = peewee.CharField(unique=True)
settings = JSONField()
class Meta:
@ -171,8 +171,8 @@ class Organization(ModelTimestampsMixin, BaseModel):
return u"<Organization: {}, {}>".format(self.id, self.name)
@classmethod
def get_by_domain(cls, domain):
return cls.get(cls.domain == domain)
def get_by_slug(cls, slug):
return cls.get(cls.slug == slug)
@property
def default_group(self):
@ -291,8 +291,8 @@ class User(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin, UserMixin, Permis
return cls.get(cls.email == email, cls.org == org)
@classmethod
def get_by_api_key(cls, api_key):
return cls.get(cls.api_key == api_key)
def get_by_api_key_and_org(cls, api_key, org):
return cls.get(cls.api_key == api_key, cls.org == org)
@classmethod
def all(cls, org):
@ -1023,7 +1023,7 @@ all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResul
def init_db():
default_org = Organization.create(name="Default", settings={})
default_org = Organization.create(name="Default", slug='default', settings={})
admin_group = Group.create(name='admin', permissions=['admin', 'super_admin'], org=default_org, type=Group.BUILTIN_GROUP)
default_group = Group.create(name='default', permissions=Group.DEFAULT_PERMISSIONS, org=default_org, type=Group.BUILTIN_GROUP)

View File

@ -25,6 +25,7 @@ PRESTO_TYPES_MAPPING = {
"date" : TYPE_DATE,
}
class Presto(BaseQueryRunner):
@classmethod
def configuration_schema(cls):

View File

@ -74,7 +74,8 @@ QUERY_RESULTS_CLEANUP_MAX_AGE = int(os.environ.get("REDASH_QUERY_RESULTS_CLEANUP
AUTH_TYPE = os.environ.get("REDASH_AUTH_TYPE", "api_key")
PASSWORD_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_PASSWORD_LOGIN_ENABLED", "true"))
ORG_RESOLVING = os.environ.get("REDASH_ORG_RESOLVING", "single_org")
MULTI_ORG = parse_boolean(os.environ.get("REDASH_MULTI_ORG", "false"))
# The following is deprecated and should be defined with the Organization object
GOOGLE_APPS_DOMAIN = set_from_string(os.environ.get("REDASH_GOOGLE_APPS_DOMAIN", ""))
@ -153,8 +154,6 @@ SENTRY_DSN = os.environ.get("REDASH_SENTRY_DSN", "")
# Client side toggles:
ALLOW_SCRIPTS_IN_USER_INPUT = parse_boolean(os.environ.get("REDASH_ALLOW_SCRIPTS_IN_USER_INPUT", "false"))
CLIENT_SIDE_METRICS = parse_boolean(os.environ.get("REDASH_CLIENT_SIDE_METRICS", "false"))
# http://api.highcharts.com/highcharts#plotOptions.series.turboThreshold
HIGHCHARTS_TURBO_THRESHOLD = int(os.environ.get("REDASH_HIGHCHARTS_TURBO_THRESHOLD", "1000"))
DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY")
# Features:
@ -172,7 +171,6 @@ SCHEMA_RUN_TABLE_SIZE_CALCULATIONS = parse_boolean(os.environ.get("REDASH_SCHEMA
COMMON_CLIENT_CONFIG = {
'clientSideMetrics': CLIENT_SIDE_METRICS,
'allowScriptsInUserInput': ALLOW_SCRIPTS_IN_USER_INPUT,
'highChartsTurboThreshold': HIGHCHARTS_TURBO_THRESHOLD,
'dateFormat': DATE_FORMAT,
'dateTimeFormat': "{0} HH:mm".format(DATE_FORMAT),
'allowAllToEditQueries': FEATURE_ALLOW_ALL_TO_EDIT_QUERIES,

View File

@ -63,6 +63,16 @@ def json_dumps(data):
return json.dumps(data, cls=JSONEncoder)
def build_url(request, host, path):
parts = request.host.split(':')
if len(parts) > 1:
port = parts[1]
if (port, request.scheme) not in (('80', 'http'), ('443', 'https')):
host = '{}:{}'.format(host, port)
return "{}://{}{}".format(request.scheme, host, path)
class UnicodeWriter:
"""
A CSV writer which will write rows to CSV file "f",

View File

@ -8,6 +8,19 @@ from redash import settings, utils, mail, __version__
from redash.models import db
from redash.metrics.request import provision_app
from redash.admin import init_admin
from werkzeug.routing import BaseConverter, ValidationError
class SlugConverter(BaseConverter):
def to_python(self, value):
# This is an ugly workaround for when we enable multi-org and some files are being called by the index rule:
if value in ('google_login.png', 'favicon.ico', 'robots.txt', 'views'):
raise ValidationError()
return value
def to_url(self, value):
return value
app = Flask(__name__,
@ -17,9 +30,24 @@ app = Flask(__name__,
# Make sure we get the right referral address even behind proxies like nginx.
app.wsgi_app = ProxyFix(app.wsgi_app)
app.url_map.converters['org_slug'] = SlugConverter
provision_app(app)
api = Api(app)
# TODO: remove duplication
def org_scoped_rule(rule):
if settings.MULTI_ORG:
return "/<org_slug>{}".format(rule)
return rule
class ApiExt(Api):
def add_org_resource(self, resource, *urls, **kwargs):
urls = [org_scoped_rule(url) for url in urls]
return self.add_resource(resource, *urls, **kwargs)
api = ApiExt(app)
init_admin(app)

View File

@ -1,7 +1,7 @@
Flask==0.10.1
Flask-Admin==1.1.0
Flask-RESTful==0.3.5
Flask-Login==0.2.11
Flask-Login==0.3.2
Flask-OAuthLib==0.9.2
flask-mail==0.9.1
passlib==1.6.2

View File

@ -6,6 +6,7 @@ os.environ['REDASH_CELERY_BROKER'] = "redis://localhost:6379/6"
# Dummy values for oauth login
os.environ['REDASH_GOOGLE_CLIENT_ID'] = "dummy"
os.environ['REDASH_GOOGLE_CLIENT_SECRET'] = "dummy"
os.environ['REDASH_MULTI_ORG'] = "true"
import logging
@ -19,8 +20,6 @@ settings.DATABASE_CONFIG = {
'threadlocals': True
}
settings.ORG_RESOLVING = "multi_org"
from redash import redis_connection
import redash.models
from tests.handlers import make_request
@ -40,11 +39,19 @@ class BaseTestCase(TestCase):
redash.models.create_db(False, True)
redis_connection.flushdb()
def make_request(self, method, path, user=None, data=None, is_json=True):
def make_request(self, method, path, org=None, user=None, data=None, is_json=True):
if user is None:
user = self.factory.user
if org is None:
org = self.factory.org
if org is not False:
path = "/{}{}".format(org.slug, path)
return make_request(method, path, user, data, is_json)
def assertResponseEqual(self, expected, actual):
for k, v in expected.iteritems():
if isinstance(v, datetime.datetime) or isinstance(actual[k], datetime.datetime):

View File

@ -45,7 +45,7 @@ user_factory = ModelFactory(redash.models.User,
org_factory = ModelFactory(redash.models.Organization,
name=Sequence("Org {}"),
domain=Sequence("org{}.example.com"),
slug=Sequence("org{}.example.com"),
settings={})
data_source_factory = ModelFactory(redash.models.DataSource,

File diff suppressed because one or more lines are too long

View File

@ -44,8 +44,7 @@ def make_request(method, path, user, data=None, is_json=True):
else:
content_type = None
base_url = "http://{}".format(user.org.domain)
response = method_fn(path, data=data, headers=headers, content_type=content_type, base_url=base_url)
response = method_fn(path, data=data, headers=headers, content_type=content_type)
if response.data and is_json:
response.json = json.loads(response.data)

View File

@ -20,13 +20,13 @@ class TestAlertResourceGet(BaseTestCase):
rv = self.make_request('get', "/api/alerts/{}".format(alert.id))
self.assertEqual(rv.status_code, 403)
def test_returns_403_if_admin_from_another_org(self):
def test_returns_404_if_admin_from_another_org(self):
second_org = self.factory.create_org()
second_org_admin = self.factory.create_admin(org=second_org)
alert = self.factory.create_alert()
rv = self.make_request('get', "/api/alerts/{}".format(alert.id), user=second_org_admin)
rv = self.make_request('get', "/api/alerts/{}".format(alert.id), org=second_org, user=second_org_admin)
self.assertEqual(rv.status_code, 404)

View File

@ -1,14 +1,12 @@
from tests import BaseTestCase
from tests.factories import org_factory
from tests.handlers import make_request
class TestDataSourceGetSchema(BaseTestCase):
def test_fails_if_user_doesnt_belong_to_org(self):
other_user = self.factory.create_user(org=self.factory.create_org())
response = make_request("get", "/api/data_sources/{}/schema".format(self.factory.data_source.id), user=other_user)
response = self.make_request("get", "/api/data_sources/{}/schema".format(self.factory.data_source.id), user=other_user)
self.assertEqual(response.status_code, 404)
other_admin = self.factory.create_admin(org=self.factory.create_org())
response = make_request("get", "/api/data_sources/{}/schema".format(self.factory.data_source.id), user=other_admin)
response = self.make_request("get", "/api/data_sources/{}/schema".format(self.factory.data_source.id), user=other_admin)
self.assertEqual(response.status_code, 404)

View File

@ -18,43 +18,52 @@ class TestApiKeyAuthentication(BaseTestCase):
super(TestApiKeyAuthentication, self).setUp()
self.api_key = 10
self.query = self.factory.create_query(api_key=self.api_key)
self.query_url = '/{}/api/queries/{}'.format(self.factory.org.slug, self.query.id)
self.queries_url = '/{}/api/queries'.format(self.factory.org.slug)
def test_no_api_key(self):
with app.test_client() as c:
rv = c.get('/api/queries/{0}'.format(self.query.id))
rv = c.get(self.query_url)
self.assertIsNone(api_key_load_user_from_request(request))
def test_wrong_api_key(self):
with app.test_client() as c:
rv = c.get('/api/queries/{0}'.format(self.query.id), query_string={'api_key': 'whatever'})
rv = c.get(self.query_url, query_string={'api_key': 'whatever'})
self.assertIsNone(api_key_load_user_from_request(request))
def test_correct_api_key(self):
with app.test_client() as c:
rv = c.get('/api/queries/{0}'.format(self.query.id), query_string={'api_key': self.api_key})
rv = c.get(self.query_url, query_string={'api_key': self.api_key})
self.assertIsNotNone(api_key_load_user_from_request(request))
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(self.queries_url, query_string={'api_key': self.api_key})
self.assertIsNone(api_key_load_user_from_request(request))
def test_user_api_key(self):
user = self.factory.create_user(api_key="user_key")
with app.test_client() as c:
rv = c.get('/api/queries/', query_string={'api_key': user.api_key})
rv = c.get(self.queries_url, query_string={'api_key': user.api_key})
self.assertEqual(user.id, api_key_load_user_from_request(request).id)
def test_api_key_header(self):
with app.test_client() as c:
rv = c.get('/api/queries/{}'.format(self.query.id), headers={'Authorization': "Key {}".format(self.api_key)})
rv = c.get(self.query_url, headers={'Authorization': "Key {}".format(self.api_key)})
self.assertIsNotNone(api_key_load_user_from_request(request))
def test_api_key_header_with_wrong_key(self):
with app.test_client() as c:
rv = c.get('/api/queries/{}'.format(self.query.id), headers={'Authorization': "Key oops"})
rv = c.get(self.query_url, headers={'Authorization': "Key oops"})
self.assertIsNone(api_key_load_user_from_request(request))
def test_api_key_for_wrong_org(self):
other_user = self.factory.create_admin(org=self.factory.create_org())
with app.test_client() as c:
rv = c.get(self.query_url, headers={'Authorization': "Key {}".format(other_user.api_key)})
self.assertEqual(404, rv.status_code)
class TestHMACAuthentication(BaseTestCase):
#

View File

@ -10,11 +10,11 @@ from redash.wsgi import app
class AuthenticationTestMixin(object):
def test_redirects_when_not_authenticated(self):
def test_returns_404_when_not_unauthenticated(self):
with app.test_client() as c:
for path in self.paths:
rv = c.get(path)
self.assertEquals(302, rv.status_code)
self.assertEquals(404, rv.status_code)
def test_returns_content_when_authenticated(self):
for path in self.paths:
@ -25,7 +25,7 @@ class AuthenticationTestMixin(object):
class TestAuthentication(BaseTestCase):
def test_redirects_for_nonsigned_in_user(self):
with app.test_client() as c:
rv = c.get("/")
rv = c.get("/default/")
self.assertEquals(302, rv.status_code)
@ -37,21 +37,32 @@ class PingTest(TestCase):
self.assertEquals('PONG.', rv.data)
class IndexTest(BaseTestCase, AuthenticationTestMixin):
class IndexTest(BaseTestCase):
def setUp(self):
self.paths = ['/', '/dashboard/example', '/queries/1', '/admin/status']
self.paths = ['/default/', '/default/dashboard/example', '/default/queries/1', '/default/admin/status']
super(IndexTest, self).setUp()
def test_redirect_to_login_when_not_authenticated(self):
with app.test_client() as c:
for path in self.paths:
rv = c.get(path)
self.assertEquals(302, rv.status_code)
def test_returns_content_when_authenticated(self):
for path in self.paths:
rv = self.make_request('get', path, org=False, is_json=False)
self.assertEquals(200, rv.status_code)
class StatusTest(BaseTestCase):
def test_returns_data_for_super_admin(self):
admin = self.factory.create_admin()
rv = self.make_request('get', '/status.json', user=admin, is_json=False)
rv = self.make_request('get', '/status.json', org=False, user=admin, is_json=False)
self.assertEqual(rv.status_code, 200)
def test_returns_403_for_non_admin(self):
rv = self.make_request('get', '/status.json', is_json=False)
rv = self.make_request('get', '/status.json', org=False, is_json=False)
self.assertEqual(rv.status_code, 403)
def test_redirects_non_authenticated_user(self):
@ -404,18 +415,18 @@ class TestLogin(BaseTestCase):
def test_redirects_to_google_login_if_password_disabled(self):
with app.test_client() as c, patch.object(settings, 'PASSWORD_LOGIN_ENABLED', False):
rv = c.get('/login')
rv = c.get('/default/login')
self.assertEquals(rv.status_code, 302)
self.assertTrue(rv.location.endswith(url_for('google_oauth.authorize', next='/')))
self.assertTrue(rv.location.endswith(url_for('google_oauth.authorize', next='/default/')))
def test_get_login_form(self):
with app.test_client() as c:
rv = c.get('/login')
rv = c.get('/default/login')
self.assertEquals(rv.status_code, 200)
def test_submit_non_existing_user(self):
with app.test_client() as c, patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.post('/login', data={'email': 'arik', 'password': 'password'})
rv = c.post('/default/login', data={'email': 'arik', 'password': 'password'})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
@ -425,7 +436,7 @@ class TestLogin(BaseTestCase):
user.save()
with app.test_client() as c, patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.post('/login', data={'email': user.email, 'password': 'password'})
rv = c.post('/default/login', data={'email': user.email, 'password': 'password'})
self.assertEquals(rv.status_code, 302)
login_user_mock.assert_called_with(user, remember=False)
@ -435,7 +446,7 @@ class TestLogin(BaseTestCase):
user.save()
with app.test_client() as c, patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.post('/login', data={'email': user.email, 'password': 'password', 'remember': True})
rv = c.post('/default/login', data={'email': user.email, 'password': 'password', 'remember': True})
self.assertEquals(rv.status_code, 302)
login_user_mock.assert_called_with(user, remember=True)
@ -445,7 +456,7 @@ class TestLogin(BaseTestCase):
user.save()
with app.test_client() as c, patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.post('/login?next=/test',
rv = c.post('/default/login?next=/test',
data={'email': user.email, 'password': 'password'})
self.assertEquals(rv.status_code, 302)
self.assertEquals(rv.location, 'http://localhost/test')
@ -453,7 +464,7 @@ class TestLogin(BaseTestCase):
def test_submit_incorrect_user(self):
with app.test_client() as c, patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.post('/login', data={'email': 'non-existing', 'password': 'password'})
rv = c.post('/default/login', data={'email': 'non-existing', 'password': 'password'})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
@ -463,7 +474,7 @@ class TestLogin(BaseTestCase):
user.save()
with app.test_client() as c, patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.post('/login', data={'email': user.email, 'password': 'badbadpassword'})
rv = c.post('/default/login', data={'email': user.email, 'password': 'badbadpassword'})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
@ -471,13 +482,13 @@ class TestLogin(BaseTestCase):
user = self.factory.user
with app.test_client() as c, patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.post('/login', data={'email': user.email, 'password': ''})
rv = c.post('/default/login', data={'email': user.email, 'password': ''})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
def test_user_already_loggedin(self):
with app.test_client() as c, authenticated_user(c), patch('redash.handlers.authentication.login_user') as login_user_mock:
rv = c.get('/login')
rv = c.get('/default/login')
self.assertEquals(rv.status_code, 302)
self.assertFalse(login_user_mock.called)
@ -485,27 +496,19 @@ class TestLogin(BaseTestCase):
class TestLogout(BaseTestCase):
@classmethod
def setUpClass(cls):
settings.ORG_RESOLVING = "single_org"
@classmethod
def tearDownClass(cls):
settings.ORG_RESOLVING = "multi_org"
def test_logout_when_not_loggedin(self):
with app.test_client() as c:
rv = c.get('/logout')
rv = c.get('/default/logout')
self.assertEquals(rv.status_code, 302)
self.assertFalse(current_user.is_authenticated())
self.assertFalse(current_user.is_authenticated)
def test_logout_when_loggedin(self):
with app.test_client() as c, authenticated_user(c, user=self.factory.user):
rv = c.get('/')
self.assertTrue(current_user.is_authenticated())
rv = c.get('/logout')
rv = c.get('/default/')
self.assertTrue(current_user.is_authenticated)
rv = c.get('/default/logout')
self.assertEquals(rv.status_code, 302)
self.assertFalse(current_user.is_authenticated())
self.assertFalse(current_user.is_authenticated)
class DataSourceTypesTest(BaseTestCase):

24
tests/test_utils.py Normal file
View File

@ -0,0 +1,24 @@
from redash.utils import build_url
from collections import namedtuple
from unittest import TestCase
DummyRequest = namedtuple('DummyRequest', ['host', 'scheme'])
class TestBuildUrl(TestCase):
def test_simple_case(self):
self.assertEqual("http://example.com/test", build_url(DummyRequest("", "http"), "example.com", "/test"))
def test_uses_current_request_port(self):
self.assertEqual("http://example.com:5000/test", build_url(DummyRequest("example.com:5000", "http"), "example.com", "/test"))
def test_uses_current_request_schema(self):
self.assertEqual("https://example.com/test", build_url(DummyRequest("example.com", "https"), "example.com", "/test"))
def test_skips_port_for_default_ports(self):
self.assertEqual("https://example.com/test", build_url(DummyRequest("example.com:443", "https"), "example.com", "/test"))
self.assertEqual("http://example.com/test", build_url(DummyRequest("example.com:80", "http"), "example.com", "/test"))
self.assertEqual("https://example.com:80/test", build_url(DummyRequest("example.com:80", "https"), "example.com", "/test"))
self.assertEqual("http://example.com:443/test", build_url(DummyRequest("example.com:443", "http"), "example.com", "/test"))
# CALL LIOR!!!