Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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, ActionType> = {
[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;

Expand Down
1 change: 1 addition & 0 deletions clients/admin-ui/src/features/manual-tasks/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];
Original file line number Diff line number Diff line change
Expand Up @@ -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, ActionType> =
{
[ManualFieldRequestType.ACCESS]: ActionType.ACCESS,
[ManualFieldRequestType.ERASURE]: ActionType.ERASURE,
[ManualFieldRequestType.CONSENT]: ActionType.CONSENT,
};
const actionType = requestTypeToActionType[type];
const displayName = SubjectRequestActionTypeMap.get(actionType) || type;
return (
<Typography.Text ellipsis={{ tooltip: displayName }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
export enum ManualFieldRequestType {
ACCESS = "access",
ERASURE = "erasure",
CONSENT = "consent",
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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."""

Expand All @@ -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."""

Expand Down Expand Up @@ -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."""
Expand Down
34 changes: 31 additions & 3 deletions src/fides/api/task/graph_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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] = []

Expand All @@ -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)
Loading
Loading