From 8cba3d066b95c001023641eb28d1f6a12fc97a2c Mon Sep 17 00:00:00 2001 From: VineetBala-AOT <90332175+VineetBala-AOT@users.noreply.github.com> Date: Thu, 21 Sep 2023 15:40:04 -0700 Subject: [PATCH] Restrict analytics for unpublish engagement (#2236) * Changes to show all survey results to superusers * removing hard coded values * fixing linting * splitting to seperate end points * fixing auth check * fixing linting * merging method in service * Handle no data error for graphs * adding new nodata component * adding new email for submission response * fixing linting and testing * Upgrades to Issue Tracking Table * removing try catch * Updated dagster user code deployment name * Restrict analytics for unpublish engagement --- .github/workflows/met-etl-cd.yml | 2 +- .../constants/engagement_status.py | 25 +++++++++++ .../services/aggregator_service.py | 14 +++--- .../services/engagement_service.py | 17 +++++--- .../analytics_api/services/survey_result.py | 9 ++-- .../services/user_response_detail.py | 17 +++++--- .../utils/engagement_access_validator.py | 26 +++++++++++ .../src/analytics_api/utils/roles.py | 1 + .../src/analytics_api/utils/token_info.py | 43 +++++++++++++++++++ .../services/ops/engagement_etl_service.py | 5 +++ 10 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 analytics-api/src/analytics_api/constants/engagement_status.py create mode 100644 analytics-api/src/analytics_api/utils/engagement_access_validator.py create mode 100644 analytics-api/src/analytics_api/utils/token_info.py diff --git a/.github/workflows/met-etl-cd.yml b/.github/workflows/met-etl-cd.yml index 6f44afb2e..f9107b950 100644 --- a/.github/workflows/met-etl-cd.yml +++ b/.github/workflows/met-etl-cd.yml @@ -26,7 +26,7 @@ defaults: env: APP_NAME: "dagster-etl" - DEPLOYMENT_NAME: "dagster-dagster-user-deployments-k8s-example-user-code-1" + DEPLOYMENT_NAME: "dagster-dagster-user-deployments-etl" TAG_NAME: "dev" PROJECT_TYPE: "${{ github.event.inputs.project_type || 'EAO' }}" # If the project type is manually selected, use the input value; otherwise, use 'EAO' as default diff --git a/analytics-api/src/analytics_api/constants/engagement_status.py b/analytics-api/src/analytics_api/constants/engagement_status.py new file mode 100644 index 000000000..95dd69b0f --- /dev/null +++ b/analytics-api/src/analytics_api/constants/engagement_status.py @@ -0,0 +1,25 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Constants of engagement status.""" +from enum import Enum + + +class Status(Enum): + """Enum of engagement status.""" + + Draft = 'Draft' + Published = 'Published' + Closed = 'Closed' + Scheduled = 'Scheduled' + Unpublished = 'Unpublished' diff --git a/analytics-api/src/analytics_api/services/aggregator_service.py b/analytics-api/src/analytics_api/services/aggregator_service.py index a38833aa2..94b0ffdeb 100644 --- a/analytics-api/src/analytics_api/services/aggregator_service.py +++ b/analytics-api/src/analytics_api/services/aggregator_service.py @@ -1,6 +1,7 @@ """Service to get counts for dashboard.""" from analytics_api.models.email_verification import EmailVerification as EmailVerificationModel from analytics_api.models.user_response_detail import UserResponseDetail as UserResponseDetailModel +from analytics_api.utils import engagement_access_validator class AggregatorService: # pylint: disable=too-few-public-methods @@ -11,11 +12,10 @@ class AggregatorService: # pylint: disable=too-few-public-methods @staticmethod def get_count(engagement_id, count_for=''): """Get total count for an engagement id.""" - total_count = 0 + if engagement_access_validator.check_engagement_access(engagement_id): + if count_for == 'email_verification': + return EmailVerificationModel.get_email_verification_count(engagement_id) + if count_for == 'survey_completed': + return UserResponseDetailModel.get_response_count(engagement_id) - if count_for == 'email_verification': - total_count = EmailVerificationModel.get_email_verification_count(engagement_id) - if count_for == 'survey_completed': - total_count = UserResponseDetailModel.get_response_count(engagement_id) - - return total_count + return 0 diff --git a/analytics-api/src/analytics_api/services/engagement_service.py b/analytics-api/src/analytics_api/services/engagement_service.py index 61ea3ebad..78ea231d2 100644 --- a/analytics-api/src/analytics_api/services/engagement_service.py +++ b/analytics-api/src/analytics_api/services/engagement_service.py @@ -2,6 +2,7 @@ from analytics_api.models.engagement import Engagement as EngagementModel from analytics_api.schemas.engagement import EngagementSchema from analytics_api.schemas.map_data import MapDataSchema +from analytics_api.utils import engagement_access_validator class EngagementService: # pylint: disable=too-few-public-methods @@ -12,13 +13,17 @@ class EngagementService: # pylint: disable=too-few-public-methods @staticmethod def get_engagement(engagement_id) -> EngagementSchema: """Get Engagement by the id.""" - engagement = EngagementModel.find_by_id(engagement_id) - engagement_schema = EngagementSchema() - return engagement_schema.dump(engagement) + if engagement_access_validator.check_engagement_access(engagement_id): + engagement = EngagementModel.find_by_id(engagement_id) + engagement_schema = EngagementSchema() + return engagement_schema.dump(engagement) + return {} @staticmethod def get_engagement_map_data(engagement_id) -> MapDataSchema: """Get Map data by the engagement id.""" - map_data = EngagementModel.get_engagement_map_data(engagement_id) - map_data_schema = MapDataSchema() - return map_data_schema.dump(map_data) + if engagement_access_validator.check_engagement_access(engagement_id): + map_data = EngagementModel.get_engagement_map_data(engagement_id) + map_data_schema = MapDataSchema() + return map_data_schema.dump(map_data) + return {} diff --git a/analytics-api/src/analytics_api/services/survey_result.py b/analytics-api/src/analytics_api/services/survey_result.py index 9bebe4d2b..40cccd80b 100644 --- a/analytics-api/src/analytics_api/services/survey_result.py +++ b/analytics-api/src/analytics_api/services/survey_result.py @@ -1,6 +1,7 @@ """Service for survey result management.""" from analytics_api.models.request_type_option import RequestTypeOption as RequestTypeOptionModel from analytics_api.schemas.survey_result import SurveyResultSchema +from analytics_api.utils import engagement_access_validator class SurveyResultService: # pylint: disable=too-few-public-methods @@ -11,6 +12,8 @@ class SurveyResultService: # pylint: disable=too-few-public-methods @staticmethod def get_survey_result(engagement_id, can_view_all_survey_results) -> SurveyResultSchema: """Get Survey result by the engagement id.""" - survey_result = RequestTypeOptionModel.get_survey_result(engagement_id, can_view_all_survey_results) - survey_result_schema = SurveyResultSchema(many=True) - return survey_result_schema.dump(survey_result) + if engagement_access_validator.check_engagement_access(engagement_id): + survey_result = RequestTypeOptionModel.get_survey_result(engagement_id, can_view_all_survey_results) + survey_result_schema = SurveyResultSchema(many=True) + return survey_result_schema.dump(survey_result) + return {} diff --git a/analytics-api/src/analytics_api/services/user_response_detail.py b/analytics-api/src/analytics_api/services/user_response_detail.py index 46e9caeef..6b7e6be96 100644 --- a/analytics-api/src/analytics_api/services/user_response_detail.py +++ b/analytics-api/src/analytics_api/services/user_response_detail.py @@ -1,5 +1,6 @@ """Service for user response detail management.""" from analytics_api.models.user_response_detail import UserResponseDetail as UserResponseDetailModel +from analytics_api.utils import engagement_access_validator class UserResponseDetailService: @@ -10,13 +11,17 @@ class UserResponseDetailService: @staticmethod def get_response_count_by_created_month(engagement_id, search_options=None): """Get user response count for an engagement id grouped by created month.""" - response_count_by_created_month = UserResponseDetailModel.get_response_count_by_created_month( - engagement_id, search_options) - return response_count_by_created_month + if engagement_access_validator.check_engagement_access(engagement_id): + response_count_by_created_month = UserResponseDetailModel.get_response_count_by_created_month( + engagement_id, search_options) + return response_count_by_created_month + return {} @staticmethod def get_response_count_by_created_week(engagement_id, search_options=None): """Get user response count for an engagement id grouped by created week.""" - response_count_by_created_week = UserResponseDetailModel.get_response_count_by_created_week( - engagement_id, search_options) - return response_count_by_created_week + if engagement_access_validator.check_engagement_access(engagement_id): + response_count_by_created_week = UserResponseDetailModel.get_response_count_by_created_week( + engagement_id, search_options) + return response_count_by_created_week + return {} diff --git a/analytics-api/src/analytics_api/utils/engagement_access_validator.py b/analytics-api/src/analytics_api/utils/engagement_access_validator.py new file mode 100644 index 000000000..8d27261fc --- /dev/null +++ b/analytics-api/src/analytics_api/utils/engagement_access_validator.py @@ -0,0 +1,26 @@ +"""Check Engagement Access Service.""" +from sqlalchemy import and_, exists +from sqlalchemy.sql.expression import true +from analytics_api.constants.engagement_status import Status +from analytics_api.models.db import db +from analytics_api.models.engagement import Engagement as EngagementModel +from analytics_api.utils.roles import Role +from analytics_api.utils.token_info import TokenInfo + + +def check_engagement_access(engagement_id): + """Check if user has access to get engagement details.""" + is_engagement_unpublished = db.session.query( + exists() + .where( + and_( + EngagementModel.source_engagement_id == engagement_id, + EngagementModel.is_active == true(), + EngagementModel.status_name == Status.Unpublished.value + ) + ) + ).scalar() + + user_roles = set(TokenInfo.get_user_roles()) + + return not is_engagement_unpublished or Role.ACCESS_DASHBOARD.value in user_roles diff --git a/analytics-api/src/analytics_api/utils/roles.py b/analytics-api/src/analytics_api/utils/roles.py index 07c82a899..9f06c69b0 100644 --- a/analytics-api/src/analytics_api/utils/roles.py +++ b/analytics-api/src/analytics_api/utils/roles.py @@ -19,3 +19,4 @@ class Role(Enum): """User Role.""" VIEW_ALL_SURVEY_RESULTS = 'view_all_survey_results' # Allows users to view results to all questions + ACCESS_DASHBOARD = 'access_dashboard' diff --git a/analytics-api/src/analytics_api/utils/token_info.py b/analytics-api/src/analytics_api/utils/token_info.py new file mode 100644 index 000000000..7b8797e3a --- /dev/null +++ b/analytics-api/src/analytics_api/utils/token_info.py @@ -0,0 +1,43 @@ +"""Helper for token decoding.""" +from flask import current_app, g + +from analytics_api.utils.roles import Role +from analytics_api.utils.user_context import UserContext, user_context + + +class TokenInfo: + """Token info.""" + + @staticmethod + @user_context + def get_id(**kwargs): + """Get the user identifier.""" + try: + user_from_context: UserContext = kwargs['user_context'] + return user_from_context.sub + except AttributeError: + return None + + @staticmethod + def get_user_data(): + """Get the user data.""" + token_info = g.jwt_oidc_token_info + user_data = { + 'external_id': token_info.get('sub', None), + 'first_name': token_info.get('given_name', None), + 'last_name': token_info.get('family_name', None), + 'email_address': token_info.get('email', None), + 'username': token_info.get('preferred_username', None), + 'identity_provider': token_info.get('identity_provider', ''), + 'roles': TokenInfo.get_user_roles(), + } + return user_data + + @staticmethod + def get_user_roles(): + """Get the user roles from token.""" + if not hasattr(g, 'jwt_oidc_token_info') or not g.jwt_oidc_token_info: + return [] + valid_roles = set(item.value for item in Role) + token_roles = current_app.config['JWT_ROLE_CALLBACK'](g.jwt_oidc_token_info) + return valid_roles.intersection(token_roles) diff --git a/met-etl/src/etl_project/services/ops/engagement_etl_service.py b/met-etl/src/etl_project/services/ops/engagement_etl_service.py index ed347d933..aa55dd702 100644 --- a/met-etl/src/etl_project/services/ops/engagement_etl_service.py +++ b/met-etl/src/etl_project/services/ops/engagement_etl_service.py @@ -1,6 +1,7 @@ from dagster import Out, Output, op from met_api.constants.engagement_status import Status as MetEngagementStatus from met_api.models.engagement import Engagement as MetEngagementModel +from met_api.models.engagement_status import EngagementStatus as EngagementStatusModel from met_api.models.widget_map import WidgetMap as MetWidgetMap from analytics_api.models.engagement import Engagement as EtlEngagementModel from sqlalchemy import func @@ -102,6 +103,9 @@ def load_engagement(context, new_engagements, updated_engagements, engagement_ne geojson=map_widget.geojson marker_label=map_widget.marker_label + engagement_status = met_session.query(EngagementStatusModel).filter( + EngagementStatusModel.id == engagement.status_id).first() + engagement_model = EtlEngagementModel(name=engagement.name, source_engagement_id=engagement.id, start_date=engagement.start_date, @@ -115,6 +119,7 @@ def load_engagement(context, new_engagements, updated_engagements, engagement_ne longitude=longitude, geojson=geojson, marker_label=marker_label, + status_name=engagement_status.status_name ) session.add(engagement_model) session.commit()