Unverified Commit d202fca2 authored by Jin Hyuk Chang's avatar Jin Hyuk Chang Committed by GitHub

feat: Mode dashboard preview ACL (#481)

* Mode dashboard preview ACL

* Update

* Update

* Update
parent d3dd9617
...@@ -47,6 +47,9 @@ def get_preview_image(uri: str) -> Response: ...@@ -47,6 +47,9 @@ def get_preview_image(uri: str) -> Response:
except FileNotFoundError as fne: except FileNotFoundError as fne:
LOGGER.exception('FileNotFoundError on get_preview_image') LOGGER.exception('FileNotFoundError on get_preview_image')
return make_response(jsonify({'msg': fne.args[0]}), HTTPStatus.NOT_FOUND) return make_response(jsonify({'msg': fne.args[0]}), HTTPStatus.NOT_FOUND)
except PermissionError as pe:
LOGGER.exception('PermissionError on get_preview_image')
return make_response(jsonify({'msg': pe.args[0]}), HTTPStatus.UNAUTHORIZED)
except Exception as e: except Exception as e:
LOGGER.exception('Unexpected failure on get_preview_image') LOGGER.exception('Unexpected failure on get_preview_image')
return make_response(jsonify({'msg': 'Encountered exception: ' + str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR) return make_response(jsonify({'msg': 'Encountered exception: ' + str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR)
...@@ -81,6 +81,9 @@ class Config: ...@@ -81,6 +81,9 @@ class Config:
CREDENTIALS_MODE_ADMIN_PASSWORD = os.getenv('CREDENTIALS_MODE_ADMIN_PASSWORD', None) CREDENTIALS_MODE_ADMIN_PASSWORD = os.getenv('CREDENTIALS_MODE_ADMIN_PASSWORD', None)
MODE_ORGANIZATION = None MODE_ORGANIZATION = None
MODE_REPORT_URL_TEMPLATE = None MODE_REPORT_URL_TEMPLATE = None
# Add Preview class name below to enable ACL, assuming it is supported by the Preview class
# e.g: ACL_ENABLED_DASHBOARD_PREVIEW = {'ModePreview'}
ACL_ENABLED_DASHBOARD_PREVIEW = set() # type: Set[Optional[str]]
class LocalConfig(Config): class LocalConfig(Config):
......
...@@ -6,7 +6,10 @@ from flask import has_app_context, current_app as app ...@@ -6,7 +6,10 @@ from flask import has_app_context, current_app as app
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from retrying import retry from retrying import retry
from amundsen_application.api.metadata.v0 import USER_ENDPOINT
from amundsen_application.api.utils.request_utils import request_metadata
from amundsen_application.base.base_preview import BasePreview from amundsen_application.base.base_preview import BasePreview
from amundsen_application.models.user import load_user
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
DEFAULT_REPORT_URL_TEMPLATE = 'https://app.mode.com/api/{organization}/reports/{dashboard_id}' DEFAULT_REPORT_URL_TEMPLATE = 'https://app.mode.com/api/{organization}/reports/{dashboard_id}'
...@@ -18,6 +21,10 @@ def _validate_not_none(var: Any, var_name: str) -> Any: ...@@ -18,6 +21,10 @@ def _validate_not_none(var: Any, var_name: str) -> Any:
return var return var
def _retry_on_retriable_error(exception: Exception) -> bool:
return not isinstance(exception, PermissionError)
class ModePreview(BasePreview): class ModePreview(BasePreview):
""" """
A class to get Mode Dashboard preview image A class to get Mode Dashboard preview image
...@@ -40,13 +47,26 @@ class ModePreview(BasePreview): ...@@ -40,13 +47,26 @@ class ModePreview(BasePreview):
if has_app_context() and app.config['MODE_REPORT_URL_TEMPLATE'] is not None: if has_app_context() and app.config['MODE_REPORT_URL_TEMPLATE'] is not None:
self._report_url_template = app.config['MODE_REPORT_URL_TEMPLATE'] self._report_url_template = app.config['MODE_REPORT_URL_TEMPLATE']
@retry(stop_max_attempt_number=3, wait_random_min=500, wait_random_max=1000) self._is_auth_enabled = False
if has_app_context() and app.config['ACL_ENABLED_DASHBOARD_PREVIEW']:
if not app.config['AUTH_USER_METHOD']:
raise Exception('AUTH_USER_METHOD must be configured to enable ACL_ENABLED_DASHBOARD_PREVIEW')
self._is_auth_enabled = self.__class__.__name__ in app.config['ACL_ENABLED_DASHBOARD_PREVIEW']
self._auth_user_method = app.config['AUTH_USER_METHOD']
@retry(stop_max_attempt_number=3, wait_random_min=500, wait_random_max=1000,
retry_on_exception=_retry_on_retriable_error)
def get_preview_image(self, *, uri: str) -> bytes: def get_preview_image(self, *, uri: str) -> bytes:
""" """
Retrieves short lived URL that provides Mode report preview, downloads it and returns it's bytes Retrieves short lived URL that provides Mode report preview, downloads it and returns it's bytes
:param uri: :param uri:
:return: image bytes :return: image bytes
:raise: PermissionError when user is not allowed to access the dashboard
""" """
if self._is_auth_enabled:
self._authorize_access(user_email=self._auth_user_method(app).email)
url = self._get_preview_image_url(uri=uri) url = self._get_preview_image_url(uri=uri)
r = requests.get(url, allow_redirects=True) r = requests.get(url, allow_redirects=True)
r.raise_for_status() r.raise_for_status()
...@@ -74,3 +94,24 @@ class ModePreview(BasePreview): ...@@ -74,3 +94,24 @@ class ModePreview(BasePreview):
raise FileNotFoundError('No preview image available on {}'.format(uri)) raise FileNotFoundError('No preview image available on {}'.format(uri))
return image_url return image_url
def _authorize_access(self, user_email: str) -> None:
"""
Get Mode user ID via metadata service. Note that metadata service needs to be at least v2.5.2 and
Databuilder should also have ingested Mode user.
https://github.com/lyft/amundsendatabuilder#modedashboarduserextractor
:param user_email:
:return:
:raise: PermissionError when user is not allowed to access the dashboard
"""
metadata_svc_url = '{0}{1}/{2}'.format(app.config['METADATASERVICE_BASE'], USER_ENDPOINT, user_email)
response = request_metadata(url=metadata_svc_url)
response.raise_for_status()
user = load_user(response.json())
if user.is_active and user.other_key_values and user.other_key_values.get('mode_user_id'):
return
raise PermissionError('User {} is not authorized to preview Mode Dashboard'.format(user_email))
...@@ -70,7 +70,7 @@ responses==0.9.0 ...@@ -70,7 +70,7 @@ responses==0.9.0
# A common package that holds the models deifnition and schemas that are used # A common package that holds the models deifnition and schemas that are used
# accross different amundsen repositories. # accross different amundsen repositories.
amundsen-common>=0.3.1,<1.0 amundsen-common>=0.3.5,<1.0
# Library for rest endpoints with Flask # Library for rest endpoints with Flask
# Upstream url: https://github.com/flask-restful/flask-restful # Upstream url: https://github.com/flask-restful/flask-restful
......
...@@ -4,8 +4,11 @@ from unittest.mock import patch ...@@ -4,8 +4,11 @@ from unittest.mock import patch
import requests import requests
from requests.auth import HTTPBasicAuth from requests.auth import HTTPBasicAuth
from unittest.mock import MagicMock
from amundsen_application import create_app
from amundsen_application.dashboard_preview.mode_preview import ModePreview, DEFAULT_REPORT_URL_TEMPLATE from amundsen_application.dashboard_preview.mode_preview import ModePreview, DEFAULT_REPORT_URL_TEMPLATE
from amundsen_application.api.utils import request_utils
ACCESS_TOKEN = 'token' ACCESS_TOKEN = 'token'
PASSWORD = 'password' PASSWORD = 'password'
...@@ -14,6 +17,15 @@ ORGANIZATION = 'foo' ...@@ -14,6 +17,15 @@ ORGANIZATION = 'foo'
class TestModePreview(unittest.TestCase): class TestModePreview(unittest.TestCase):
def setUp(self) -> None:
self.app = create_app(config_module_class='amundsen_application.config.LocalConfig')
self.app_context = self.app.app_context()
self.app_context.push()
def tearDown(self) -> None:
self.app_context.pop()
def test_get_preview_image(self) -> None: def test_get_preview_image(self) -> None:
with patch.object(ModePreview, '_get_preview_image_url') as mock_get_preview_image_url,\ with patch.object(ModePreview, '_get_preview_image_url') as mock_get_preview_image_url,\
patch.object(requests, 'get') as mock_get: patch.object(requests, 'get') as mock_get:
...@@ -56,3 +68,138 @@ class TestModePreview(unittest.TestCase): ...@@ -56,3 +68,138 @@ class TestModePreview(unittest.TestCase):
preview = ModePreview(access_token='token', password='password', organization='foo') preview = ModePreview(access_token='token', password='password', organization='foo')
mode_dashboard_uri = 'mode_dashboard://gold.dg/d_id' mode_dashboard_uri = 'mode_dashboard://gold.dg/d_id'
self.assertRaises(FileNotFoundError, preview._get_preview_image_url, uri=mode_dashboard_uri) self.assertRaises(FileNotFoundError, preview._get_preview_image_url, uri=mode_dashboard_uri)
def test_auth_disabled(self) -> None:
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertFalse(preview._is_auth_enabled)
def test_auth_disabled_2(self) -> None:
self.app.config['ACL_ENABLED_DASHBOARD_PREVIEW'] = {'FooPreview'}
self.app.config['AUTH_USER_METHOD'] = MagicMock()
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertFalse(preview._is_auth_enabled)
def test_auth_enabled(self) -> None:
self.app.config['ACL_ENABLED_DASHBOARD_PREVIEW'] = {'ModePreview'}
self.app.config['AUTH_USER_METHOD'] = MagicMock()
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertTrue(preview._is_auth_enabled)
def test_authorization(self) -> None:
self.app.config['ACL_ENABLED_DASHBOARD_PREVIEW'] = {'ModePreview'}
self.app.config['AUTH_USER_METHOD'] = MagicMock()
with patch('amundsen_application.dashboard_preview.mode_preview.request_metadata') as mock_request_metadata:
mock_request_metadata.return_value.json.return_value = {
'employee_type': 'teamMember',
'full_name': 'test_full_name',
'is_active': 'True',
'github_username': 'test-github',
'slack_id': 'test_id',
'last_name': 'test_last_name',
'first_name': 'test_first_name',
'team_name': 'test_team',
'email': 'test_email',
'other_key_values': {
'mode_user_id': 'foo_mode_user_id'
}
}
preview = ModePreview(access_token='token', password='password', organization='foo')
preview._authorize_access(user_email='test_email')
with patch('amundsen_application.dashboard_preview.mode_preview.request_metadata') as mock_request_metadata:
mock_request_metadata.return_value.json.return_value = {
'employee_type': 'teamMember',
'full_name': 'test_full_name',
'is_active': 'False',
'github_username': 'test-github',
'slack_id': 'test_id',
'last_name': 'test_last_name',
'first_name': 'test_first_name',
'team_name': 'test_team',
'email': 'test_email',
'other_key_values': {
'mode_user_id': 'foo_mode_user_id'
}
}
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertRaises(PermissionError, preview._authorize_access, user_email='test_email')
with patch('amundsen_application.dashboard_preview.mode_preview.request_metadata') as mock_request_metadata:
mock_request_metadata.return_value.json.return_value = {
'employee_type': 'teamMember',
'full_name': 'test_full_name',
'is_active': 'True',
'github_username': 'test-github',
'slack_id': 'test_id',
'last_name': 'test_last_name',
'first_name': 'test_first_name',
'team_name': 'test_team',
'email': 'test_email',
'other_key_values': {}
}
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertRaises(PermissionError, preview._authorize_access, user_email='test_email')
with patch('amundsen_application.dashboard_preview.mode_preview.request_metadata') as mock_request_metadata:
mock_request_metadata.return_value.json.return_value = {
'employee_type': 'teamMember',
'full_name': 'test_full_name',
'is_active': 'True',
'github_username': 'test-github',
'slack_id': 'test_id',
'last_name': 'test_last_name',
'first_name': 'test_first_name',
'team_name': 'test_team',
'email': 'test_email',
'other_key_values': {
'foo': 'bar'
}
}
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertRaises(PermissionError, preview._authorize_access, user_email='test_email')
with patch('amundsen_application.dashboard_preview.mode_preview.request_metadata') as mock_request_metadata:
mock_request_metadata.return_value.json.return_value = {
'employee_type': 'teamMember',
'full_name': 'test_full_name',
'is_active': 'True',
'github_username': 'test-github',
'slack_id': 'test_id',
'last_name': 'test_last_name',
'first_name': 'test_first_name',
'team_name': 'test_team',
'email': 'test_email',
'other_key_values': None
}
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertRaises(PermissionError, preview._authorize_access, user_email='test_email')
with patch('amundsen_application.dashboard_preview.mode_preview.request_metadata') as mock_request_metadata:
mock_request_metadata.return_value.json.return_value = {
'employee_type': 'teamMember',
'full_name': 'test_full_name',
'is_active': 'True',
'github_username': 'test-github',
'slack_id': 'test_id',
'last_name': 'test_last_name',
'first_name': 'test_first_name',
'team_name': 'test_team',
'email': 'test_email',
}
preview = ModePreview(access_token='token', password='password', organization='foo')
self.assertRaises(PermissionError, preview._authorize_access, user_email='test_email')
if __name__ == '__main__':
unittest.main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment