Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PP-337 Quicksight dashboard embed URL generation #1378

Merged
merged 4 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,18 @@ 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 dictionary of the format `"<dashboard name>": ["arn:aws:quicksight:...",...]`
where each quicksight dashboard gets treated with an arbitrary "name", and a list of "authorized arns".
The first the "authorized arns" is always considered as the `InitialDashboardID` when creating an embed URL
for the respective "dashboard name".

#### Email

### Email sending

To use the features that require sending emails, for example to reset the password for logged-out users, you will need
to have a working SMTP server and set some environment variables:

Expand Down
3 changes: 3 additions & 0 deletions api/admin/controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from typing import TYPE_CHECKING

from api.admin.controller.quicksight import QuickSightController

if TYPE_CHECKING:
from api.controller import CirculationManager

Expand Down Expand Up @@ -100,3 +102,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)
117 changes: 117 additions & 0 deletions api/admin/controller/quicksight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import logging
from typing import Dict

import boto3
import flask

from api.admin.model.quicksight import (
QuicksightDashboardNamesResponse,
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_name) -> Dict:
log = logging.getLogger(self.__class__.__name__)
admin: Admin = getattr(flask.request, "admin")
request_data = QuicksightGenerateUrlRequest(**flask.request.args)

all_authorized_arns = Configuration.quicksight_authorized_arns()
if not all_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."
)
)

authorized_arns = all_authorized_arns.get(dashboard_name)
if not authorized_arns:
raise ProblemError(
INVALID_INPUT.detailed(
"The requested Dashboard ARN is not recognized by this server."
)
)

# The first dashboard id is the primary ARN
dashboard_arn = authorized_arns[0]
# format aws:arn:quicksight:<region>:<account id>:<dashboard>
arn_parts = dashboard_arn.split(":")
# Pull the region and account id from the ARN
aws_account_id = arn_parts[4]
region = arn_parts[3]
dashboard_id = arn_parts[5].split("/", 1)[1] # drop the "dashboard/" part

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:
delimiter = "|"
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=delimiter.join([l.name for l in libraries]),
)
],
)
except Exception as ex:
log.error(f"Error while fetching the Quisksight Embed url: {ex}")
raise ProblemError(

Check warning on line 97 in api/admin/controller/quicksight.py

View check run for this annotation

Codecov / codecov/patch

api/admin/controller/quicksight.py#L95-L97

Added lines #L95 - L97 were not covered by tests
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()

def get_dashboard_names(self):
"""Get the named dashboard IDs defined in the configuration"""
config = Configuration.quicksight_authorized_arns()
return QuicksightDashboardNamesResponse(names=list(config.keys())).api_dict()
23 changes: 23 additions & 0 deletions api/admin/model/quicksight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import List

from pydantic import Field, validator

from core.util.flask_util import CustomBaseModel, str_comma_list_validator


class QuicksightGenerateUrlRequest(CustomBaseModel):
library_ids: List[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."
)

@validator("library_ids", pre=True)
def parse_library_ids(cls, value):
return str_comma_list_validator(value)


class QuicksightGenerateUrlResponse(CustomBaseModel):
embed_url: str = Field(description="The dashboard embed url.")


class QuicksightDashboardNamesResponse(CustomBaseModel):
names: List[str] = Field(description="The named quicksight dashboard ids")
38 changes: 36 additions & 2 deletions api/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@
from api.admin.controller.custom_lists import CustomListsController
from api.admin.dashboard_stats import generate_statistics
from api.admin.model.dashboard_statistics import StatisticsResponse
from api.admin.model.quicksight import (
QuicksightDashboardNamesResponse,
QuicksightGenerateUrlRequest,
QuicksightGenerateUrlResponse,
)
from api.admin.templates import admin_sign_in_again as sign_in_again_template
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

# An admin's session will expire after this amount of time and
# the admin will have to log in again.
Expand Down Expand Up @@ -82,7 +87,11 @@

@wraps(f)
def decorated(*args, **kwargs):
v = f(*args, **kwargs)
try:
v = f(*args, **kwargs)
except ProblemError as ex:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this in so we can globally manage raising ProblemErrors

# A ProblemError is the same as a ProblemDetail
v = ex.problem_detail
if isinstance(v, ProblemDetail):
return v.response
if isinstance(v, Response):
Expand Down Expand Up @@ -313,6 +322,31 @@
return statistics_response.api_dict()


@app.route("/admin/quicksight_embed/<dashboard_name>")
@api_spec.validate(
resp=SpecResponse(HTTP_200=QuicksightGenerateUrlResponse),
tags=["admin.quicksight"],
query=QuicksightGenerateUrlRequest,
)
@returns_json_or_response_or_problem_detail
@requires_admin
def generate_quicksight_url(dashboard_name: str):
return app.manager.admin_quicksight_controller.generate_quicksight_url(

Check warning on line 334 in api/admin/routes.py

View check run for this annotation

Codecov / codecov/patch

api/admin/routes.py#L334

Added line #L334 was not covered by tests
dashboard_name
)


@app.route("/admin/quicksight_embed/names")
@api_spec.validate(
resp=SpecResponse(HTTP_200=QuicksightDashboardNamesResponse),
tags=["admin.quicksight"],
)
@returns_json_or_response_or_problem_detail
@requires_admin
def get_quicksight_names():
return app.manager.admin_quicksight_controller.get_dashboard_names()

Check warning on line 347 in api/admin/routes.py

View check run for this annotation

Codecov / codecov/patch

api/admin/routes.py#L347

Added line #L347 was not covered by tests


@app.route("/admin/libraries", methods=["GET", "POST"])
@returns_json_or_response_or_problem_detail
@requires_admin
Expand Down
2 changes: 2 additions & 0 deletions api/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,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,
Expand Down Expand Up @@ -220,6 +221,7 @@ class CirculationManager:
admin_announcement_service: AnnouncementSettings
admin_search_controller: AdminSearchController
admin_view_controller: ViewController
admin_quicksight_controller: QuickSightController

def __init__(self, _db, services: Services):
self._db = _db
Expand Down
12 changes: 11 additions & 1 deletion core/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,6 +51,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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above re dictionary.


# Environment variable for SirsiDynix Auth
SIRSI_DYNIX_APP_ID = "SIMPLIFIED_SIRSI_DYNIX_APP_ID"

Expand Down Expand Up @@ -284,6 +288,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) -> Dict[str, List[str]]:
"""Split the comma separated arns"""
arns_str = os.environ.get(cls.QUICKSIGHT_AUTHORIZED_ARNS_KEY, "")
return json.loads(arns_str)

@classmethod
def localization_languages(cls):
return [LanguageCodes.three_to_two["eng"]]
Expand Down
11 changes: 11 additions & 0 deletions core/util/flask_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,14 @@ def api_dict(
rather than their Python class member names.
"""
return self.dict(*args, by_alias=by_alias, **kwargs)


def str_comma_list_validator(value):
"""Validate a comma separated string and parse it into a list, generally used for query parameters"""
if isinstance(value, (int, float)):
# A single number shows up as an int
value = str(value)
elif not isinstance(value, str):
raise TypeError("string required")

return value.split(",")
Loading