diff --git a/recidiviz/case_triage/authorization_utils.py b/recidiviz/case_triage/authorization_utils.py index ebe2853e9a..ad1dabcec3 100644 --- a/recidiviz/case_triage/authorization_utils.py +++ b/recidiviz/case_triage/authorization_utils.py @@ -18,7 +18,9 @@ This module contains a helper for authenticating users accessing product APIs hosted on the Case Triage backend. """ +import datetime import json +import logging import os from http import HTTPStatus from typing import Any, Callable, Dict, List, Optional @@ -139,3 +141,45 @@ def on_successful_authorization_requested_state( return raise AuthorizationError(code="not_authorized", description="Access denied") + + +def get_active_feature_variants( + feature_variants: dict, pseudonymized_id: Optional[str] +) -> dict: + """Get active feature variants for a user, logging an error if any + feature variant is invalid (i.e., not a dict or bool). + + Args: + feature_variants (Dict[str, Any]): Raw dictionary of possible feature variants. + pseudonymized_id (Optional[str]): ID of the user for logging purposes. + + Returns: + Dict[str, Any]: Active feature variants. + """ + + active_feature_variants, failure_feature_variants = {}, {} + + for fv, params in feature_variants.items(): + if isinstance(params, dict): + active_date: datetime.datetime | None = ( + datetime.datetime.fromisoformat(params["activeDate"]) + if "activeDate" in params + else None + ) + if active_date is None or active_date < datetime.datetime.now( + tz=active_date.tzinfo + ): + active_feature_variants[fv] = params + elif params is True: + active_feature_variants[fv] = {} + elif params not in {False, None}: + failure_feature_variants[fv] = params + + if failure_feature_variants: + logging.error( + "User with id %s has invalid feature variants: %s", + pseudonymized_id if pseudonymized_id else "unknown", + failure_feature_variants, + ) + + return active_feature_variants diff --git a/recidiviz/case_triage/outliers/outliers_authorization.py b/recidiviz/case_triage/outliers/outliers_authorization.py index edab6c267b..c4c0eab191 100644 --- a/recidiviz/case_triage/outliers/outliers_authorization.py +++ b/recidiviz/case_triage/outliers/outliers_authorization.py @@ -15,7 +15,6 @@ # along with this program. If not, see . # ============================================================================= """Implements authorization for Outliers routes""" -import datetime import os from http import HTTPStatus from typing import Any, Dict, Optional @@ -26,6 +25,7 @@ get_outliers_enabled_states, ) from recidiviz.case_triage.authorization_utils import ( + get_active_feature_variants, on_successful_authorization_requested_state, ) from recidiviz.case_triage.outliers.user_context import UserContext @@ -63,16 +63,7 @@ def on_successful_authorization( ) user_pseudonymized_id = app_metadata.get("pseudonymizedId", None) routes = app_metadata.get("routes", {}) - feature_variants = { - fv: params - for fv, params in app_metadata.get("featureVariants", {}).items() - if "activeDate" not in params - or datetime.datetime.fromisoformat(params["activeDate"]) - # Handle both naive and UTC activeDates - < datetime.datetime.now( - tz=datetime.datetime.fromisoformat(params["activeDate"]).tzinfo - ) - } + feature_variants = app_metadata.get("featureVariants", {}) if user_state_code == "RECIDIVIZ": feature_variants["supervisorHomepageWorkflows"] = {} @@ -83,7 +74,9 @@ def on_successful_authorization( can_access_all_supervisors=is_recidiviz_or_csg # TODO(Recidiviz/recidiviz-dashboards#4520): don't hard-code this string or routes.get("insights_supervision_supervisors-list", False), - feature_variants=feature_variants, + feature_variants=get_active_feature_variants( + feature_variants, user_pseudonymized_id + ), ) # If the user is a recidiviz user, skip endpoint checks diff --git a/recidiviz/case_triage/workflows/workflows_authorization.py b/recidiviz/case_triage/workflows/workflows_authorization.py index 8ac621e044..b7c93712ff 100644 --- a/recidiviz/case_triage/workflows/workflows_authorization.py +++ b/recidiviz/case_triage/workflows/workflows_authorization.py @@ -15,8 +15,6 @@ # along with this program. If not, see . # ============================================================================= """Implements user validations for workflows APIs. """ -import datetime -import logging import os from typing import Any, Dict, List @@ -26,6 +24,7 @@ get_workflows_enabled_states, ) from recidiviz.case_triage.authorization_utils import ( + get_active_feature_variants, on_successful_authorization_requested_state, ) from recidiviz.common.constants.states import StateCode @@ -66,26 +65,10 @@ def on_successful_authorization(claims: Dict[str, Any]) -> None: app_metadata = claims[f"{os.environ['AUTH0_CLAIM_NAMESPACE']}/app_metadata"] g.is_recidiviz_user = app_metadata["stateCode"].upper() == "RECIDIVIZ" - g.feature_variants = {} - for fv, params in app_metadata.get("featureVariants", {}).items(): - if isinstance(params, dict): - # Only include FVs with no date, or with a date that parses correctly & is in the past - if "activeDate" not in params or datetime.datetime.fromisoformat( - params["activeDate"] - ) < datetime.datetime.now( - tz=datetime.datetime.fromisoformat(params["activeDate"]).tzinfo - ): - g.feature_variants[fv] = params - elif params is True: - g.feature_variants[fv] = {} - elif params is not False and params is not None: - id_for_error = app_metadata.get("pseudonymizedId", "unknown") - logging.error( - "User with id %s has feature value %s with non-dict/bool value %s", - id_for_error, - fv, - params, - ) + g.feature_variants = get_active_feature_variants( + app_metadata.get("featureVariants", {}), + app_metadata.get("pseudonymizedId", None), + ) def get_workflows_external_request_enabled_states() -> List[str]: diff --git a/recidiviz/tests/case_triage/authorization_utils_test.py b/recidiviz/tests/case_triage/authorization_utils_test.py new file mode 100644 index 0000000000..d124f5a37b --- /dev/null +++ b/recidiviz/tests/case_triage/authorization_utils_test.py @@ -0,0 +1,91 @@ +# Recidiviz - a data platform for criminal justice reform +# Copyright (C) 2023 Recidiviz, Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# ============================================================================= +"""Implements tests for authorization utils.""" + +from unittest import TestCase + +import freezegun + +from recidiviz.case_triage.authorization_utils import get_active_feature_variants + +TEST_PSEUDONYMIZED_ID: str = "testestestbsapr749bwrb893b4389test" + +INPUT_FVS = { + "fvOne": {}, + "fvTwo": {"activeDate": "2022-01-01T01:01:01.000Z"}, + "fvThree": {"activeDate": "2022-01-01T01:01:01.000"}, + "fvFour": {"activeDate": "2022-01-01"}, + "fvFive": True, +} +EXPECTED_FVS = { + "fvOne": {}, + "fvTwo": {"activeDate": "2022-01-01T01:01:01.000Z"}, + "fvThree": {"activeDate": "2022-01-01T01:01:01.000"}, + "fvFour": {"activeDate": "2022-01-01"}, + "fvFive": {}, +} + + +class TestAuthorizationUtils(TestCase): + """_summary_ + Test authorization utils for case triage. + """ + + @freezegun.freeze_time("2022-12-30") + def test_feature_variant_parsing_included(self) -> None: + + self.assertDictEqual( + get_active_feature_variants(INPUT_FVS, TEST_PSEUDONYMIZED_ID), EXPECTED_FVS + ) + + @freezegun.freeze_time("2022-12-30") + def test_feature_variant_with_future_active_date(self) -> None: + input_fvs = {**INPUT_FVS, "fvSix": {"activeDate": "2023-01-01T01:01:01.000Z"}} + + self.assertDictEqual( + get_active_feature_variants(input_fvs, TEST_PSEUDONYMIZED_ID), EXPECTED_FVS + ) + + @freezegun.freeze_time("2022-12-30") + def test_feature_variant_with_bad_value(self) -> None: + # Define the additional feature variants as a separate dict + new_fvs = {"fvZero": 3, "fvSix": "dog"} + + # Merge new_fvs into a copy of INPUT_FVS + input_fvs = {**INPUT_FVS, **new_fvs} + + # Assert active feature variants match expected ones + self.assertDictEqual( + get_active_feature_variants(input_fvs, TEST_PSEUDONYMIZED_ID), EXPECTED_FVS + ) + + # Capture logs and verify content + with self.assertLogs(level="ERROR") as log: + get_active_feature_variants(input_fvs, TEST_PSEUDONYMIZED_ID) + + # Loop through new_fvs to check if each one is logged as expected + for key, value in new_fvs.items(): + self.assertTrue( + any( + all( + term in message + for term in [TEST_PSEUDONYMIZED_ID, key, str(value)] + ) + for message in log.output + ), + f"Expected {key} with value {value} in the log message, but it was not found.", + ) diff --git a/recidiviz/tests/case_triage/workflows/workflows_authorization_test.py b/recidiviz/tests/case_triage/workflows/workflows_authorization_test.py index 007daf01ba..9b5c7565d2 100644 --- a/recidiviz/tests/case_triage/workflows/workflows_authorization_test.py +++ b/recidiviz/tests/case_triage/workflows/workflows_authorization_test.py @@ -20,8 +20,7 @@ from unittest import TestCase, mock from unittest.mock import MagicMock -from flask import Flask, Response, make_response, g -import freezegun +from flask import Flask, Response, make_response from recidiviz.case_triage.workflows.workflows_authorization import ( on_successful_authorization, @@ -138,59 +137,3 @@ def test_on_successful_authorization(self, _mock_enabled_states: MagicMock) -> N "external_request/US_WY/enqueue_sms_request", user_state_code="US_WY" ) self.assertEqual(assertion.exception.code, "external_requests_not_enabled") - - @freezegun.freeze_time("2022-12-30") - def test_feature_variant_parsing_included(self) -> None: - input_fvs = { - "fvOne": {}, - "fvTwo": {"activeDate": "2022-01-01T01:01:01.000Z"}, - "fvThree": {"activeDate": "2022-01-01T01:01:01.000"}, - "fvFour": {"activeDate": "2022-01-01"}, - "fvFive": True, - } - - expected_fvs = { - "fvOne": {}, - "fvTwo": {"activeDate": "2022-01-01T01:01:01.000Z"}, - "fvThree": {"activeDate": "2022-01-01T01:01:01.000"}, - "fvFour": {"activeDate": "2022-01-01"}, - "fvFive": {}, - } - - with test_app.app_context(): # to access the Flask g object from within a test - self.assertIsNone( - self.process_claims( - "external_request/US_CA/enqueue_sms_request", - user_state_code="US_CA", - feature_variants=input_fvs, - ) - ) - fvs = g.get("feature_variants") - self.assertDictEqual(fvs, expected_fvs) - - @freezegun.freeze_time("2022-12-30") - def test_feature_variant_parsing_excluded(self) -> None: - with test_app.app_context(): # to access the Flask g object from within a test - self.assertIsNone( - self.process_claims( - "external_request/US_CA/enqueue_sms_request", - user_state_code="US_CA", - feature_variants={ - "fvShouldNotIncludeFalse": False, - "fvShouldNotIncludeNone": None, - "fvShouldNotIncludeFuture": {"activeDate": "2023-01-01"}, - }, - ) - ) - fvs = g.get("feature_variants") - self.assertDictEqual(fvs, {}) - - def test_feature_variant_parsing_incorrect_date(self) -> None: - with self.assertRaises(ValueError): - self.process_claims( - "external_request/US_CA/enqueue_sms_request", - user_state_code="US_CA", - feature_variants={ - "fvOne": {"activeDate": "garbagio, not a real datetime"}, - }, - )