Skip to content

Commit

Permalink
[Insights][Auth] Have (workflows | outliers)_authorization.py acc…
Browse files Browse the repository at this point in the history
…ept feature variants set as `True` or `{}` (Recidiviz/recidiviz-data#34287)

GitOrigin-RevId: f1062bf99ae757be6a9e97fa33e794ab37c0cae8
  • Loading branch information
nojibe authored and Helper Bot committed Nov 2, 2024
1 parent 08c0e25 commit 7648ed0
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 92 deletions.
44 changes: 44 additions & 0 deletions recidiviz/case_triage/authorization_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
17 changes: 5 additions & 12 deletions recidiviz/case_triage/outliers/outliers_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# =============================================================================
"""Implements authorization for Outliers routes"""
import datetime
import os
from http import HTTPStatus
from typing import Any, Dict, Optional
Expand All @@ -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
Expand Down Expand Up @@ -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"] = {}

Expand All @@ -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
Expand Down
27 changes: 5 additions & 22 deletions recidiviz/case_triage/workflows/workflows_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# =============================================================================
"""Implements user validations for workflows APIs. """
import datetime
import logging
import os
from typing import Any, Dict, List

Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
91 changes: 91 additions & 0 deletions recidiviz/tests/case_triage/authorization_utils_test.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
# =============================================================================
"""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.",
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"},
},
)

0 comments on commit 7648ed0

Please sign in to comment.