diff --git a/README.md b/README.md index e379626402..ade8dee601 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,11 @@ export SIMPLIFIED_FCM_CREDENTIALS_FILE="/opt/credentials/fcm_credentials.json" The FCM credentials can be downloaded once a Google Service account has been created. More details in the [FCM documentation](https://firebase.google.com/docs/admin/setup#set-up-project-and-service-account) +##### Quicksight Dashboards + +For generating quicksight dashboard links the following environment variable is required +`QUICKSIGHT_AUTHORIZED_ARNS` - A comma separated list of `arn:aws:quicksight:...` format ARN strings + ### Email sending To use the features that require sending emails, for example to reset the password for logged-out users, you will need diff --git a/api/admin/controller/__init__.py b/api/admin/controller/__init__.py index 32e82b095f..0c45079954 100644 --- a/api/admin/controller/__init__.py +++ b/api/admin/controller/__init__.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +from api.admin.controller.quicksight import QuickSightController + if TYPE_CHECKING: from api.controller import CirculationManager @@ -104,3 +106,4 @@ def setup_admin_controllers(manager: CirculationManager): manager.admin_catalog_services_controller = CatalogServicesController(manager) manager.admin_announcement_service = AnnouncementSettings(manager) manager.admin_search_controller = AdminSearchController(manager) + manager.admin_quicksight_controller = QuickSightController(manager) diff --git a/api/admin/controller/quicksight.py b/api/admin/controller/quicksight.py new file mode 100644 index 0000000000..4cd1b2505e --- /dev/null +++ b/api/admin/controller/quicksight.py @@ -0,0 +1,103 @@ +import logging +from typing import Dict + +import boto3 +import flask + +from api.admin.model.quicksight import ( + QuicksightGenerateUrlRequest, + QuicksightGenerateUrlResponse, +) +from api.controller import CirculationManagerController +from api.problem_details import NOT_FOUND_ON_REMOTE +from core.config import Configuration +from core.model.admin import Admin +from core.model.library import Library +from core.problem_details import INTERNAL_SERVER_ERROR, INVALID_INPUT +from core.util.problem_detail import ProblemError + + +class QuickSightController(CirculationManagerController): + def generate_quicksight_url(self, dashboard_id) -> Dict: + log = logging.getLogger(self.__class__.__name__) + admin: Admin = getattr(flask.request, "admin") + request_data = QuicksightGenerateUrlRequest(**flask.request.args) + + authorized_arns = Configuration.quicksight_authorized_arns() + if not authorized_arns: + log.error("No Quicksight ARNs were configured for this server.") + raise ProblemError( + INTERNAL_SERVER_ERROR.detailed( + "Quicksight has not been configured for this server." + ) + ) + + for arn in authorized_arns: + # format aws:arn:quicksight::: + arn_parts = arn.split(":") + if f"dashboard/{dashboard_id}" == arn_parts[5]: + # Pull the region and account id from the ARN + aws_account_id = arn_parts[4] + region = arn_parts[3] + break + else: + raise ProblemError( + INVALID_INPUT.detailed( + "The requested Dashboard ARN is not recognized by this server." + ) + ) + + allowed_libraries = [] + for library in self._db.query(Library).all(): + if admin.is_librarian(library): + allowed_libraries.append(library) + + if request_data.library_ids: + allowed_library_ids = list( + set(request_data.library_ids).intersection( + {l.id for l in allowed_libraries} + ) + ) + else: + allowed_library_ids = [l.id for l in allowed_libraries] + + if not allowed_library_ids: + raise ProblemError( + NOT_FOUND_ON_REMOTE.detailed( + "No library was found for this Admin that matched the request." + ) + ) + + libraries = ( + self._db.query(Library).filter(Library.id.in_(allowed_library_ids)).all() + ) + + try: + client = boto3.client("quicksight", region_name=region) + response = client.generate_embed_url_for_anonymous_user( + AwsAccountId=aws_account_id, + Namespace="default", # Default namespace only + AuthorizedResourceArns=authorized_arns, + ExperienceConfiguration={ + "Dashboard": {"InitialDashboardId": dashboard_id} + }, + SessionTags=[dict(Key="library_name", Value=l.name) for l in libraries], + ) + except Exception as ex: + log.error(f"Error while fetching the Quisksight Embed url: {ex}") + raise ProblemError( + INTERNAL_SERVER_ERROR.detailed( + "Error while fetching the Quisksight Embed url." + ) + ) + + embed_url = response.get("EmbedUrl") + if response.get("Status") // 100 != 2 or embed_url is None: + log.error(f"QuiskSight Embed url error response {response}") + raise ProblemError( + INTERNAL_SERVER_ERROR.detailed( + "Error while fetching the Quisksight Embed url." + ) + ) + + return QuicksightGenerateUrlResponse(embed_url=embed_url).api_dict() diff --git a/api/admin/model/quicksight.py b/api/admin/model/quicksight.py new file mode 100644 index 0000000000..eecacabee6 --- /dev/null +++ b/api/admin/model/quicksight.py @@ -0,0 +1,13 @@ +from pydantic import Field + +from core.util.flask_util import CustomBaseModel, StrCommaList + + +class QuicksightGenerateUrlRequest(CustomBaseModel): + library_ids: StrCommaList[int] = Field( + description="The list of libraries to include in the dataset, an empty list is equivalent to all the libraries the user is allowed to access." + ) + + +class QuicksightGenerateUrlResponse(CustomBaseModel): + embed_url: str = Field(description="The dashboard embed url.") diff --git a/api/admin/routes.py b/api/admin/routes.py index ccabccf285..14d4bc890e 100644 --- a/api/admin/routes.py +++ b/api/admin/routes.py @@ -10,10 +10,11 @@ from api.admin.config import Configuration as AdminClientConfig from api.admin.dashboard_stats import generate_statistics from api.admin.model.dashboard_statistics import StatisticsResponse +from api.admin.model.quicksight import QuicksightGenerateUrlResponse from api.app import api_spec, app from api.routes import allows_library, has_library, library_route from core.app_server import ensure_pydantic_after_problem_detail, returns_problem_detail -from core.util.problem_detail import ProblemDetail, ProblemDetailModel +from core.util.problem_detail import ProblemDetail, ProblemDetailModel, ProblemError from .controller.custom_lists import CustomListsController from .templates import admin_sign_in_again as sign_in_again_template @@ -83,7 +84,11 @@ def returns_json_or_response_or_problem_detail(f): @wraps(f) def decorated(*args, **kwargs): - v = f(*args, **kwargs) + try: + v = f(*args, **kwargs) + except ProblemError as ex: + # A ProblemError is the same as a ProblemDetail + v = ex.problem_detail if isinstance(v, ProblemDetail): return v.response if isinstance(v, Response): @@ -340,6 +345,16 @@ def stats(): return statistics_response.api_dict() +@app.route("/admin/quicksight_embed/") +@api_spec.validate( + resp=SpecResponse(HTTP_200=QuicksightGenerateUrlResponse), tags=["admin.quicksight"] +) +@returns_json_or_response_or_problem_detail +@requires_admin +def generate_quicksight_url(dashboard_id: str): + return app.manager.admin_quicksight_controller.generate_quicksight_url(dashboard_id) + + @app.route("/admin/libraries", methods=["GET", "POST"]) @returns_json_or_response_or_problem_detail @requires_admin diff --git a/api/controller.py b/api/controller.py index 743697e886..9b83fcd47f 100644 --- a/api/controller.py +++ b/api/controller.py @@ -149,6 +149,7 @@ PatronAuthServiceSelfTestsController, ) from api.admin.controller.patron_auth_services import PatronAuthServicesController + from api.admin.controller.quicksight import QuickSightController from api.admin.controller.reset_password import ResetPasswordController from api.admin.controller.search_service_self_tests import ( SearchServiceSelfTestsController, @@ -223,6 +224,7 @@ class CirculationManager: admin_announcement_service: AnnouncementSettings admin_search_controller: AdminSearchController admin_view_controller: ViewController + admin_quicksight_controller: QuickSightController def __init__(self, _db): self._db = _db diff --git a/core/config.py b/core/config.py index 5a9cf07b46..28b24bdc18 100644 --- a/core/config.py +++ b/core/config.py @@ -1,7 +1,7 @@ import json import logging import os -from typing import Dict +from typing import Dict, List from flask_babel import lazy_gettext as _ from sqlalchemy.engine.url import make_url @@ -50,6 +50,10 @@ class Configuration(ConfigurationConstants): OD_FULFILLMENT_CLIENT_KEY_SUFFIX = "OVERDRIVE_FULFILLMENT_CLIENT_KEY" OD_FULFILLMENT_CLIENT_SECRET_SUFFIX = "OVERDRIVE_FULFILLMENT_CLIENT_SECRET" + # Quicksight + # Comma separated aws arns + QUICKSIGHT_AUTHORIZED_ARNS_KEY = "QUICKSIGHT_AUTHORIZED_ARNS" + # Environment variable for SirsiDynix Auth SIRSI_DYNIX_APP_ID = "SIMPLIFIED_SIRSI_DYNIX_APP_ID" @@ -263,6 +267,12 @@ def overdrive_fulfillment_keys(cls, testing=False) -> Dict[str, str]: raise CannotLoadConfiguration("Invalid fulfillment credentials.") return {"key": key, "secret": secret} + @classmethod + def quicksight_authorized_arns(cls) -> List[str]: + """Split the comma separated arns""" + arns_str = os.environ.get(cls.QUICKSIGHT_AUTHORIZED_ARNS_KEY, "") + return arns_str.split(",") + @classmethod def localization_languages(cls): return [LanguageCodes.three_to_two["eng"]] diff --git a/core/util/flask_util.py b/core/util/flask_util.py index a2e0e11a1d..a7634b7ede 100644 --- a/core/util/flask_util.py +++ b/core/util/flask_util.py @@ -1,7 +1,7 @@ """Utilities for Flask applications.""" import datetime import time -from typing import Any, Dict +from typing import Any, Dict, Generic, TypeVar from wsgiref.handlers import format_date_time from flask import Response as FlaskResponse @@ -206,3 +206,28 @@ def api_dict( rather than their Python class member names. """ return self.dict(*args, by_alias=by_alias, **kwargs) + + +T = TypeVar("T") + + +class StrCommaList(list, Generic[T]): + """A list of comma separated values, generally received as query parameters in a URL. + We expect pydantic to do the type coercion with respect to the Generic Type. + The final value is expected to be a List of the right Type. + + Usage: StrCommaList[Type], just like a List[Type] defintion. + """ + + @classmethod + def __get_validators__(cls): + """Pydantic specific API""" + yield cls.validate + + @classmethod + def validate(cls, comma_separated_str): + """Validate the data type and split the string by commas""" + if not isinstance(comma_separated_str, str): + raise TypeError("String required") + # Pydantic wil typecast the values based on the Generic type to the List[...] + return [value for value in comma_separated_str.split(",")] diff --git a/tests/api/admin/controller/test_quicksight.py b/tests/api/admin/controller/test_quicksight.py new file mode 100644 index 0000000000..29e567ad6c --- /dev/null +++ b/tests/api/admin/controller/test_quicksight.py @@ -0,0 +1,188 @@ +from unittest import mock + +import pytest + +from core.model import create +from core.model.admin import Admin, AdminRole +from core.util.problem_detail import ProblemError +from tests.fixtures.api_admin import AdminControllerFixture +from tests.fixtures.api_controller import ControllerFixture + + +class QuickSightControllerFixture(AdminControllerFixture): + def __init__(self, controller_fixture: ControllerFixture): + super().__init__(controller_fixture) + + +@pytest.fixture +def quicksight_fixture( + controller_fixture: ControllerFixture, +) -> QuickSightControllerFixture: + return QuickSightControllerFixture(controller_fixture) + + +class TestQuicksightController: + def test_generate_quicksight_url( + self, quicksight_fixture: QuickSightControllerFixture + ): + ctrl = quicksight_fixture.manager.admin_quicksight_controller + db = quicksight_fixture.ctrl.db + + system_admin, _ = create(db.session, Admin, email="admin@email.com") + system_admin.add_role(AdminRole.SYSTEM_ADMIN) + default = db.default_library() + library1 = db.library() + + with mock.patch( + "api.admin.controller.quicksight.boto3" + ) as mock_boto, mock.patch( + "api.admin.controller.quicksight.Configuration.quicksight_authorized_arns" + ) as mock_qs_arns: + arns = [ + "arn:aws:quicksight:us-west-1:aws-account-id:dashboard/uuid1", + "arn:aws:quicksight:us-west-1:aws-account-id:dashboard/uuid2", + ] + mock_qs_arns.return_value = arns + generate_method: mock.MagicMock = ( + mock_boto.client().generate_embed_url_for_anonymous_user + ) + generate_method.return_value = {"Status": 201, "EmbedUrl": "https://embed"} + + with quicksight_fixture.request_context_with_admin( + f"/?library_ids={default.id},{library1.id},30000", + admin=system_admin, + ) as ctx: + response = ctrl.generate_quicksight_url("uuid1") + + # Assert the right client was created, with a region + assert mock_boto.client.call_args == mock.call( + "quicksight", region_name="us-west-1" + ) + # Assert the reqest and response formats + assert response["embedUrl"] == "https://embed" + assert generate_method.call_args == mock.call( + AwsAccountId="aws-account-id", + Namespace="default", + AuthorizedResourceArns=arns, + ExperienceConfiguration={ + "Dashboard": {"InitialDashboardId": "uuid1"} + }, + SessionTags=[ + dict(Key="library_name", Value=name) + for name in [default.name, library1.name] + ], + ) + + # Specific library roles + admin1, _ = create(db.session, Admin, email="admin1@email.com") + admin1.add_role(AdminRole.LIBRARY_MANAGER, library1) + + with quicksight_fixture.request_context_with_admin( + f"/?library_ids=1,{library1.id}", + admin=admin1, + ) as ctx: + generate_method.reset_mock() + ctrl.generate_quicksight_url("uuid2") + + assert generate_method.call_args == mock.call( + AwsAccountId="aws-account-id", + Namespace="default", + AuthorizedResourceArns=arns, + ExperienceConfiguration={ + "Dashboard": {"InitialDashboardId": "uuid2"} + }, + SessionTags=[ + dict(Key="library_name", Value=name) + for name in [library1.name] # Only the Admin authorized library + ], + ) + + def test_generate_quicksight_url_errors( + self, quicksight_fixture: QuickSightControllerFixture + ): + ctrl = quicksight_fixture.manager.admin_quicksight_controller + db = quicksight_fixture.ctrl.db + + library = db.library() + library_not_allowed = db.library() + admin, _ = create(db.session, Admin, email="admin@email.com") + admin.add_role(AdminRole.LIBRARY_MANAGER, library=library) + + with mock.patch( + "api.admin.controller.quicksight.boto3" + ) as mock_boto, mock.patch( + "api.admin.controller.quicksight.Configuration.quicksight_authorized_arns" + ) as mock_qs_arns: + arns = [ + "arn:aws:quicksight:us-west-1:aws-account-id:dashboard/uuid1", + "arn:aws:quicksight:us-west-1:aws-account-id:dashboard/uuid2", + ] + mock_qs_arns.return_value = arns + + with quicksight_fixture.request_context_with_admin( + f"/?library_ids={library.id}", + admin=admin, + ) as ctx: + with pytest.raises(ProblemError) as raised: + ctrl.generate_quicksight_url("uuid-none") + assert ( + raised.value.problem_detail.detail + == "The requested Dashboard ARN is not recognized by this server." + ) + + mock_qs_arns.return_value = [] + with pytest.raises(ProblemError) as raised: + ctrl.generate_quicksight_url("uuid1") + assert ( + raised.value.problem_detail.detail + == "Quicksight has not been configured for this server." + ) + + with quicksight_fixture.request_context_with_admin( + f"/?library_ids={library_not_allowed.id}", + admin=admin, + ) as ctx: + mock_qs_arns.return_value = arns + with pytest.raises(ProblemError) as raised: + ctrl.generate_quicksight_url("uuid1") + assert ( + raised.value.problem_detail.detail + == "No library was found for this Admin that matched the request." + ) + + with quicksight_fixture.request_context_with_admin( + f"/?library_ids={library.id}", + admin=admin, + ) as ctx: + # Bad response from boto + mock_boto.generate_embed_url_for_anonymous_user.return_value = dict( + status=400, embed_url="http://embed" + ) + with pytest.raises(ProblemError) as raised: + ctrl.generate_quicksight_url("uuid1") + assert ( + raised.value.problem_detail.detail + == "Error while fetching the Quisksight Embed url." + ) + + # 200 status, but no url + mock_boto.generate_embed_url_for_anonymous_user.return_value = dict( + status=200, + ) + with pytest.raises(ProblemError) as raised: + ctrl.generate_quicksight_url("uuid1") + assert ( + raised.value.problem_detail.detail + == "Error while fetching the Quisksight Embed url." + ) + + # Boto threw an error + mock_boto.generate_embed_url_for_anonymous_user.side_effect = Exception( + "" + ) + with pytest.raises(ProblemError) as raised: + ctrl.generate_quicksight_url("uuid1") + assert ( + raised.value.problem_detail.detail + == "Error while fetching the Quisksight Embed url." + )