diff --git a/docs/dev/saml.rst b/docs/dev/saml.rst new file mode 100644 index 000000000..5fff029b4 --- /dev/null +++ b/docs/dev/saml.rst @@ -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 \ No newline at end of file diff --git a/redash/authentication/google_oauth.py b/redash/authentication/google_oauth.py index 1e21fd1df..0dba2ea05 100644 --- a/redash/authentication/google_oauth.py +++ b/redash/authentication/google_oauth.py @@ -69,6 +69,8 @@ def create_and_login_user(org, name, email): login_user(user_object, remember=True) + return user_object + @blueprint.route('//oauth/google', endpoint="authorize_org") def org_login(org_slug): diff --git a/redash/authentication/saml_auth.py b/redash/authentication/saml_auth.py index 918c4b3f6..80fa5d2f9 100644 --- a/redash/authentication/saml_auth.py +++ b/redash/authentication/saml_auth.py @@ -85,7 +85,12 @@ def idp_initiated(): # This is what as known as "Just In Time (JIT) provisioning". # 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 - 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') return redirect(url) diff --git a/redash/models.py b/redash/models.py index 3d30788ce..8eb346521 100644 --- a/redash/models.py +++ b/redash/models.py @@ -244,6 +244,11 @@ class Group(BaseModel, BelongsToOrgMixin): def members(cls, 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): return unicode(self.id) @@ -330,6 +335,12 @@ class User(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin, UserMixin, Permis def verify_password(self, password): 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): def db_value(self, value): diff --git a/tests/test_models.py b/tests/test_models.py index e4190c1d9..12c5d5ae2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -276,7 +276,6 @@ class QueryArchiveTest(BaseTestCase): self.assertEqual(None, query.schedule) - class DataSourceTest(BaseTestCase): def test_get_schema(self): return_value = [{'name': 'table', 'columns': []}] @@ -415,6 +414,42 @@ class TestQueryAll(BaseTestCase): 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): def setUp(self): super(TestQueryResultStoreResult, self).setUp()