mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Merge pull request #1060 from thoughtworks/saml-authorization
Feature: support configuring user's groups with SAML
This commit is contained in:
34
docs/dev/saml.rst
Normal file
34
docs/dev/saml.rst
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
SAML Authentication and Authorization
|
||||||
|
#####################################
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
==============
|
||||||
|
|
||||||
|
Add to your .env file REDASH_SAML_METADATA_URL config value which
|
||||||
|
needs to point to the SAML provider metadata url, eg https://app.onelogin.com/saml/metadata/
|
||||||
|
|
||||||
|
And an optional REDASH_SAML_CALLBACK_SERVER_NAME which contains the
|
||||||
|
server name of the redash server for the callbacks from the SAML provider (eg demo.redash.io)
|
||||||
|
|
||||||
|
On the SAML provider side, example configuration for OneLogin is:
|
||||||
|
SAML Consumer URL: http://demo.redash.io/saml/login
|
||||||
|
SAML Audience: http://demo.redash.io/saml/callback
|
||||||
|
SAML Recipient: http://demo.redash.io/saml/callback
|
||||||
|
|
||||||
|
Example configuration for Okta is:
|
||||||
|
Single Sign On URL: http://demo.redash.io/saml/callback
|
||||||
|
Recipient URL: http://demo.redash.io/saml/callback
|
||||||
|
Destination URL: http://demo.redash.io/saml/callback
|
||||||
|
|
||||||
|
with parameters 'FirstName' and 'LastName', both configured to be included in the SAML assertion.
|
||||||
|
|
||||||
|
|
||||||
|
Authorization
|
||||||
|
=============
|
||||||
|
To manage group assignments in Redash using your SAML provider, configure SAML response to include
|
||||||
|
attribute with key 'RedashGroups', and value as names of groups in Redash.
|
||||||
|
|
||||||
|
Example configuration for Okta is:
|
||||||
|
In the Group Attribute Statements -
|
||||||
|
Name: RedashGroups
|
||||||
|
Filter: Starts with: this-is-a-group-in-redash
|
||||||
@@ -69,6 +69,8 @@ def create_and_login_user(org, name, email):
|
|||||||
|
|
||||||
login_user(user_object, remember=True)
|
login_user(user_object, remember=True)
|
||||||
|
|
||||||
|
return user_object
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/<org_slug>/oauth/google', endpoint="authorize_org")
|
@blueprint.route('/<org_slug>/oauth/google', endpoint="authorize_org")
|
||||||
def org_login(org_slug):
|
def org_login(org_slug):
|
||||||
|
|||||||
@@ -85,7 +85,12 @@ def idp_initiated():
|
|||||||
# This is what as known as "Just In Time (JIT) provisioning".
|
# This is what as known as "Just In Time (JIT) provisioning".
|
||||||
# What that means is that, if a user in a SAML assertion
|
# What that means is that, if a user in a SAML assertion
|
||||||
# isn't in the user store, we create that user first, then log them in
|
# isn't in the user store, we create that user first, then log them in
|
||||||
create_and_login_user(current_org, name, email)
|
user = create_and_login_user(current_org, name, email)
|
||||||
|
|
||||||
|
if 'RedashGroups' in authn_response.ava:
|
||||||
|
group_names = authn_response.ava.get('RedashGroups')
|
||||||
|
user.update_group_assignments(group_names)
|
||||||
|
|
||||||
url = url_for('redash.index')
|
url = url_for('redash.index')
|
||||||
|
|
||||||
return redirect(url)
|
return redirect(url)
|
||||||
|
|||||||
@@ -244,6 +244,11 @@ class Group(BaseModel, BelongsToOrgMixin):
|
|||||||
def members(cls, group_id):
|
def members(cls, group_id):
|
||||||
return User.select().where(peewee.SQL("%s = ANY(groups)", group_id))
|
return User.select().where(peewee.SQL("%s = ANY(groups)", group_id))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_by_name(cls, org, group_names):
|
||||||
|
result = cls.select().where(cls.org == org, cls.name << group_names)
|
||||||
|
return list(result)
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return unicode(self.id)
|
return unicode(self.id)
|
||||||
|
|
||||||
@@ -330,6 +335,12 @@ class User(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin, UserMixin, Permis
|
|||||||
def verify_password(self, password):
|
def verify_password(self, password):
|
||||||
return self.password_hash and pwd_context.verify(password, self.password_hash)
|
return self.password_hash and pwd_context.verify(password, self.password_hash)
|
||||||
|
|
||||||
|
def update_group_assignments(self, group_names):
|
||||||
|
groups = Group.find_by_name(self.org, group_names)
|
||||||
|
groups.append(self.org.default_group)
|
||||||
|
self.groups = map(lambda g: g.id, groups)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationField(peewee.TextField):
|
class ConfigurationField(peewee.TextField):
|
||||||
def db_value(self, value):
|
def db_value(self, value):
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ class QueryArchiveTest(BaseTestCase):
|
|||||||
|
|
||||||
self.assertEqual(None, query.schedule)
|
self.assertEqual(None, query.schedule)
|
||||||
|
|
||||||
|
|
||||||
class DataSourceTest(BaseTestCase):
|
class DataSourceTest(BaseTestCase):
|
||||||
def test_get_schema(self):
|
def test_get_schema(self):
|
||||||
return_value = [{'name': 'table', 'columns': []}]
|
return_value = [{'name': 'table', 'columns': []}]
|
||||||
@@ -415,6 +414,42 @@ class TestQueryAll(BaseTestCase):
|
|||||||
self.assertIn(q2, models.Query.all_queries([group1, group2]))
|
self.assertIn(q2, models.Query.all_queries([group1, group2]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestUser(BaseTestCase):
|
||||||
|
def test_default_group_always_added(self):
|
||||||
|
user = self.factory.create_user()
|
||||||
|
|
||||||
|
user.update_group_assignments(["g_unknown"])
|
||||||
|
self.assertItemsEqual([user.org.default_group.id], user.groups)
|
||||||
|
|
||||||
|
def test_update_group_assignments(self):
|
||||||
|
user = self.factory.user
|
||||||
|
new_group = models.Group.create(id='999', name="g1", org=user.org)
|
||||||
|
|
||||||
|
user.update_group_assignments(["g1"])
|
||||||
|
self.assertItemsEqual([user.org.default_group.id, new_group.id], user.groups)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroup(BaseTestCase):
|
||||||
|
def test_returns_groups_with_specified_names(self):
|
||||||
|
org1 = self.factory.create_org()
|
||||||
|
org2 = self.factory.create_org()
|
||||||
|
|
||||||
|
matching_group1 = models.Group.create(id='999', name="g1", org=org1)
|
||||||
|
matching_group2 = models.Group.create(id='888', name="g2", org=org1)
|
||||||
|
non_matching_group = models.Group.create(id='777', name="g1", org=org2)
|
||||||
|
|
||||||
|
groups = models.Group.find_by_name(org1, ["g1", "g2"])
|
||||||
|
self.assertIn(matching_group1, groups)
|
||||||
|
self.assertIn(matching_group2, groups)
|
||||||
|
self.assertNotIn(non_matching_group, groups)
|
||||||
|
|
||||||
|
def test_returns_no_groups(self):
|
||||||
|
org1 = self.factory.create_org()
|
||||||
|
|
||||||
|
models.Group.create(id='999', name="g1", org=org1)
|
||||||
|
self.assertEqual([], models.Group.find_by_name(org1, ["non-existing"]))
|
||||||
|
|
||||||
|
|
||||||
class TestQueryResultStoreResult(BaseTestCase):
|
class TestQueryResultStoreResult(BaseTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(TestQueryResultStoreResult, self).setUp()
|
super(TestQueryResultStoreResult, self).setUp()
|
||||||
|
|||||||
Reference in New Issue
Block a user