Skip to content
Open
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
75 changes: 59 additions & 16 deletions source/app/datamgmt/custom_dashboard/query_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,16 +720,31 @@ class WidgetQueryExecutor:
}
}

# Tables reachable directly from `cases` without needing `alerts` at all. A widget whose
# fields/group_by/filters touch only these can be rooted at `cases` instead of `alerts`
# (see _is_case_scoped) so a case's child rows are looked up independently of how many
# alerts happen to be merged into it - fixes both halves of the alerts-as-universal-base
# bug: cases with 0 merged alerts were invisible, cases with 2+ produced duplicate rows.
_CASE_ROOTED_TABLES = frozenset({
'cases', 'case_owner', 'case_creator', 'case_reviewer', 'case_tags', 'tags',
'case_assets', 'case_asset_types', 'case_iocs', 'case_ioc_types',
'case_events', 'case_notes', 'case_tasks', 'review_status', 'case_state',
})

def __init__(self, definition: Dict[str, Any]):
self.definition = definition or {}
self.builder = _WidgetQueryBuilder()
self._projection_tables: Set[str] = self._collect_projection_tables()
referenced_tables = self._collect_all_referenced_tables()
self._is_case_scoped = bool(referenced_tables) and referenced_tables <= self._CASE_ROOTED_TABLES
self._effective_base_table = 'cases' if self._is_case_scoped else self._BASE_TABLE
options = self.definition.get('options') or {}
self.time_column_spec = options.get('time_column') or 'alerts.alert_creation_time'
default_time_column = 'cases.open_date' if self._is_case_scoped else 'alerts.alert_creation_time'
self.time_column_spec = options.get('time_column') or default_time_column
self._normalized_time_column_spec = self._normalize_table_column_value(self.time_column_spec)
raw_time_bucket = self.definition.get('time_bucket')
self.time_bucket = raw_time_bucket.strip().lower() if isinstance(raw_time_bucket, str) else ''
self._time_bucket_label = ''
self._projection_tables: Set[str] = self._collect_projection_tables()

def execute(self, timeframe: Tuple[Optional[datetime], Optional[datetime]]) -> WidgetQueryResult:
widgets_fields = self.definition.get('fields') or []
Expand Down Expand Up @@ -782,7 +797,7 @@ def _get_column(self, table_name: str, column_name: str):
column = columns.get(column_name)
if column is None:
raise QueryExecutionError(f"Column '{column_name}' is not allowed for table '{table_name}'.")
if table_name in {'case_owner', 'case_creator', 'case_reviewer', 'case_tags', 'tags', 'case_assets', 'case_asset_types', 'case_iocs', 'case_ioc_types', 'case_events', 'case_notes', 'case_tasks', 'review_status', 'case_state'}:
if table_name in self._CASE_ROOTED_TABLES and table_name != 'cases' and not self._is_case_scoped:
self.builder.add_join('cases')
if table_name == 'tags':
self.builder.add_join('case_tags')
Expand All @@ -794,7 +809,7 @@ def _get_column(self, table_name: str, column_name: str):
self.builder.add_join('alert_assets')
if table_name == 'alert_ioc_types':
self.builder.add_join('alert_iocs')
if table_name != self._BASE_TABLE:
if table_name != self._effective_base_table:
self.builder.add_join(table_name)
return column

Expand Down Expand Up @@ -853,6 +868,26 @@ def _collect_projection_tables(self) -> Set[str]:
tables.add(group_entry.split('.', 1)[0].strip())
return tables

def _collect_all_referenced_tables(self) -> Set[str]:
# Widening of _collect_projection_tables() to decide whether a widget can be rooted
# at `cases` instead of `alerts` (see _is_case_scoped in __init__) - also has to see
# per-field filters, top-level filters, and an explicit time_column override, since
# any of those touching an alert-only table forces the alert-rooted query path.
tables: Set[str] = set(self._projection_tables)
for field in self.definition.get('fields') or []:
if isinstance(field, dict):
field_filter = field.get('filter')
if isinstance(field_filter, dict) and field_filter.get('table'):
tables.add(field_filter['table'])
for filter_entry in self.definition.get('filters') or []:
if isinstance(filter_entry, dict) and filter_entry.get('table'):
tables.add(filter_entry['table'])
options = self.definition.get('options') or {}
explicit_time_column = options.get('time_column')
if isinstance(explicit_time_column, str) and '.' in explicit_time_column:
tables.add(explicit_time_column.split('.', 1)[0].strip())
return tables

def _build_case_scoped_tag_filter(self, filter_definition: Dict[str, Any]):
# Many-to-many joins on tags inflate counts; route tag-only filters through
# a Cases.case_id IN (subquery) so the main query stays flat.
Expand Down Expand Up @@ -889,7 +924,8 @@ def _build_case_scoped_tag_filter(self, filter_definition: Dict[str, Any]):
else:
subquery = select(CaseTags.case_id).where(condition)

self.builder.add_join('cases')
if not self._is_case_scoped:
self.builder.add_join('cases')
return Cases.case_id.in_(subquery)

def _apply_timeframe(self, start: Optional[datetime], end: Optional[datetime]):
Expand Down Expand Up @@ -918,29 +954,36 @@ def _apply_access_filters(self):
if ac_current_user_has_permission(Permissions.server_administrator):
return

deny_all = (Cases.case_id == -1) if self._is_case_scoped else (Alert.alert_id == -1)

user_id = getattr(current_user, 'id', None)
if not user_id:
# Without a logged-in user we cannot determine scope; deny by default.
self.builder.filters.append(Alert.alert_id == -1)
self.builder.filters.append(deny_all)
return

client_ids = get_user_clients_id(user_id) or []
case_ids = ac_get_fast_user_cases_access(user_id) or []

access_conditions = []

if client_ids:
access_conditions.append(Alert.alert_customer_id.in_(client_ids))

if case_ids:
case_alerts_subquery = select(AlertCaseAssociation.alert_id).where(
AlertCaseAssociation.case_id.in_(case_ids)
)
access_conditions.append(Alert.alert_id.in_(case_alerts_subquery))
if self._is_case_scoped:
if client_ids:
access_conditions.append(Cases.client_id.in_(client_ids))
if case_ids:
access_conditions.append(Cases.case_id.in_(case_ids))
else:
if client_ids:
access_conditions.append(Alert.alert_customer_id.in_(client_ids))
if case_ids:
case_alerts_subquery = select(AlertCaseAssociation.alert_id).where(
AlertCaseAssociation.case_id.in_(case_ids)
)
access_conditions.append(Alert.alert_id.in_(case_alerts_subquery))

if not access_conditions:
# User has no accessible scope -> no data should be returned.
self.builder.filters.append(Alert.alert_id == -1)
self.builder.filters.append(deny_all)
return

if len(access_conditions) == 1:
Expand All @@ -954,7 +997,7 @@ def _build_query(self) -> Query:
raise QueryExecutionError('Widgets must contain at least one aggregated field or grouping column.')

query = db.session.query(*self.builder.selects)
query = query.select_from(Alert)
query = query.select_from(Cases if self._is_case_scoped else Alert)
for join_table in self.builder.joins:
table = self._get_table(join_table)
join_callable = table.get('join')
Expand Down
72 changes: 71 additions & 1 deletion tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,74 @@ def test_update_case_should_not_require_case_name_issue_358(self):
case = self._subject.create_case()
case_identifier = case['case_id']
response = self._subject.update_case(case_identifier, {'case_tags': 'test,example'})
self.assertEqual('success', response['status'])
self.assertEqual('success', response['status'])

def test_custom_dashboard_case_child_table_widgets_are_not_affected_by_merged_alert_count(self):
dashboard_payload = {
'name': 'Regression dashboard - issue 1112',
'widgets': [
{
'name': 'Total Tasks',
'chart_type': 'number',
'fields': [{'table': 'case_tasks', 'column': 'id', 'aggregation': 'count', 'alias': 'total'}],
},
{
'name': 'Tasks per Case',
'chart_type': 'table',
'fields': [
{'table': 'cases', 'column': 'name', 'alias': 'case_name'},
{'table': 'case_tasks', 'column': 'task_title', 'alias': 'task_title'},
],
},
],
}
response = self._subject._api.post('/custom-dashboards/api/dashboards', dashboard_payload)
self.assertEqual('success', response['status'])
dashboard_id = response['data']['id']

def fetch_widgets():
response = self._subject._api.get(f'/custom-dashboards/api/dashboards/{dashboard_id}/data')
return response['data']['widgets']

# Baseline before adding our own data -- the suite's database is not reset between
# tests, so asserting on a delta (not an absolute count) keeps this robust regardless
# of what other tests have already created.
baseline_total_tasks = fetch_widgets()[0]['data']['value']

# Mirrors the exact reproduction from issue #1112: 4 cases with 1 task each, merged
# into 0/2/2/1 alerts respectively. Pre-fix, the widget engine's query was always
# rooted at `alerts`, so a case with 0 merged alerts never produced a base row
# (invisible) and a case with N merged alerts produced N duplicate base rows (its
# task counted N times): 0+2+2+1=5, not the true count of 4.
case_ids = []
case_names = []
for _ in range(4):
case = self._subject.create_case()
case_ids.append(case['case_id'])
case_names.append(case['case_name'])
task_body = {'task_title': 'Regression task', 'task_status_id': 1, 'task_assignees_id': [1]}
response = self._subject._api.post('/case/tasks/add', task_body, query_parameters={'cid': case['case_id']})
self.assertEqual('success', response['status'])

for case_index, alert_count in [(1, 2), (2, 2), (3, 1)]:
target_case_id = case_ids[case_index]
for _ in range(alert_count):
alert = self._subject.create_alert()
alert_id = alert['data']['alert_id']
merge_body = {
'target_case_id': target_case_id,
'iocs_import_list': [],
'assets_import_list': [],
'import_as_event': False,
}
response = self._subject._api.post(f'/alerts/merge/{alert_id}', merge_body)
self.assertEqual('success', response['status'])

widgets = fetch_widgets()
self.assertEqual(baseline_total_tasks + 4, widgets[0]['data']['value'])

table_rows = widgets[1]['data']['source_rows']
rows_for_my_cases = [row for row in table_rows if row['case_name'] in case_names]
self.assertEqual(4, len(rows_for_my_cases))
for row in rows_for_my_cases:
self.assertEqual('Regression task', row['task_title'])