Feature: new permissions system

This is one huge change for the permissions system and related:

* (Backward incompatible:) Remove the table based permissions in favour of the new model.
* Manage permission to view or query datasources based on groups.
* Add the concept of Organization. It's irrelevant for most deployments, but allows for
  multi-tenant support in re:dash.
* Replace ActivityLog with Event based rows (old data in activity_log table is retained).
* Enforce permissions on the server-side. There were some permissions that were only enforced
  on the client side. This is no more. All permissions are enforced by the server.
* Added new permission: 'super-admin' to access the status and Flask-Admin interface.
* Make sure that html is never cached by the browser - this is to make sure that the browser
  will always ask for the new Javascript/CSS resources (if such are available).
This commit is contained in:
Arik Fraimovich
2015-12-01 15:44:08 +02:00
parent 6e32f5b9f2
commit f7b57fa580
93 changed files with 2430 additions and 1458 deletions

View File

@@ -5,16 +5,11 @@ from flask.ext.login import current_user
from mock import patch
from tests import BaseTestCase
from tests.handlers import authenticated_user, json_request
from tests.factories import dashboard_factory, widget_factory, visualization_factory, query_factory, \
query_result_factory, user_factory, data_source_factory
from redash import models, settings
from redash.wsgi import app
settings.GOOGLE_APPS_DOMAIN = "example.com"
class AuthenticationTestMixin():
class AuthenticationTestMixin(object):
def test_redirects_when_not_authenticated(self):
with app.test_client() as c:
for path in self.paths:
@@ -22,10 +17,9 @@ class AuthenticationTestMixin():
self.assertEquals(302, rv.status_code)
def test_returns_content_when_authenticated(self):
with app.test_client() as c, authenticated_user(c):
for path in self.paths:
rv = c.get(path)
self.assertEquals(200, rv.status_code)
for path in self.paths:
rv = self.make_request('get', path, is_json=False)
self.assertEquals(200, rv.status_code)
class TestAuthentication(BaseTestCase):
@@ -50,16 +44,15 @@ class IndexTest(BaseTestCase, AuthenticationTestMixin):
class StatusTest(BaseTestCase):
def test_returns_data_for_admin(self):
admin = user_factory.create(groups=['admin', 'default'])
with app.test_client() as c, authenticated_user(c, user=admin):
rv = c.get('/status.json')
self.assertEqual(rv.status_code, 200)
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)
self.assertEqual(rv.status_code, 200)
def test_returns_403_for_non_admin(self):
with app.test_client() as c, authenticated_user(c):
rv = c.get('/status.json')
self.assertEqual(rv.status_code, 403)
rv = self.make_request('get', '/status.json', is_json=False)
self.assertEqual(rv.status_code, 403)
def test_redirects_non_authenticated_user(self):
with app.test_client() as c:
@@ -73,48 +66,60 @@ class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
super(DashboardAPITest, self).setUp()
def test_get_dashboard(self):
d1 = dashboard_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = c.get('/api/dashboards/{0}'.format(d1.slug))
self.assertEquals(rv.status_code, 200)
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)
expected = d1.to_dict(with_widgets=True)
actual = json.loads(rv.data)
self.assertResponseEqual(expected, actual)
self.assertResponseEqual(expected, actual)
def test_get_non_existint_dashbaord(self):
with app.test_client() as c, authenticated_user(c):
rv = c.get('/api/dashboards/not_existing')
self.assertEquals(rv.status_code, 404)
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):
user = user_factory.create()
with app.test_client() as c, authenticated_user(c, user=user):
dashboard_name = 'Test Dashboard'
rv = json_request(c.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'], user.id)
self.assertEquals(rv.json['layout'], [])
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 = dashboard_factory.create()
d = self.factory.create_dashboard()
new_name = 'New Name'
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.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)
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 = dashboard_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.delete, '/api/dashboards/{0}'.format(d.slug))
self.assertEquals(rv.status_code, 200)
d = self.factory.create_dashboard()
d = models.Dashboard.get_by_slug(d.slug)
self.assertTrue(d.is_archived)
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 WidgetAPITest(BaseTestCase):
@@ -126,14 +131,13 @@ class WidgetAPITest(BaseTestCase):
'width': width
}
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/widgets', data=data)
rv = self.make_request('post', '/api/widgets', data=data)
return rv
def test_create_widget(self):
dashboard = dashboard_factory.create()
vis = visualization_factory.create()
dashboard = self.factory.create_dashboard()
vis = self.factory.create_visualization()
rv = self.create_widget(dashboard, vis)
self.assertEquals(rv.status_code, 200)
@@ -161,7 +165,7 @@ class WidgetAPITest(BaseTestCase):
self.assertEquals(rv4.json['new_row'], True)
def test_create_text_widget(self):
dashboard = dashboard_factory.create()
dashboard = self.factory.create_dashboard()
data = {
'visualization_id': None,
@@ -171,24 +175,22 @@ class WidgetAPITest(BaseTestCase):
'width': 2
}
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/widgets', data=data)
rv = self.make_request('post', '/api/widgets', data=data)
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['widget']['text'], 'Sample text.')
def test_delete_widget(self):
widget = widget_factory.create()
widget = self.factory.create_widget()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.delete, '/api/widgets/{0}'.format(widget.id))
rv = self.make_request('delete', '/api/widgets/{0}'.format(widget.id))
self.assertEquals(rv.status_code, 200)
dashboard = models.Dashboard.get_by_slug(widget.dashboard.slug)
self.assertEquals(dashboard.widgets.count(), 0)
self.assertEquals(dashboard.layout, '[]')
self.assertEquals(rv.status_code, 200)
dashboard = models.Dashboard.get_by_slug_and_org(widget.dashboard.slug, widget.dashboard.org)
self.assertEquals(dashboard.widgets.count(), 0)
self.assertEquals(dashboard.layout, '[]')
# TODO: test how it updates the layout
# TODO: test how it updates the layout
class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
@@ -197,60 +199,78 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
super(QueryAPITest, self).setUp()
def test_update_query(self):
query = query_factory.create()
admin = self.factory.create_admin()
query = self.factory.create_query()
other_user = user_factory.create()
with app.test_client() as c, authenticated_user(c, user=other_user):
rv = json_request(c.post, '/api/queries/{0}'.format(query.id), data={'name': 'Testing'})
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.json['name'], 'Testing')
self.assertEqual(rv.json['last_modified_by']['id'], other_user.id)
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):
user = user_factory.create()
data_source = data_source_factory.create()
query_data = {
'name': 'Testing',
'query': 'SELECT 1',
'schedule': "3600",
'data_source_id': data_source.id
'data_source_id': self.factory.data_source.id
}
with app.test_client() as c, authenticated_user(c, user=user):
rv = json_request(c.post, '/api/queries', data=query_data)
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'], user.id)
self.assertIsNotNone(rv.json['api_key'])
self.assertIsNotNone(rv.json['query_hash'])
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)
query = models.Query.get_by_id(rv.json['id'])
self.assertEquals(len(list(query.visualizations)), 1)
def test_get_query(self):
query = query_factory.create()
query = self.factory.create_query()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.get, '/api/queries/{0}'.format(query.id))
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))
self.assertEquals(rv.status_code, 200)
self.assertResponseEqual(rv.json, query.to_dict(with_visualizations=True))
def test_get_all_queries(self):
queries = [query_factory.create() for _ in range(10)]
queries = [self.factory.create_query() for _ in range(10)]
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.get, '/api/queries')
rv = self.make_request('get', '/api/queries')
self.assertEquals(rv.status_code, 200)
self.assertEquals(len(rv.json), 10)
self.assertEquals(rv.status_code, 200)
self.assertEquals(len(rv.json), 10)
def test_query_without_data_source_should_be_available_only_by_admin(self):
query = self.factory.create_query()
query.data_source = None
query.save()
rv = self.make_request('get', '/api/queries/{}'.format(query.id))
self.assertEquals(rv.status_code, 403)
rv = self.make_request('get', '/api/queries/{}'.format(query.id), user=self.factory.create_admin())
self.assertEquals(rv.status_code, 200)
def test_query_only_accessible_to_users_from_its_organization(self):
second_org = self.factory.create_org()
second_org_admin = self.factory.create_admin(org=second_org)
query = self.factory.create_query()
query.data_source = None
query.save()
rv = self.make_request('get', '/api/queries/{}'.format(query.id), user=second_org_admin)
self.assertEquals(rv.status_code, 404)
rv = self.make_request('get', '/api/queries/{}'.format(query.id), user=self.factory.create_admin())
self.assertEquals(rv.status_code, 200)
class VisualizationAPITest(BaseTestCase):
class VisualizationResourceTest(BaseTestCase):
def test_create_visualization(self):
query = query_factory.create()
query = self.factory.create_query()
data = {
'query_id': query.id,
'name': 'Chart',
@@ -259,31 +279,93 @@ class VisualizationAPITest(BaseTestCase):
'type': 'CHART'
}
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/visualizations', data=data)
rv = self.make_request('post', '/api/visualizations', data=data)
self.assertEquals(rv.status_code, 200)
data.pop('query_id')
self.assertDictContainsSubset(data, rv.json)
self.assertEquals(rv.status_code, 200)
data.pop('query_id')
self.assertDictContainsSubset(data, rv.json)
def test_delete_visualization(self):
visualization = visualization_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.delete, '/api/visualizations/{0}'.format(visualization.id))
visualization = self.factory.create_visualization()
self.assertEquals(rv.status_code, 200)
# =1 because each query has a default table visualization.
self.assertEquals(models.Visualization.select().count(), 1)
rv = self.make_request('delete', '/api/visualizations/{}'.format(visualization.id))
self.assertEquals(rv.status_code, 200)
# =1 because each query has a default table visualization.
self.assertEquals(models.Visualization.select().count(), 1)
def test_update_visualization(self):
visualization = visualization_factory.create()
visualization = self.factory.create_visualization()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/visualizations/{0}'.format(visualization.id),
data={'name': 'After Update'})
rv = self.make_request('post', '/api/visualizations/{0}'.format(visualization.id), data={'name': 'After Update'})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'After Update')
def test_only_owner_or_admin_can_create_visualization(self):
query = self.factory.create_query()
data = {
'query_id': query.id,
'name': 'Chart',
'description':'',
'options': {},
'type': 'CHART'
}
other_user = self.factory.create_user()
admin = self.factory.create_admin()
admin_from_diff_org = self.factory.create_admin(org=self.factory.create_org())
rv = self.make_request('post', '/api/visualizations', data=data, user=admin)
self.assertEquals(rv.status_code, 200)
rv = self.make_request('post', '/api/visualizations', data=data, user=other_user)
self.assertEquals(rv.status_code, 403)
rv = self.make_request('post', '/api/visualizations', data=data, user=admin_from_diff_org)
self.assertEquals(rv.status_code, 404)
def test_only_owner_or_admin_can_edit_visualization(self):
vis = self.factory.create_visualization()
path = '/api/visualizations/{}'.format(vis.id)
data={'name': 'After Update'}
other_user = self.factory.create_user()
admin = self.factory.create_admin()
admin_from_diff_org = self.factory.create_admin(org=self.factory.create_org())
rv = self.make_request('post', path, user=admin, data=data)
self.assertEquals(rv.status_code, 200)
rv = self.make_request('post', path, user=other_user, data=data)
self.assertEquals(rv.status_code, 403)
rv = self.make_request('post', path, user=admin_from_diff_org, data=data)
self.assertEquals(rv.status_code, 404)
def test_only_owner_or_admin_can_delete_visualization(self):
vis = self.factory.create_visualization()
path = '/api/visualizations/{}'.format(vis.id)
other_user = self.factory.create_user()
admin = self.factory.create_admin()
admin_from_diff_org = self.factory.create_admin(org=self.factory.create_org())
rv = self.make_request('delete', path, user=admin)
self.assertEquals(rv.status_code, 200)
vis = self.factory.create_visualization()
path = '/api/visualizations/{}'.format(vis.id)
rv = self.make_request('delete', path, user=other_user)
self.assertEquals(rv.status_code, 403)
vis = self.factory.create_visualization()
path = '/api/visualizations/{}'.format(vis.id)
rv = self.make_request('delete', path, user=admin_from_diff_org)
self.assertEquals(rv.status_code, 404)
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'After Update')
class QueryResultAPITest(BaseTestCase, AuthenticationTestMixin):
@@ -292,15 +374,13 @@ class QueryResultAPITest(BaseTestCase, AuthenticationTestMixin):
super(QueryResultAPITest, self).setUp()
def test_post_result_list(self):
data_source = data_source_factory.create()
query_result = query_result_factory.create()
query = query_factory.create()
query_result = self.factory.create_query_result()
query = self.factory.create_query()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/query_results',
data={'data_source_id': data_source.id,
'query': query.query})
self.assertEquals(rv.status_code, 200)
rv = self.make_request('post', '/api/query_results',
data={'data_source_id': self.factory.data_source.id,
'query': query.query})
self.assertEquals(rv.status_code, 200)
class JobAPITest(BaseTestCase, AuthenticationTestMixin):
@@ -314,6 +394,14 @@ class TestLogin(BaseTestCase):
settings.PASSWORD_LOGIN_ENABLED = True
super(TestLogin, self).setUp()
@classmethod
def setUpClass(cls):
settings.ORG_RESOLVING = "single_org"
@classmethod
def tearDownClass(cls):
settings.ORG_RESOLVING = "multi_org"
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')
@@ -332,8 +420,7 @@ class TestLogin(BaseTestCase):
self.assertFalse(login_user_mock.called)
def test_submit_correct_user_and_password(self):
user = user_factory.create()
user = self.factory.user
user.hash_password('password')
user.save()
@@ -343,7 +430,7 @@ class TestLogin(BaseTestCase):
login_user_mock.assert_called_with(user, remember=False)
def test_submit_correct_user_and_password_and_remember_me(self):
user = user_factory.create()
user = self.factory.user
user.hash_password('password')
user.save()
@@ -353,7 +440,7 @@ class TestLogin(BaseTestCase):
login_user_mock.assert_called_with(user, remember=True)
def test_submit_correct_user_and_password_with_next(self):
user = user_factory.create()
user = self.factory.user
user.hash_password('password')
user.save()
@@ -371,7 +458,7 @@ class TestLogin(BaseTestCase):
self.assertFalse(login_user_mock.called)
def test_submit_incorrect_password(self):
user = user_factory.create()
user = self.factory.user
user.hash_password('password')
user.save()
@@ -381,7 +468,7 @@ class TestLogin(BaseTestCase):
self.assertFalse(login_user_mock.called)
def test_submit_incorrect_password(self):
user = user_factory.create()
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': ''})
@@ -398,6 +485,14 @@ 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')
@@ -405,7 +500,7 @@ class TestLogout(BaseTestCase):
self.assertFalse(current_user.is_authenticated())
def test_logout_when_loggedin(self):
with app.test_client() as c, authenticated_user(c):
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')
@@ -415,40 +510,35 @@ class TestLogout(BaseTestCase):
class DataSourceTypesTest(BaseTestCase):
def test_returns_data_for_admin(self):
admin = user_factory.create(groups=['admin', 'default'])
with app.test_client() as c, authenticated_user(c, user=admin):
rv = c.get("/api/data_sources/types")
self.assertEqual(rv.status_code, 200)
admin = self.factory.create_admin()
rv = self.make_request('get', "/api/data_sources/types", user=admin)
self.assertEqual(rv.status_code, 200)
def test_returns_403_for_non_admin(self):
with app.test_client() as c, authenticated_user(c):
rv = c.get("/api/data_sources/types")
self.assertEqual(rv.status_code, 403)
rv = self.make_request('get', "/api/data_sources/types")
self.assertEqual(rv.status_code, 403)
class DataSourceTest(BaseTestCase):
def test_returns_400_when_missing_fields(self):
admin = user_factory.create(groups=['admin', 'default'])
with app.test_client() as c, authenticated_user(c, user=admin):
rv = c.post("/api/data_sources")
self.assertEqual(rv.status_code, 400)
admin = self.factory.create_admin()
rv = self.make_request('post', "/api/data_sources", user=admin)
self.assertEqual(rv.status_code, 400)
rv = json_request(c.post, '/api/data_sources', data={'name': 'DS 1'})
rv = self.make_request('post', '/api/data_sources', data={'name': 'DS 1'}, user=admin)
self.assertEqual(rv.status_code, 400)
self.assertEqual(rv.status_code, 400)
def test_returns_400_when_configuration_invalid(self):
admin = user_factory.create(groups=['admin', 'default'])
with app.test_client() as c, authenticated_user(c, user=admin):
rv = json_request(c.post, '/api/data_sources',
data={'name': 'DS 1', 'type': 'pg', 'options': '{}'})
admin = self.factory.create_admin()
rv = self.make_request('post', '/api/data_sources',
data={'name': 'DS 1', 'type': 'pg', 'options': '{}'}, user=admin)
self.assertEqual(rv.status_code, 400)
self.assertEqual(rv.status_code, 400)
def test_creates_data_source(self):
admin = user_factory.create(groups=['admin', 'default'])
with app.test_client() as c, authenticated_user(c, user=admin):
rv = json_request(c.post, '/api/data_sources',
data={'name': 'DS 1', 'type': 'pg', 'options': {"dbname": "redash"}})
admin = self.factory.create_admin()
rv = self.make_request('post', '/api/data_sources',
data={'name': 'DS 1', 'type': 'pg', 'options': {"dbname": "redash"}}, user=admin)
self.assertEqual(rv.status_code, 200)
self.assertEqual(rv.status_code, 200)