diff --git a/app/schemas/workflows.py b/app/schemas/workflows.py index 4cd5771..f866266 100644 --- a/app/schemas/workflows.py +++ b/app/schemas/workflows.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import re from datetime import datetime from enum import StrEnum @@ -120,6 +121,18 @@ class WorkflowUserDetails(BaseModel): user_email: str = Field(..., description="Email address of the user") ip_address: str = Field(..., description="IP address of the user") + def get_encoded_account_details(self) -> str: + """Return the `-A` account string for cluster job submission. + + Encodes email and IP as base64 (colon-joined) so the account string + never leaks the user's raw email/IP into cluster logs or job metadata. + """ + encoded_email = base64.b64encode(self.user_email.encode()).decode() + if not self.ip_address: + return encoded_email + encoded_ip = base64.b64encode(self.ip_address.encode()).decode() + return f"{encoded_email}:{encoded_ip}" + class WispsFormData(WorkflowFormData): """Form data for WISPS workflows (interaction-screening, bulk-prediction).""" diff --git a/app/services/bindflow_config.py b/app/services/bindflow_config.py index 359342f..8f831d5 100644 --- a/app/services/bindflow_config.py +++ b/app/services/bindflow_config.py @@ -5,7 +5,7 @@ from typing import Any from ..schemas.workflows import WorkflowUserDetails -from .cluster_utils import GADI_PROJECT, encode_ip +from .cluster_utils import GADI_PROJECT from .workflow_config_fetcher import fetch_workflow_config @@ -31,11 +31,6 @@ def get_bindflow_config_text( """Read bindflow base config and append a process override block with runtime values.""" base = fetch_workflow_config(config_file_path) - account = ( - f"{user_details.user_email}:{encode_ip(user_details.ip_address)}" - if user_details.ip_address - else user_details.user_email - ) - cluster_opts = f"-A {account}" + cluster_opts = f"-A {user_details.get_encoded_account_details()}" override = f'\nprocess {{\n clusterOptions = "{cluster_opts}"\n}}\n' return base + override diff --git a/app/services/cluster_utils.py b/app/services/cluster_utils.py index 6d0af78..b7d3da2 100644 --- a/app/services/cluster_utils.py +++ b/app/services/cluster_utils.py @@ -2,12 +2,6 @@ from __future__ import annotations -import base64 import os GADI_PROJECT: str = os.getenv("GADI_PROJECT", "yz52") - - -def encode_ip(ip_address: str) -> str: - """Return the base64 encoding of an IP address string.""" - return base64.b64encode(ip_address.encode()).decode() diff --git a/app/services/proteindj_config.py b/app/services/proteindj_config.py index bc83991..1f73557 100644 --- a/app/services/proteindj_config.py +++ b/app/services/proteindj_config.py @@ -5,7 +5,6 @@ from typing import Any from ..schemas.workflows import WorkflowUserDetails -from .cluster_utils import encode_ip from .workflow_config_fetcher import fetch_workflow_config @@ -43,11 +42,6 @@ def get_proteindj_config_text( """Read proteindj base config and append a process override block with runtime values.""" base = fetch_workflow_config(config_file_path) - account = ( - f"{user_details.user_email}:{encode_ip(user_details.ip_address)}" - if user_details.ip_address - else user_details.user_email - ) - cluster_opts = f"-A {account}" + cluster_opts = f"-A {user_details.get_encoded_account_details()}" override = f'\nprocess {{\n clusterOptions = "{cluster_opts}"\n}}\n' return base + override diff --git a/app/services/proteinfold_config.py b/app/services/proteinfold_config.py index 4241c49..b836a2a 100644 --- a/app/services/proteinfold_config.py +++ b/app/services/proteinfold_config.py @@ -5,7 +5,7 @@ from typing import Any from ..schemas.workflows import WorkflowUserDetails -from .cluster_utils import GADI_PROJECT, encode_ip +from .cluster_utils import GADI_PROJECT from .workflow_config_fetcher import fetch_workflow_config @@ -29,11 +29,6 @@ def get_proteinfold_config_text( """Read proteinfold base config and append a process override block with runtime values.""" base = fetch_workflow_config(config_file_path) - account = ( - f"{user_details.user_email}:{encode_ip(user_details.ip_address)}" - if user_details.ip_address - else user_details.user_email - ) - cluster_opts = f"-P {GADI_PROJECT} -A {account}" + cluster_opts = f"-P {GADI_PROJECT} -A {user_details.get_encoded_account_details()}" override = f'\nprocess {{\n clusterOptions = "{cluster_opts}"\n}}\n' return base + override diff --git a/app/services/wisps_config.py b/app/services/wisps_config.py index 6f18c65..d265df5 100644 --- a/app/services/wisps_config.py +++ b/app/services/wisps_config.py @@ -5,7 +5,7 @@ from typing import Any, Literal from ..schemas.workflows import WorkflowUserDetails -from .cluster_utils import GADI_PROJECT, encode_ip +from .cluster_utils import GADI_PROJECT from .workflow_config_fetcher import fetch_workflow_config WispsMode = Literal["g1-g2", "manual"] @@ -49,11 +49,6 @@ def get_wisps_config_text( """ base = fetch_workflow_config(config_file_path) - account = ( - f"{user_details.user_email}:{encode_ip(user_details.ip_address)}" - if user_details.ip_address - else user_details.user_email - ) - cluster_opts = f"-P {GADI_PROJECT} -A {account}" + cluster_opts = f"-P {GADI_PROJECT} -A {user_details.get_encoded_account_details()}" override = f'\nprocess {{\n clusterOptions = "{cluster_opts}"\n}}\n' return base + override diff --git a/tests/test_proteindj_coverage.py b/tests/test_proteindj_coverage.py index 2c9efa5..1c863b1 100644 --- a/tests/test_proteindj_coverage.py +++ b/tests/test_proteindj_coverage.py @@ -221,20 +221,20 @@ def test_get_proteindj_config_text_appends_process_block(): assert "clusterOptions" in result -def test_get_proteindj_config_text_contains_email_and_encoded_ip(): +def test_get_proteindj_config_text_contains_encoded_email_and_encoded_ip(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_proteindj_config_text("/fake/proteindj.config", user_details=_USER_DETAILS) - assert "user@ex.com" in result + assert "dXNlckBleC5jb20=" in result assert "MS4yLjMuNA==" in result -def test_get_proteindj_config_text_without_ip_uses_email_only(): +def test_get_proteindj_config_text_without_ip_uses_encoded_email_only(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_proteindj_config_text( "/fake/proteindj.config", user_details=_USER_DETAILS.model_copy(update={"ip_address": ""}), ) - assert "-A user@ex.com" in result + assert "-A dXNlckBleC5jb20=" in result assert ":" not in result.split("clusterOptions = ")[1] diff --git a/tests/test_proteinfold_coverage.py b/tests/test_proteinfold_coverage.py index b96a77b..e8918a2 100644 --- a/tests/test_proteinfold_coverage.py +++ b/tests/test_proteinfold_coverage.py @@ -547,23 +547,23 @@ def test_get_proteinfold_config_text_appends_process_block(): assert "clusterOptions" in result -def test_get_proteinfold_config_text_contains_email_and_encoded_ip(): +def test_get_proteinfold_config_text_contains_encoded_email_and_encoded_ip(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_proteinfold_config_text( "/fake/proteinfold.config", user_details=_USER_DETAILS, ) - assert "user@ex.com" in result + assert "dXNlckBleC5jb20=" in result assert "MS4yLjMuNA==" in result -def test_get_proteinfold_config_text_without_ip_uses_email_only(): +def test_get_proteinfold_config_text_without_ip_uses_encoded_email_only(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_proteinfold_config_text( "/fake/proteinfold.config", user_details=_USER_DETAILS.model_copy(update={"ip_address": ""}), ) - assert "-A user@ex.com" in result + assert "-A dXNlckBleC5jb20=" in result assert ":" not in result.split("clusterOptions = ")[1] diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 3cff85f..a7a5a80 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -24,6 +24,7 @@ WorkflowLaunchForm, WorkflowLaunchPayload, WorkflowLaunchResponse, + WorkflowUserDetails, map_pipeline_status_to_ui, single_prediction_size_limit, validate_single_prediction_entities, @@ -467,6 +468,16 @@ def test_interaction_screening_request_extra_fields_forbidden(): ) +def test_get_encoded_account_details_encodes_email_and_ip(): + details = WorkflowUserDetails(user_email="user@example.com", ip_address="1.2.3.4") + assert details.get_encoded_account_details() == "dXNlckBleGFtcGxlLmNvbQ==:MS4yLjMuNA==" + + +def test_get_encoded_account_details_without_ip_encodes_email_only(): + details = WorkflowUserDetails(user_email="user@example.com", ip_address="") + assert details.get_encoded_account_details() == "dXNlckBleGFtcGxlLmNvbQ==" + + # ============================================================================= # Single-prediction entity validation # ============================================================================= diff --git a/tests/test_services_bindflow_config.py b/tests/test_services_bindflow_config.py index 95935c0..f1f69d3 100644 --- a/tests/test_services_bindflow_config.py +++ b/tests/test_services_bindflow_config.py @@ -145,7 +145,7 @@ def test_get_bindflow_config_text_interpolates_email(): "/fake/bindflow.config", user_details=_user_details("alice@example.com"), ) - assert "-A alice@example.com" in result + assert "-A YWxpY2VAZXhhbXBsZS5jb20=" in result def test_get_bindflow_config_text_without_ip_address_omits_encoding(): @@ -154,7 +154,7 @@ def test_get_bindflow_config_text_without_ip_address_omits_encoding(): "/fake/bindflow.config", user_details=_user_details("user@example.com"), ) - assert "-A user@example.com" in result + assert "-A dXNlckBleGFtcGxlLmNvbQ==" in result assert ":" not in result.split("clusterOptions = ")[1] @@ -164,7 +164,7 @@ def test_get_bindflow_config_text_with_ip_address_appends_encoded_ip(): "/fake/bindflow.config", user_details=_user_details("user@example.com", ip_address="1.2.3.4"), ) - assert "-A user@example.com:MS4yLjMuNA==" in result + assert "-A dXNlckBleGFtcGxlLmNvbQ==:MS4yLjMuNA==" in result def test_get_bindflow_config_text_url_fetching(): diff --git a/tests/test_services_cluster_utils.py b/tests/test_services_cluster_utils.py index fad9453..f4fc80c 100644 --- a/tests/test_services_cluster_utils.py +++ b/tests/test_services_cluster_utils.py @@ -3,24 +3,9 @@ # pylint: disable=missing-function-docstring from __future__ import annotations -import base64 import importlib from app.services import cluster_utils -from app.services.cluster_utils import encode_ip - - -def test_encode_ip_returns_base64_of_ip_string(): - assert encode_ip("1.2.3.4") == base64.b64encode(b"1.2.3.4").decode() - - -def test_encode_ip_empty_string(): - assert encode_ip("") == "" - - -def test_encode_ip_ipv6_address(): - ip = "2001:db8::1" - assert encode_ip(ip) == base64.b64encode(ip.encode()).decode() def test_gadi_project_defaults_to_yz52(monkeypatch): diff --git a/tests/test_wisps_coverage.py b/tests/test_wisps_coverage.py index f842c61..dedf83a 100644 --- a/tests/test_wisps_coverage.py +++ b/tests/test_wisps_coverage.py @@ -262,23 +262,23 @@ def test_get_wisps_config_text_appends_process_block(): assert "clusterOptions" in result -def test_get_wisps_config_text_contains_email_and_encoded_ip(): +def test_get_wisps_config_text_contains_encoded_email_and_encoded_ip(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_wisps_config_text( config_file_path="/fake/path.config", user_details=_USER_DETAILS, ) - assert "user@ex.com" in result + assert "dXNlckBleC5jb20=" in result assert "MS4yLjMuNA==" in result -def test_get_wisps_config_text_without_ip_uses_email_only(): +def test_get_wisps_config_text_without_ip_uses_encoded_email_only(): with patch("builtins.open", mock_open(read_data="base_config")): result = get_wisps_config_text( config_file_path="/fake/path.config", user_details=_USER_DETAILS.model_copy(update={"ip_address": ""}), ) - assert "-A user@ex.com" in result + assert "-A dXNlckBleC5jb20=" in result assert ":" not in result.split("clusterOptions = ")[1]