Skip to content

Commit

Permalink
Merge pull request #59 from dimagi/cs/SC-3473-user-roles-from-hq
Browse files Browse the repository at this point in the history
Assign HQ-defined roles to CCA user
  • Loading branch information
Charl1996 authored Nov 6, 2024
2 parents 166dc9d + 4196ab5 commit fad5991
Show file tree
Hide file tree
Showing 10 changed files with 327 additions and 47 deletions.
46 changes: 46 additions & 0 deletions hq_superset/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,49 @@
HQ_DATABASE_NAME = "HQ Data"

OAUTH2_DATABASE_NAME = "oauth2-server-data"

HQ_USER_ROLE_NAME = "hq_user"
GAMMA_ROLE_NAME = "Gamma"
READ_ONLY_ROLE_NAME = "hq_user_read_only"

# Permissions
SCHEMA_ACCESS_PERMISSION = "schema_access"
MENU_ACCESS_PERMISSION = "menu_access"

CAN_SHOW_PERMISSION = "can_show"
CAN_LIST_PERMISSION = "can_list"
CAN_GET_PERMISSION = "can_get"
CAN_EXTERNAL_METADATA_PERMISSION = "can_external_metadata"
CAN_EXTERNAL_METADATA_BY_NAME_PERMISSION = "can_external_metadata_by_name"
CAN_READ_PERMISSION = "can_read"

READ_ONLY_PERMISSIONS = [
CAN_SHOW_PERMISSION,
CAN_LIST_PERMISSION,
CAN_GET_PERMISSION,
CAN_EXTERNAL_METADATA_PERMISSION,
CAN_EXTERNAL_METADATA_BY_NAME_PERMISSION,
CAN_READ_PERMISSION,
]

CAN_WRITE_PERMISSION = "can_write"
CAN_EDIT_PERMISSION = "can_edit"
CAN_ADD_PERMISSION = "can_add"
CAN_DELETE_PERMISSIONS = "can_delete"


READ_ONLY_MENU_PERMISSIONS = {
"Chart": READ_ONLY_PERMISSIONS,
"Dataset": READ_ONLY_PERMISSIONS,
"Dashboard": READ_ONLY_PERMISSIONS,
"Datasource": READ_ONLY_PERMISSIONS,
"OpenApi": READ_ONLY_PERMISSIONS,
"Explore": READ_ONLY_PERMISSIONS,
"Select a Domain": [MENU_ACCESS_PERMISSION],
"Home": [MENU_ACCESS_PERMISSION],
"Data": [MENU_ACCESS_PERMISSION],
"Dashboards": [MENU_ACCESS_PERMISSION],
"Charts": [MENU_ACCESS_PERMISSION],
"Datasets": [MENU_ACCESS_PERMISSION],
"ExploreFormDataRestApi": [CAN_READ_PERMISSION]
}
4 changes: 4 additions & 0 deletions hq_superset/hq_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ def datasource_unsubscribe(domain, datasource_id):
f"a/{domain}/configurable_reports/data_sources/unsubscribe/"
f"{datasource_id}/"
)


def user_domain_roles(domain):
return f"a/{domain}/api/analytics-roles/v1"
9 changes: 9 additions & 0 deletions hq_superset/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ def set_oauth_session(self, provider, oauth_response):
# }
session[SESSION_OAUTH_RESPONSE_KEY] = oauth_response

def set_role_permissions(self, role, permissions):
"""
This method sets the permissions on a role by overwriting the existing
permissions
"""
role.permissions = []
for permission in permissions:
self.add_permission_role(role, permission)


def get_valid_cchq_oauth_token():
# Returns a valid working oauth access_token and also saves it on session
Expand Down
5 changes: 0 additions & 5 deletions hq_superset/tests/config_for_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@
'0fXurIGyQM4HQYoe7feuwV8c1Kz_88BdmCNutLKiO38=', # Don't reuse this!
]

# Any other additional roles to be assigned to the user on top of the base role
# Note: by design we cannot use AUTH_USER_REGISTRATION_ROLE to
# specify more than one role
AUTH_USER_ADDITIONAL_ROLES = ["sql_lab"]

HQ_DATABASE_URI = "postgresql://commcarehq:commcarehq@localhost:5432/test_superset_hq"

AUTH_TYPE = AUTH_OAUTH
Expand Down
25 changes: 25 additions & 0 deletions hq_superset/tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def test_oauth_user_info(self):
oauth_mock.domain_json["objects"]
)


class TestGetOAuthTokenGetter(SupersetTestCase):

def tearDown(self):
Expand Down Expand Up @@ -110,3 +111,27 @@ def test_tries_token_refresh_if_expired(self):
set_mock.assert_called_once_with(
"commcare", {"access_token": "new key"}
)


class TestSetRolePermissions(SupersetTestCase):

def tearDown(self):
session.clear()

def test_set_role_permissions(self):
appbuilder = self.app.appbuilder
role_name = "test_role"

role = appbuilder.sm.add_role(role_name)
permissions = [
appbuilder.sm.add_permission_view_menu("can_edit", "Chart"),
appbuilder.sm.add_permission_view_menu("can_edit", "Dashboard"),
]

appbuilder.sm.set_role_permissions(role, permissions)
role = appbuilder.sm.find_role(role_name)
assert role.permissions == permissions

appbuilder.sm.set_role_permissions(role, [])
role = appbuilder.sm.find_role(role_name)
assert role.permissions == []
84 changes: 82 additions & 2 deletions hq_superset/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import doctest
from unittest.mock import patch

from hq_superset.utils import get_column_dtypes

from hq_superset.utils import get_column_dtypes, DomainSyncUtil
from .base_test import SupersetTestCase
from .const import TEST_DATASOURCE
from hq_superset.const import READ_ONLY_ROLE_NAME


def test_get_column_dtypes():
Expand All @@ -24,3 +26,81 @@ def test_doctests():
import hq_superset.utils
results = doctest.testmod(hq_superset.utils)
assert results.failed == 0


class TestDomainSyncUtil(SupersetTestCase):
PLATFORM_ROLE_NAMES = ["Gamma", "sql_lab", "dataset_editor"]

@patch.object(DomainSyncUtil, "_get_domain_access")
def test_gamma_role_assigned_for_edit_permissions(self, get_domain_access_mock):
security_manager = self.app.appbuilder.sm
self._ensure_platform_roles_exist(security_manager)

get_domain_access_mock.return_value = self._to_permissions_response(
can_write=True,
can_read=True,
roles=[],
)
additional_roles = DomainSyncUtil(security_manager)._get_additional_user_roles("test-domain")
assert len(additional_roles) == 1
assert additional_roles[0].name == "Gamma"

@patch.object(DomainSyncUtil, "_get_domain_access")
def test_no_roles_assigned_without_at_least_read_permission(self, get_domain_access_mock):
security_manager = self.app.appbuilder.sm
self._ensure_platform_roles_exist(security_manager)

get_domain_access_mock.return_value = self._to_permissions_response(
can_write=False,
can_read=False,
roles=["sql_lab", "dataset_editor"],
)
additional_roles = DomainSyncUtil(security_manager)._get_additional_user_roles("test-domain")
assert not additional_roles

@patch.object(DomainSyncUtil, "_get_domain_access")
def test_read_permission_gives_read_only_role(self, get_domain_access_mock):
security_manager = self.app.appbuilder.sm
self._ensure_platform_roles_exist(security_manager)

get_domain_access_mock.return_value = self._to_permissions_response(
can_write=False,
can_read=True,
roles=[],
)
additional_roles = DomainSyncUtil(security_manager)._get_additional_user_roles("test-domain")
assert len(additional_roles) == 1
assert additional_roles[0].name == READ_ONLY_ROLE_NAME

@patch.object(DomainSyncUtil, "_get_domain_access")
def test_permissions_change_updates_user_role(self, get_domain_access_mock):
security_manager = self.app.appbuilder.sm
self._ensure_platform_roles_exist(security_manager)

get_domain_access_mock.return_value = self._to_permissions_response(
can_write=False,
can_read=True,
roles=[],
)
additional_roles = DomainSyncUtil(security_manager)._get_additional_user_roles("test-domain")
assert additional_roles[0].name == READ_ONLY_ROLE_NAME

# user has no access
get_domain_access_mock.return_value = self._to_permissions_response(
can_write=False,
can_read=False,
roles=[],
)
additional_roles = DomainSyncUtil(security_manager)._get_additional_user_roles("test-domain")
assert not additional_roles

def _ensure_platform_roles_exist(self, sm):
for role_name in self.PLATFORM_ROLE_NAMES:
sm.add_role(role_name)

@staticmethod
def _to_permissions_response(can_write, can_read, roles):
return {
"can_write": can_write,
"can_read": can_read,
}, roles
15 changes: 13 additions & 2 deletions hq_superset/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from hq_superset.utils import (
SESSION_USER_DOMAINS_KEY,
get_schema_name_for_domain,
DomainSyncUtil,
)

from .base_test import HQDBTestCase
Expand Down Expand Up @@ -80,6 +81,10 @@ def __init__(self):
]
}
self.api_base_url = "https://cchq.org/"
self.user_domain_roles = {
"permissions": {"can_view": True, "can_edit": True},
"roles": ["Gamma", "sql_lab"],
}

def authorize_access_token(self):
return {"access_token": "some-key"}
Expand All @@ -92,6 +97,8 @@ def get(self, url, token):
'a/test2/api/v0.5/ucr_data_source/': MockResponse(self.test2_datasources, 200),
'a/test1/api/v0.5/ucr_data_source/test1_ucr1/': MockResponse(TEST_DATASOURCE, 200),
'a/test1/configurable_reports/data_sources/export/test1_ucr1/?format=csv': MockResponse(TEST_UCR_CSV_V1, 200),
'a/test1/api/analytics-roles/v1': MockResponse(self.user_domain_roles, 200),
'a/test2/api/analytics-roles/v1': MockResponse(self.user_domain_roles, 200),
}[url]


Expand Down Expand Up @@ -171,7 +178,8 @@ def test_redirects_to_domain_select_after_login(self):
)
self.logout(client)

def test_domain_select_works(self):
@patch.object(DomainSyncUtil, "_get_domain_access", return_value=({"can_write": True, "can_read": True}, []))
def test_domain_select_works(self, *args):
client = self.app.test_client()
self.login(client)

Expand Down Expand Up @@ -201,6 +209,7 @@ def test_non_user_domain_cant_be_selected(self):
self.logout(client)

@patch('hq_superset.hq_requests.get_valid_cchq_oauth_token', return_value={})
@patch.object(DomainSyncUtil, "sync_domain_role", return_value=True)
def test_datasource_list(self, *args):
def _do_assert(datasources):
self.assert_template_used("hq_datasource_list.html")
Expand All @@ -218,6 +227,7 @@ def _do_assert(datasources):
client.get('/hq_datasource/list/', follow_redirects=True)
_do_assert(self.oauth_mock.test2_datasources)

@patch.object(DomainSyncUtil, "sync_domain_role", return_value=True)
def test_datasource_upload(self, *args):
client = self.app.test_client()
self.login(client)
Expand All @@ -232,7 +242,8 @@ def test_datasource_upload(self, *args):
'ds1'
)

def test_trigger_datasource_refresh_with_api_exception(self):
@patch.object(DomainSyncUtil, "sync_domain_role", return_value=True)
def test_trigger_datasource_refresh_with_api_exception(self, *args):
with patch("hq_superset.views.download_and_subscribe_to_datasource", side_effect=HQAPIException('mocked error')):
client = self.app.test_client()
self.login(client)
Expand Down
Loading

0 comments on commit fad5991

Please sign in to comment.