diff --git a/clients/admin-ui/src/features/manual-tasks/components/TaskDetails.tsx b/clients/admin-ui/src/features/manual-tasks/components/TaskDetails.tsx index ac94509df23..f7e56f86e52 100644 --- a/clients/admin-ui/src/features/manual-tasks/components/TaskDetails.tsx +++ b/clients/admin-ui/src/features/manual-tasks/components/TaskDetails.tsx @@ -28,11 +28,13 @@ const TaskInfoRow = ({ ); export const TaskDetails = ({ task }: TaskDetailsProps) => { - // Map the request type using the existing map - const actionType = - task.request_type === ManualFieldRequestType.ACCESS - ? ActionType.ACCESS - : ActionType.ERASURE; + // Map the request type to ActionType for display + const requestTypeToActionType: Record = { + [ManualFieldRequestType.ACCESS]: ActionType.ACCESS, + [ManualFieldRequestType.ERASURE]: ActionType.ERASURE, + [ManualFieldRequestType.CONSENT]: ActionType.CONSENT, + }; + const actionType = requestTypeToActionType[task.request_type]; const requestTypeDisplay = SubjectRequestActionTypeMap.get(actionType) || task.request_type; diff --git a/clients/admin-ui/src/features/manual-tasks/constants.ts b/clients/admin-ui/src/features/manual-tasks/constants.ts index 372491f0f18..9e2b6a0a3bc 100644 --- a/clients/admin-ui/src/features/manual-tasks/constants.ts +++ b/clients/admin-ui/src/features/manual-tasks/constants.ts @@ -31,4 +31,5 @@ export const STATUS_FILTER_OPTIONS = [ export const REQUEST_TYPE_FILTER_OPTIONS = [ { text: "Access", value: ManualFieldRequestType.ACCESS }, { text: "Erasure", value: ManualFieldRequestType.ERASURE }, + { text: "Consent", value: ManualFieldRequestType.CONSENT }, ]; diff --git a/clients/admin-ui/src/features/manual-tasks/hooks/useManualTaskColumns.tsx b/clients/admin-ui/src/features/manual-tasks/hooks/useManualTaskColumns.tsx index a3d05e3a30c..d4253b34b5c 100644 --- a/clients/admin-ui/src/features/manual-tasks/hooks/useManualTaskColumns.tsx +++ b/clients/admin-ui/src/features/manual-tasks/hooks/useManualTaskColumns.tsx @@ -97,10 +97,14 @@ export const useManualTaskColumns = ({ key: "request_type", width: 80, render: (type: ManualFieldRequestType) => { - const actionType = - type === ManualFieldRequestType.ACCESS - ? ActionType.ACCESS - : ActionType.ERASURE; + // Map request type to ActionType for display + const requestTypeToActionType: Record = + { + [ManualFieldRequestType.ACCESS]: ActionType.ACCESS, + [ManualFieldRequestType.ERASURE]: ActionType.ERASURE, + [ManualFieldRequestType.CONSENT]: ActionType.CONSENT, + }; + const actionType = requestTypeToActionType[type]; const displayName = SubjectRequestActionTypeMap.get(actionType) || type; return ( diff --git a/clients/admin-ui/src/types/api/models/ManualFieldRequestType.ts b/clients/admin-ui/src/types/api/models/ManualFieldRequestType.ts index 361ec556570..e0aa868e84b 100644 --- a/clients/admin-ui/src/types/api/models/ManualFieldRequestType.ts +++ b/clients/admin-ui/src/types/api/models/ManualFieldRequestType.ts @@ -8,4 +8,5 @@ export enum ManualFieldRequestType { ACCESS = "access", ERASURE = "erasure", + CONSENT = "consent", } diff --git a/src/fides/api/service/privacy_request/request_runner_service.py b/src/fides/api/service/privacy_request/request_runner_service.py index 54cf527e5e5..eea4ba88100 100644 --- a/src/fides/api/service/privacy_request/request_runner_service.py +++ b/src/fides/api/service/privacy_request/request_runner_service.py @@ -514,7 +514,10 @@ def run_privacy_request( ] # Add manual task artificial graphs to dataset graphs - manual_task_graphs = create_manual_task_artificial_graphs(session) + # Only include manual tasks with access or erasure configs + manual_task_graphs = create_manual_task_artificial_graphs( + session, config_types=[ActionType.access, ActionType.erasure] + ) dataset_graphs.extend(manual_task_graphs) dataset_graph = DatasetGraph(*dataset_graphs) @@ -643,7 +646,7 @@ def run_privacy_request( consent_runner( privacy_request=privacy_request, policy=policy, - graph=build_consent_dataset_graph(datasets), + graph=build_consent_dataset_graph(datasets, session), connection_configs=connection_configs, identity=identity_data, session=session, diff --git a/src/fides/api/task/conditional_dependencies/privacy_request/schemas.py b/src/fides/api/task/conditional_dependencies/privacy_request/schemas.py index a020aff4ccd..5e97c9a4107 100644 --- a/src/fides/api/task/conditional_dependencies/privacy_request/schemas.py +++ b/src/fides/api/task/conditional_dependencies/privacy_request/schemas.py @@ -51,6 +51,22 @@ class PrivacyRequestConvenienceFields(Enum): location_regulations = f"{PrivacyRequestTopLevelFields.privacy_request.value}.{PrivacyRequestLocationConvenienceFields.location_regulations.value}" +class ConsentPrivacyRequestConvenienceFields(Enum): + """Convenience fields available for consent privacy request conditions. + + """ + + # Policy convenience fields (all available for consent) + rule_action_types = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.rule_action_types.value}" + has_access_rule = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.has_access_rule.value}" + has_erasure_rule = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.has_erasure_rule.value}" + has_consent_rule = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.has_consent_rule.value}" + has_update_rule = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.has_update_rule.value}" + rule_count = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.rule_count.value}" + rule_names = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.rule_names.value}" + has_storage_destination = f"{PrivacyRequestTopLevelFields.policy.value}.{PrivacyRequestPolicyConvenienceFields.has_storage_destination.value}" + + class PrivacyRequestFields(Enum): """Fields for privacy request.""" @@ -66,6 +82,21 @@ class PrivacyRequestFields(Enum): submitted_by = f"{PrivacyRequestTopLevelFields.privacy_request.value}.submitted_by" +class ConsentPrivacyRequestFields(Enum): + """Fields available for consent privacy request conditions. + + """ + + created_at = f"{PrivacyRequestTopLevelFields.privacy_request.value}.created_at" + identity_verified_at = ( + f"{PrivacyRequestTopLevelFields.privacy_request.value}.identity_verified_at" + ) + origin = f"{PrivacyRequestTopLevelFields.privacy_request.value}.origin" + requested_at = f"{PrivacyRequestTopLevelFields.privacy_request.value}.requested_at" + source = f"{PrivacyRequestTopLevelFields.privacy_request.value}.source" + submitted_by = f"{PrivacyRequestTopLevelFields.privacy_request.value}.submitted_by" + + class PolicyFields(Enum): """Fields for policy.""" @@ -79,6 +110,18 @@ class PolicyFields(Enum): rules = f"{PrivacyRequestTopLevelFields.policy.value}.rules" +class ConsentPolicyFields(Enum): + """Policy fields available for consent privacy request conditions. + + """ + + id = "privacy_request.policy.id" + name = f"{PrivacyRequestTopLevelFields.policy.value}.name" + key = f"{PrivacyRequestTopLevelFields.policy.value}.key" + description = f"{PrivacyRequestTopLevelFields.policy.value}.description" + rules = f"{PrivacyRequestTopLevelFields.policy.value}.rules" + + class IdentityFields(Enum): """Fields for identity.""" @@ -152,6 +195,61 @@ def get_custom_field_name(cls, field_path: str) -> Optional[str]: str, # Custom field paths (must match prefix pattern, validated below) ] +# Union type for consent-specific field paths (subset of ConditionalDependencyFieldPath) +ConsentConditionalDependencyFieldPath = Union[ + ConsentPrivacyRequestFields, + ConsentPolicyFields, + IdentityFields, # All identity fields are available for consent + ConsentPrivacyRequestConvenienceFields, + str, # Custom field paths (still supported for consent) +] + + +# Fields that are NOT available for consent requests +# Used for generating helpful error messages when conditions reference unavailable fields +CONSENT_UNAVAILABLE_FIELDS: set[str] = { + # Direct fields + PrivacyRequestFields.due_date.value, + PrivacyRequestFields.location.value, + PolicyFields.execution_timeframe.value, + # Location convenience fields + PrivacyRequestConvenienceFields.location_country.value, + PrivacyRequestConvenienceFields.location_groups.value, + PrivacyRequestConvenienceFields.location_regulations.value, +} + + +def get_consent_unavailable_field_message(field_path: str) -> Optional[str]: + """Get a human-readable message explaining why a field is unavailable for consent. + + Args: + field_path: The field path that is unavailable + + Returns: + A message explaining why the field is unavailable, or None if the field is available + """ + field_messages = { + PrivacyRequestFields.due_date.value: "due_date is not available for consent requests (no execution timeframe)", + PrivacyRequestFields.location.value: "location is not captured in the consent request workflow", + PolicyFields.execution_timeframe.value: "execution_timeframe is not applicable to consent requests", + PrivacyRequestConvenienceFields.location_country.value: "location_country is not available (location not captured for consent)", + PrivacyRequestConvenienceFields.location_groups.value: "location_groups is not available (location not captured for consent)", + PrivacyRequestConvenienceFields.location_regulations.value: "location_regulations is not available (location not captured for consent)", + } + return field_messages.get(field_path) + + +def is_field_available_for_consent(field_path: str) -> bool: + """Check if a field is available for consent request conditions. + + Args: + field_path: The field path to check + + Returns: + True if the field is available for consent, False otherwise + """ + return field_path not in CONSENT_UNAVAILABLE_FIELDS + class ConditionalDependencyFieldInfo(BaseModel): """Information about a field available for conditional dependencies.""" diff --git a/src/fides/api/task/graph_task.py b/src/fides/api/task/graph_task.py index 7f2efbd4c02..b4265aa35f0 100644 --- a/src/fides/api/task/graph_task.py +++ b/src/fides/api/task/graph_task.py @@ -47,6 +47,7 @@ from fides.api.service.execution_context import collect_execution_log_messages from fides.api.task.consolidate_query_matches import consolidate_query_matches from fides.api.task.filter_element_match import filter_element_match +from fides.api.task.manual.manual_task_utils import create_manual_task_artificial_graphs from fides.api.task.refine_target_path import FieldPathNodeInput from fides.api.task.scheduler_utils import use_dsr_3_0_scheduler from fides.api.task.task_resources import TaskResources @@ -282,8 +283,17 @@ def generate_dry_run_query(self) -> Optional[str]: return self.connector.dry_run_query(self.execution_node) def can_write_data(self) -> bool: - """Checks if the relevant ConnectionConfig has been granted "write" access to its data""" + """Checks if the relevant ConnectionConfig has been granted "write" access to its data. + + Manual task connections always return True since they don't actually write to + external systems - humans manually record/confirm actions instead. + """ connection_config: ConnectionConfig = self.connector.configuration + # Manual tasks don't connect to external systems, so the write access + # concept doesn't apply. Humans manually record erasure confirmations + # or consent preferences. + if connection_config.connection_type == ConnectionType.manual_task: + return True return connection_config.access == AccessLevel.write def _combine_seed_data( @@ -1042,11 +1052,22 @@ def build_affected_field_logs( return ret -def build_consent_dataset_graph(datasets: List[DatasetConfig]) -> DatasetGraph: +def build_consent_dataset_graph( + datasets: List[DatasetConfig], session: Optional[Session] = None +) -> DatasetGraph: """ Build the starting DatasetGraph for consent requests. - Consent Graph has one node per dataset. Nodes must be of saas type and have consent requests defined. + Consent Graph has one node per dataset. Nodes must be of saas type and have consent + requests defined, or be manual tasks with consent configurations. + + Args: + datasets: List of DatasetConfig objects to build the graph from + session: Optional database session for loading manual task graphs. + If provided, manual tasks with consent configs will be included. + + Returns: + DatasetGraph containing all consent-capable nodes """ consent_datasets: List[GraphDataset] = [] @@ -1065,4 +1086,11 @@ def build_consent_dataset_graph(datasets: List[DatasetConfig]) -> DatasetGraph: dataset_config.get_dataset_with_stubbed_collection() ) + # Add manual task graphs if session is provided + if session: + manual_task_graphs = create_manual_task_artificial_graphs( + session, config_types=[ActionType.consent] + ) + consent_datasets.extend(manual_task_graphs) + return DatasetGraph(*consent_datasets) diff --git a/src/fides/api/task/manual/manual_task_conditional_evaluation.py b/src/fides/api/task/manual/manual_task_conditional_evaluation.py index 241b5fa7c9a..a43eb87c993 100644 --- a/src/fides/api/task/manual/manual_task_conditional_evaluation.py +++ b/src/fides/api/task/manual/manual_task_conditional_evaluation.py @@ -1,5 +1,6 @@ from typing import Any, Optional, cast +from loguru import logger from pydantic.v1.utils import deep_update from sqlalchemy.orm import Session @@ -13,7 +14,16 @@ from fides.api.task.conditional_dependencies.privacy_request.privacy_request_data import ( PrivacyRequestDataTransformer, ) -from fides.api.task.conditional_dependencies.schemas import EvaluationResult +from fides.api.task.conditional_dependencies.privacy_request.schemas import ( + CONSENT_UNAVAILABLE_FIELDS, + get_consent_unavailable_field_message, +) +from fides.api.task.conditional_dependencies.schemas import ( + Condition, + ConditionGroup, + ConditionLeaf, + EvaluationResult, +) from fides.api.task.conditional_dependencies.util import extract_nested_field_value from fides.api.task.manual.manual_task_utils import extract_field_addresses from fides.api.util.collection_util import Row @@ -58,6 +68,42 @@ def has_non_privacy_request_conditions(manual_task: ManualTask) -> bool: return any(not addr.startswith("privacy_request.") for addr in all_field_addresses) +def get_consent_unavailable_conditions( + manual_task: ManualTask, +) -> list[tuple[str, str]]: + """ + Get a list of conditional dependency fields that are not available for consent requests. + + This is useful for validation and providing helpful error messages when configuring + consent manual tasks. + + Args: + manual_task: The manual task to check + + Returns: + List of (field_path, message) tuples for unavailable fields + """ + all_field_addresses = get_all_field_addresses_from_manual_task(manual_task) + unavailable: list[tuple[str, str]] = [] + + for addr in all_field_addresses: + # Check for dataset fields (non-privacy_request) + if not addr.startswith("privacy_request."): + unavailable.append( + ( + addr, + f"{addr} is a dataset field (not available for consent requests)", + ) + ) + # Check for unavailable privacy_request fields + elif addr in CONSENT_UNAVAILABLE_FIELDS: + message = get_consent_unavailable_field_message(addr) + if message: + unavailable.append((addr, message)) + + return unavailable + + def extract_privacy_request_only_conditional_data( field_addresses: set[str], privacy_request: PrivacyRequest, @@ -222,8 +268,118 @@ def set_nested_value(field_address: str, value: Any) -> dict[str, Any]: return {field_address: value} +class ConsentConditionFilterResult: + """Result of filtering conditions for consent evaluation.""" + + def __init__(self) -> None: + self.filtered_condition: Optional[Condition] = None + self.skipped_dataset_fields: list[str] = [] + self.unavailable_privacy_request_fields: list[tuple[str, str]] = ( + [] + ) # (field, message) pairs + + @property + def has_skipped_conditions(self) -> bool: + return len(self.skipped_dataset_fields) > 0 + + @property + def has_unavailable_fields(self) -> bool: + return len(self.unavailable_privacy_request_fields) > 0 + + def get_skip_message(self) -> str: + """Get a human-readable message about skipped conditions.""" + if not self.skipped_dataset_fields: + return "" + fields = ", ".join(self.skipped_dataset_fields) + return ( + f"Note: {len(self.skipped_dataset_fields)} condition(s) referencing dataset fields " + f"were skipped for consent evaluation (consent requests don't have data flow): {fields}" + ) + + def get_unavailable_fields_message(self) -> str: + """Get a human-readable message about unavailable privacy request fields.""" + if not self.unavailable_privacy_request_fields: + return "" + messages = [msg for _, msg in self.unavailable_privacy_request_fields] + return ( + f"Warning: {len(self.unavailable_privacy_request_fields)} condition(s) reference " + f"privacy request fields not available for consent: {'; '.join(messages)}" + ) + + +def _filter_condition_tree_for_privacy_request_only( + condition: Condition, + filter_result: Optional[ConsentConditionFilterResult] = None, +) -> Optional[Condition]: + """ + Filter a condition tree to only include privacy_request.* conditions + that are available for consent requests. + + For consent tasks: + - Dataset field conditions are removed (consent DSRs don't have data flow) + - Privacy request fields not captured for consent are tracked with warnings + (e.g., due_date, location_country) + + Args: + condition: The condition (ConditionLeaf or ConditionGroup) to filter + filter_result: Optional result object to track what was filtered + + Returns: + Filtered condition with only available privacy_request.* conditions, + or None if no conditions remain after filtering + """ + if isinstance(condition, ConditionLeaf): + field_address = condition.field_address + + # Check if it's a privacy_request field + if field_address.startswith("privacy_request."): + # Check if this specific field is available for consent + if field_address in CONSENT_UNAVAILABLE_FIELDS: + # Track unavailable privacy request fields with helpful message + if filter_result is not None: + message = get_consent_unavailable_field_message(field_address) + if message: + filter_result.unavailable_privacy_request_fields.append( + (field_address, message) + ) + return None + # Field is available for consent + return condition + + # Track skipped dataset field conditions + if filter_result is not None: + filter_result.skipped_dataset_fields.append(field_address) + return None + + # Filter all child conditions + filtered_conditions: list[Condition] = [] + for child in condition.conditions: + filtered_child = _filter_condition_tree_for_privacy_request_only( + child, filter_result + ) + if filtered_child is not None: + filtered_conditions.append(filtered_child) + + # If no conditions remain after filtering, return None + if not filtered_conditions: + return None + + # If only one condition remains, return it directly (no need for group) + if len(filtered_conditions) == 1: + return filtered_conditions[0] + + # Return a new group with filtered conditions + return ConditionGroup( + logical_operator=condition.logical_operator, + conditions=filtered_conditions, + ) + + def evaluate_conditional_dependencies( - db: Session, manual_task: ManualTask, conditional_data: dict[str, Any] + db: Session, + manual_task: ManualTask, + conditional_data: dict[str, Any], + privacy_request_only: bool = False, ) -> Optional[EvaluationResult]: """ Evaluate conditional dependencies for a manual task using data from regular tasks. @@ -234,6 +390,8 @@ def evaluate_conditional_dependencies( Args: manual_task: The manual task to evaluate conditional_data: Data from regular tasks for conditional dependency fields + privacy_request_only: If True, only evaluate privacy_request.* conditions + (used for consent tasks which don't have dataset data flow) Returns: EvaluationResult object containing detailed information about which conditions @@ -248,6 +406,38 @@ def evaluate_conditional_dependencies( # No conditional dependencies - always execute return None + # For consent tasks, filter out dataset field conditions and unavailable fields + condition_tree: Optional[Condition] = root_condition + filter_result: Optional[ConsentConditionFilterResult] = None + if privacy_request_only: + filter_result = ConsentConditionFilterResult() + condition_tree = _filter_condition_tree_for_privacy_request_only( + root_condition, filter_result + ) + # Log skipped dataset conditions for visibility + if filter_result.has_skipped_conditions: + logger.info(filter_result.get_skip_message()) + + # Warn about unavailable privacy request fields + if filter_result.has_unavailable_fields: + logger.warning(filter_result.get_unavailable_fields_message()) + + if condition_tree is None: + # No evaluable conditions remain - always execute + if filter_result.has_unavailable_fields: + logger.info( + "All conditions for consent manual task referenced unavailable fields " + "and were skipped. Task will execute unconditionally." + ) + else: + logger.info( + "All conditions for consent manual task referenced dataset fields " + "and were skipped. Task will execute unconditionally." + ) + return None + # Evaluate the condition using the data from regular tasks + # At this point condition_tree cannot be None (early return above handles that case) + assert condition_tree is not None evaluator = ConditionEvaluator(db) - return evaluator.evaluate_rule(root_condition, conditional_data) + return evaluator.evaluate_rule(condition_tree, conditional_data) diff --git a/src/fides/api/task/manual/manual_task_utils.py b/src/fides/api/task/manual/manual_task_utils.py index b7cf10081ea..1171e49d0a6 100644 --- a/src/fides/api/task/manual/manual_task_utils.py +++ b/src/fides/api/task/manual/manual_task_utils.py @@ -128,11 +128,35 @@ def create_conditional_dependency_scalar_fields( def _create_collection_from_manual_task( manual_task: ManualTask, + config_types: Optional[list[ActionType]] = None, ) -> Optional[Collection]: - """Create a Collection from a ManualTask. Helper function to avoid duplication.""" + """Create a Collection from a ManualTask. Helper function to avoid duplication. + + Args: + manual_task: The manual task to create a collection from + config_types: Optional list of config types to filter dependencies by. + For consent tasks, dataset field references are excluded since + consent DSRs don't have data flow through datasets. + """ + # Determine if we should exclude dataset field references + # Consent tasks don't have data flow, so they shouldn't reference dataset fields + is_consent_only = ( + config_types is not None + and len(config_types) == 1 + and ActionType.consent in config_types + ) + # Get conditional dependency field addresses from JSONB condition_tree conditional_field_addresses: set[str] = set() for dependency in manual_task.conditional_dependencies: + # Filter field-level dependencies by config type + if dependency.config_field_id is not None and config_types is not None: + # Get the config type for this field's config + config_field = dependency.config_field + if config_field and config_field.config: + if config_field.config.config_type not in config_types: + continue + tree = dependency.condition_tree if isinstance(tree, dict) or tree is None: field_addresses = set( @@ -140,6 +164,12 @@ def _create_collection_from_manual_task( for addr in extract_field_addresses(tree) if not addr.startswith("privacy_request.") ) + + # For consent-only tasks, skip dataset field references entirely + # since consent DSRs don't have data flow through datasets + if is_consent_only: + continue + conditional_field_addresses.update(field_addresses) # Create scalar fields for data category fields and conditional dependency field addresses @@ -168,7 +198,9 @@ def create_collection_for_connection_key( return _create_collection_from_manual_task(manual_task) -def create_manual_task_artificial_graphs(db: Session) -> list[GraphDataset]: +def create_manual_task_artificial_graphs( + db: Session, config_types: Optional[list[ActionType]] = None +) -> list[GraphDataset]: """ Create artificial GraphDataset objects for manual tasks that can be included in the main dataset graph during the dataset configuration phase. @@ -179,6 +211,9 @@ def create_manual_task_artificial_graphs(db: Session) -> list[GraphDataset]: Args: db: Database session + config_types: Optional list of ActionType values to filter manual tasks by. + Only tasks with configs matching these types will be included. + If None, all manual tasks are included. Returns: List of GraphDataset objects representing manual tasks as individual collections @@ -217,8 +252,19 @@ def create_manual_task_artificial_graphs(db: Session) -> list[GraphDataset]: if not manual_task: continue + # Filter by config_types if specified + if config_types is not None: + # Check if any config matches the requested types + has_matching_config = any( + config.config_type in config_types for config in manual_task.configs + ) + if not has_matching_config: + continue + # Create collection using the helper function to avoid duplication - collection = _create_collection_from_manual_task(manual_task) + # Pass config_types to filter dependencies appropriately (e.g., consent tasks + # should not include dataset field references since they don't have data flow) + collection = _create_collection_from_manual_task(manual_task, config_types) if not collection: continue diff --git a/tests/api/task/manual/test_manual_task_utils.py b/tests/api/task/manual/test_manual_task_utils.py index 7e8181f8c1a..f2b8c7756d7 100644 --- a/tests/api/task/manual/test_manual_task_utils.py +++ b/tests/api/task/manual/test_manual_task_utils.py @@ -11,10 +11,11 @@ ConnectionConfig, ConnectionType, ) -from fides.api.models.manual_task import ManualTask +from fides.api.models.manual_task import ManualTask, ManualTaskConfig from fides.api.models.manual_task.conditional_dependency import ( ManualTaskConditionalDependency, ) +from fides.api.schemas.policy import ActionType from fides.api.task.manual.manual_task_address import ManualTaskAddress from fides.api.task.manual.manual_task_utils import ( create_collection_for_connection_key, @@ -457,3 +458,113 @@ def test_multiple_manual_tasks_get_separate_collections( # Clean up second_manual_task.delete(db) second_connection_config.delete(db) + + +class TestConsentManualTaskUtils: + """Tests for consent-specific manual task utility functions""" + + def test_get_manual_task_addresses_with_consent_returns_empty_when_no_consent_configs( + self, db: Session, connection_with_manual_access_task + ): + """Test that get_manual_task_addresses with consent filter returns empty when no consent configs exist""" + # connection_with_manual_access_task has only access configs + addresses = get_manual_task_addresses(db, config_types=[ActionType.consent]) + + # Should not include the access-only manual task + assert len(addresses) == 0 + + def test_get_manual_task_addresses_with_consent_returns_addresses_for_consent_configs( + self, db: Session, connection_with_manual_access_task + ): + """Test that get_manual_task_addresses with consent filter returns addresses for manual tasks with consent configs""" + connection_config, manual_task, _, _ = connection_with_manual_access_task + + # Create a consent config for the manual task + consent_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.consent, + "is_current": True, + }, + ) + + try: + addresses = get_manual_task_addresses(db, config_types=[ActionType.consent]) + + # Should include the manual task with consent config + assert len(addresses) == 1 + assert addresses[0].dataset == connection_config.key + assert addresses[0].collection == "manual_data" + finally: + consent_config.delete(db) + + def test_get_manual_task_addresses_with_consent_excludes_non_current_configs( + self, db: Session, connection_with_manual_access_task + ): + """Test that get_manual_task_addresses with consent filter excludes manual tasks with only non-current consent configs""" + connection_config, manual_task, _, _ = connection_with_manual_access_task + + # Create a non-current consent config + consent_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.consent, + "is_current": False, # Not current + }, + ) + + try: + addresses = get_manual_task_addresses(db, config_types=[ActionType.consent]) + + # Should not include the manual task with non-current consent config + assert len(addresses) == 0 + finally: + consent_config.delete(db) + + def test_create_manual_task_artificial_graphs_with_consent_returns_empty_when_no_consent_configs( + self, db: Session, connection_with_manual_access_task + ): + """Test that create_manual_task_artificial_graphs with consent filter returns empty when no consent configs exist""" + # connection_with_manual_access_task has only access configs + graphs = create_manual_task_artificial_graphs( + db, config_types=[ActionType.consent] + ) + + # Should not include any graphs + assert len(graphs) == 0 + + def test_create_manual_task_artificial_graphs_with_consent_creates_graphs_for_consent_configs( + self, db: Session, connection_with_manual_access_task + ): + """Test that create_manual_task_artificial_graphs with consent filter creates GraphDatasets for consent manual tasks""" + connection_config, manual_task, _, _ = connection_with_manual_access_task + + # Create a consent config for the manual task + consent_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.consent, + "is_current": True, + }, + ) + + try: + graphs = create_manual_task_artificial_graphs( + db, config_types=[ActionType.consent] + ) + + # Should have one graph for the consent manual task + assert len(graphs) == 1 + + graph = graphs[0] + assert graph.name == connection_config.key + assert graph.connection_key == connection_config.key + assert len(graph.collections) == 1 + + collection = graph.collections[0] + assert collection.name == "manual_data" + finally: + consent_config.delete(db) diff --git a/tests/ops/service/privacy_request/test_request_runner_service.py b/tests/ops/service/privacy_request/test_request_runner_service.py index 9d623d0ea8b..10be499f3cb 100644 --- a/tests/ops/service/privacy_request/test_request_runner_service.py +++ b/tests/ops/service/privacy_request/test_request_runner_service.py @@ -1,5 +1,6 @@ # pylint: disable=missing-docstring, redefined-outer-name import time +from datetime import datetime from io import BytesIO from typing import Any, Dict, List, Set from unittest import mock @@ -28,6 +29,16 @@ AttachmentType, ) from fides.api.models.datasetconfig import DatasetConfig +from fides.api.models.manual_task import ( + ManualTask, + ManualTaskConfig, + ManualTaskConfigField, + ManualTaskInstance, + ManualTaskSubmission, +) +from fides.api.models.manual_task.conditional_dependency import ( + ManualTaskConditionalDependency, +) from fides.api.models.manual_webhook import AccessManualWebhook from fides.api.models.policy import PolicyPostWebhook, PolicyPreWebhook from fides.api.models.privacy_request import ExecutionLog, PrivacyRequest @@ -1667,6 +1678,198 @@ def test_build_consent_dataset_graph( ] +class TestConsentManualTaskIntegration: + """Integration tests for consent manual tasks in the privacy request runner""" + + @pytest.mark.usefixtures("use_dsr_3_0") + def test_consent_request_with_manual_task_full_flow( + self, + db, + consent_policy, + connection_config, + run_privacy_request_task, + ): + """Test full consent flow: pause for input → submit → complete""" + + # Create manual task with consent config + manual_task = ManualTask.create( + db=db, + data={ + "task_type": "privacy_request", + "parent_entity_id": connection_config.id, + "parent_entity_type": "connection_config", + }, + ) + consent_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.consent, + "is_current": True, + }, + ) + # Must have at least one field for the manual task to be included in the graph + consent_field = ManualTaskConfigField.create( + db=db, + data={ + "task_id": manual_task.id, + "config_id": consent_config.id, + "field_key": "consent_confirmation", + "field_type": "text", + "field_metadata": { + "label": "Consent Confirmation", + "required": True, + "data_categories": ["user.consent"], + }, + }, + ) + + # Create privacy request with consent policy + privacy_request = PrivacyRequest.create( + db=db, + data={ + "requested_at": datetime.utcnow(), + "policy_id": consent_policy.id, + "status": PrivacyRequestStatus.pending, + }, + ) + privacy_request.cache_identity(Identity(email="test@example.com")) + + instance = None + submission = None + + # Step 1: Run privacy request - should pause for manual input + run_privacy_request_task.delay(privacy_request.id).get( + timeout=PRIVACY_REQUEST_TASK_TIMEOUT + ) + db.refresh(privacy_request) + + # Should require input because manual task needs submission + assert privacy_request.status == PrivacyRequestStatus.requires_input + + # Verify manual task instance was created + instance = ( + db.query(ManualTaskInstance) + .filter( + ManualTaskInstance.task_id == manual_task.id, + ManualTaskInstance.entity_id == privacy_request.id, + ) + .first() + ) + assert instance is not None, "ManualTaskInstance should be created" + assert instance.config.config_type == ActionType.consent + + # Step 2: Submit data for the manual task field + submission = ManualTaskSubmission.create( + db=db, + data={ + "task_id": manual_task.id, + "config_id": consent_config.id, + "field_id": consent_field.id, + "instance_id": instance.id, + "data": {"field_type": "text", "value": "consent_confirmed"}, + }, + ) + + # Step 3: Re-run privacy request - should complete + run_privacy_request_task.delay(privacy_request.id).get( + timeout=PRIVACY_REQUEST_TASK_TIMEOUT + ) + db.refresh(privacy_request) + + assert privacy_request.status == PrivacyRequestStatus.complete + + @pytest.mark.usefixtures("use_dsr_3_0") + def test_consent_request_with_privacy_request_condition( + self, + db, + consent_policy, + connection_config, + run_privacy_request_task, + ): + """Test consent manual task with privacy_request.* condition skips when condition is false""" + + # Create manual task with consent config + manual_task = ManualTask.create( + db=db, + data={ + "task_type": "privacy_request", + "parent_entity_id": connection_config.id, + "parent_entity_type": "connection_config", + }, + ) + consent_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.consent, + "is_current": True, + }, + ) + consent_field = ManualTaskConfigField.create( + db=db, + data={ + "task_id": manual_task.id, + "config_id": consent_config.id, + "field_key": "consent_confirmation", + "field_type": "text", + "field_metadata": { + "label": "Consent Confirmation", + "required": True, + "data_categories": ["user.consent"], + }, + }, + ) + + # Add a condition that will NOT be met (email != nonexistent@test.com) + # This should cause the manual task to be skipped + conditional_dep = ManualTaskConditionalDependency.create( + db=db, + data={ + "manual_task_id": manual_task.id, + "condition_tree": { + "field_address": "privacy_request.identity.email", + "operator": "eq", + "value": "nonexistent@test.com", + }, + }, + ) + + # Create privacy request with different email (condition won't match) + privacy_request = PrivacyRequest.create( + db=db, + data={ + "requested_at": datetime.utcnow(), + "policy_id": consent_policy.id, + "status": PrivacyRequestStatus.pending, + }, + ) + privacy_request.cache_identity(Identity(email="actual@example.com")) + + instance = None + # Run privacy request - condition is false, so manual task should be skipped + run_privacy_request_task.delay(privacy_request.id).get( + timeout=PRIVACY_REQUEST_TASK_TIMEOUT + ) + db.refresh(privacy_request) + + # Should complete without requiring input (condition not met = task skipped) + assert privacy_request.status == PrivacyRequestStatus.complete + + # Verify no manual task instance was created (task was skipped) + instance = ( + db.query(ManualTaskInstance) + .filter( + ManualTaskInstance.task_id == manual_task.id, + ManualTaskInstance.entity_id == privacy_request.id, + ) + .first() + ) + assert ( + instance is None + ), "ManualTaskInstance should NOT be created when condition is false" + + class TestConsentEmailStep: @pytest.mark.parametrize( "dsr_version", diff --git a/tests/ops/task/test_create_request_tasks.py b/tests/ops/task/test_create_request_tasks.py index 982e14cd335..0b2dfdbf032 100644 --- a/tests/ops/task/test_create_request_tasks.py +++ b/tests/ops/task/test_create_request_tasks.py @@ -10,6 +10,11 @@ from fides.api.graph.graph import DatasetGraph from fides.api.graph.traversal import Traversal, TraversalNode from fides.api.models.datasetconfig import convert_dataset_to_graph +from fides.api.models.manual_task import ( + ManualTask, + ManualTaskConfig, + ManualTaskConfigField, +) from fides.api.models.privacy_request import ExecutionLog, RequestTask from fides.api.models.worker_task import ExecutionLogStatus from fides.api.schemas.policy import ActionType @@ -27,6 +32,7 @@ ) from fides.api.task.execute_request_tasks import run_access_node from fides.api.task.graph_task import build_consent_dataset_graph +from fides.api.task.manual.manual_task_address import ManualTaskAddress from fides.config import CONFIG from tests.conftest import wait_for_tasks_to_complete from tests.ops.task.traversal_data import combined_mongo_postgresql_graph @@ -1257,6 +1263,154 @@ def test_reprocess_consent_request_with_existing_request_tasks( assert privacy_request.consent_tasks.count() == 3 +class TestConsentGraphWithManualTasks: + """Tests for consent graph including manual tasks with consent configs""" + + def test_build_consent_dataset_graph_includes_manual_tasks_when_session_provided( + self, + db, + saas_example_dataset_config, + connection_config, + ): + """Test that build_consent_dataset_graph includes manual tasks when session is provided""" + + # Create a manual task with a consent config for the connection + manual_task = ManualTask.create( + db=db, + data={ + "task_type": "privacy_request", + "parent_entity_id": connection_config.id, + "parent_entity_type": "connection_config", + }, + ) + + consent_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.consent, + "is_current": True, + }, + ) + + # Must have at least one field for the manual task to be included in the graph + consent_field = ManualTaskConfigField.create( + db=db, + data={ + "task_id": manual_task.id, + "config_id": consent_config.id, + "field_key": "consent_confirmation", + "field_type": "text", + "field_metadata": { + "label": "Consent Confirmation", + "required": True, + "data_categories": ["user.consent"], + }, + }, + ) + + try: + # Build consent graph WITH session - should include manual tasks + graph = build_consent_dataset_graph([saas_example_dataset_config], db) + + # Verify manual task is included in the graph + manual_task_address = ManualTaskAddress.create(connection_config.key) + assert manual_task_address in graph.nodes + + # Verify the regular SaaS consent node is also included + saas_address = next( + addr + for addr in graph.nodes + if not ManualTaskAddress.is_manual_task_address(addr) + ) + assert saas_address is not None + + finally: + consent_field.delete(db) + consent_config.delete(db) + manual_task.delete(db) + + def test_build_consent_dataset_graph_excludes_manual_tasks_without_session( + self, + db, + saas_example_dataset_config, + connection_config, + ): + """Test that build_consent_dataset_graph excludes manual tasks when session is not provided""" + from fides.api.models.manual_task import ManualTask, ManualTaskConfig + + # Create a manual task with a consent config + manual_task = ManualTask.create( + db=db, + data={ + "task_type": "privacy_request", + "parent_entity_id": connection_config.id, + "parent_entity_type": "connection_config", + }, + ) + + consent_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.consent, + "is_current": True, + }, + ) + + try: + # Build consent graph WITHOUT session - should not include manual tasks + graph = build_consent_dataset_graph([saas_example_dataset_config]) + + # Verify manual task is NOT included in the graph + manual_task_address = ManualTaskAddress.create(connection_config.key) + assert manual_task_address not in graph.nodes + + finally: + consent_config.delete(db) + manual_task.delete(db) + + def test_build_consent_dataset_graph_excludes_manual_tasks_without_consent_config( + self, + db, + saas_example_dataset_config, + connection_config, + ): + """Test that build_consent_dataset_graph excludes manual tasks without consent configs""" + from fides.api.models.manual_task import ManualTask, ManualTaskConfig + + # Create a manual task with only an access config (no consent config) + manual_task = ManualTask.create( + db=db, + data={ + "task_type": "privacy_request", + "parent_entity_id": connection_config.id, + "parent_entity_type": "connection_config", + }, + ) + + access_config = ManualTaskConfig.create( + db=db, + data={ + "task_id": manual_task.id, + "config_type": ActionType.access, + "is_current": True, + }, + ) + + try: + # Build consent graph with session + graph = build_consent_dataset_graph([saas_example_dataset_config], db) + + # Verify manual task is NOT included (no consent config) + manual_task_address = ManualTaskAddress.create(connection_config.key) + assert manual_task_address not in graph.nodes + + finally: + access_config.delete(db) + manual_task.delete(db) + + class TestGetExistingReadyTasks: def test_no_request_tasks(self, privacy_request, db): assert get_existing_ready_tasks(db, privacy_request, ActionType.access) == []