diff --git a/docs/servers/secops_mcp.md b/docs/servers/secops_mcp.md index 432d4e9c..ca3b1732 100644 --- a/docs/servers/secops_mcp.md +++ b/docs/servers/secops_mcp.md @@ -839,6 +839,547 @@ The service account or user credentials need the following Chronicle roles: - `region` (optional): Chronicle region (defaults to environment config or 'us'). - **Returns:** Dictionary containing investigation associations grouped by detection ID, with verdict and confidence information. +### Dashboard Management Tools (Native SIEM Dashboards, Charts, Queries) + +- **`list_dashboards(view=None, page_size=None, page_token=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List native SIEM dashboards in the Chronicle instance. + - **Parameters:** + - `view` (optional): NATIVE_DASHBOARD_VIEW_BASIC (default) or NATIVE_DASHBOARD_VIEW_FULL (includes `definition`). + - `page_size` (optional): Maximum dashboards per page. + - `page_token` (optional): `nextPageToken` from a previous call. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"nativeDashboards": [...], "nextPageToken": ..., "total_dashboards": N}` or `{"error": ...}`. + +- **`get_dashboard(dashboard_id, view=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Get a native SIEM dashboard by ID or resource name. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name (`projects/.../nativeDashboards/{id}`). + - `view` (optional): NATIVE_DASHBOARD_VIEW_BASIC or NATIVE_DASHBOARD_VIEW_FULL. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The NativeDashboard resource or `{"error": ...}`. + +- **`create_dashboard(display_name, description=None, access_type="DASHBOARD_PRIVATE", filters=None, dashboard=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Create a new (empty) native SIEM dashboard. + - **Parameters:** + - `display_name` (required): Title shown to users. + - `description` (optional): Free-text description. + - `access_type` (optional): DASHBOARD_PRIVATE (default) or DASHBOARD_PUBLIC; the API rejects requests without it. + - `filters` (optional): DashboardFilter list placed under `definition.filters`, e.g. `[{"id": "f1", "displayName": "Hostname", "dataSource": "EVENTS", "fieldPath": "principal.hostname", "filterOperatorAndFieldValues": [{"filterOperator": "EQUAL", "fieldValues": ["host-1"]}]}]`. + - `dashboard` (optional): Additional NativeDashboard fields (camelCase) merged over the generated body, e.g. `{"definition": {...}}`. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The created NativeDashboard (with `name`) or `{"error": ...}`. + +- **`update_dashboard(dashboard_id, display_name=None, description=None, filters=None, dashboard=None, update_mask=None, etag=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Update fields of a native SIEM dashboard (PATCH with update mask). + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name. + - `display_name` (optional): New title. + - `description` (optional): New description. + - `filters` (optional): Replacement DashboardFilter list for `definition.filters`. + - `dashboard` (optional): Raw NativeDashboard fields (camelCase) merged over the generated body. + - `update_mask` (optional): Comma-separated camelCase field paths (e.g. "displayName,definition.charts"). Merged with the auto-derived mask. + - `etag` (optional): Optimistic-concurrency etag from `get_dashboard`. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The updated NativeDashboard or `{"error": ...}`. + +- **`duplicate_dashboard(dashboard_id, display_name, description=None, access_type="DASHBOARD_PRIVATE", dashboard=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Duplicate a native SIEM dashboard (including its charts and queries). + - **Parameters:** + - `dashboard_id` (required): Source dashboard ID or full resource name. + - `display_name` (required): Title for the copy. + - `description` (optional): Description for the copy. + - `access_type` (optional): DASHBOARD_PRIVATE (default) or DASHBOARD_PUBLIC; the API rejects requests without it. + - `dashboard` (optional): Extra NativeDashboard fields (camelCase) merged into the `nativeDashboard` body. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The new NativeDashboard or `{"error": ...}`. + +- **`delete_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Delete a custom native SIEM dashboard. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"status": "deleted", "dashboard_id": ...}` or `{"error": ...}`. + +- **`add_dashboard_chart(dashboard_id, display_name, query=None, time_unit=None, start_time_val=None, start_time=None, end_time=None, visualization=None, description=None, tile_type=None, start_x=None, start_y=None, span_x=None, span_y=None, data_sources=None, chart=None, dashboard_query=None, chart_layout=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Add a chart (and its UDM stats query) to a native SIEM dashboard. + - **Parameters:** + - `dashboard_id` (required): Target dashboard ID or full resource name. + - `display_name` (required): Chart title. + - `query` (optional): YARA-L 2.0 UDM stats query, e.g. `metadata.event_type = "USER_LOGIN" match: principal.user.userid outcome: $count = count(metadata.id) order: $count desc`. + - `time_unit` (optional): Relative window unit: SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, YEAR (default DAY when a query is given). + - `start_time_val` (optional): Relative window length (default 1). + - `start_time` (optional): Absolute window start (RFC 3339); with `end_time` replaces the relative window. + - `end_time` (optional): Absolute window end (RFC 3339). + - `visualization` (optional): DashboardChart `visualization` config (ECharts-style), e.g. `{"series": [{"chartType": "TABLE"}]}`. + - `description` (optional): Chart description. + - `tile_type` (optional): TILE_TYPE_VISUALIZATION (default), TILE_TYPE_BUTTON or TILE_TYPE_MARKDOWN. + - `data_sources` (optional): `chartDatasource. dataSources` for query-backed charts; defaults to `["UDM"]` when a query is given (the API rejects charts without it). + - `chart` (optional): Raw DashboardChart fields merged over the generated `dashboardChart`. + - `dashboard_query` (optional): Raw DashboardQuery fields merged over the generated `dashboardQuery`. + - `chart_layout` (optional): Raw ChartLayout (`{"startX":0,"startY":0,"spanX":48,"spanY":26}`). + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"nativeDashboard": {...}, "dashboardChart": {...}}` or `{"error": ...}`. + +- **`edit_dashboard_chart(dashboard_id, chart_id, edit_mask, display_name=None, description=None, query=None, time_unit=None, start_time_val=None, start_time=None, end_time=None, visualization=None, chart=None, dashboard_query=None, etag=None, language_features=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Edit an existing chart and/or its query on a native SIEM dashboard. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name. + - `chart_id` (required): Chart ID or full `dashboardCharts/{id}` name. + - `edit_mask` (required): Comma-separated camelCase paths, e.g. "dashboardChart.displayName,dashboardQuery.query,dashboardQuery.input". + - `display_name` (optional): New chart title. + - `description` (optional): New chart description. + - `query` (optional): New YARA-L 2.0 stats query. + - `time_unit` (optional): Relative window unit (SECOND..YEAR). + - `start_time_val` (optional): Relative window length. + - `start_time` (optional): Absolute window start (RFC 3339). + - `end_time` (optional): Absolute window end (RFC 3339). + - `visualization` (optional): New `visualization`. + - `chart` (optional): Raw DashboardChart fields merged over the generated `dashboardChart` (`name` and `etag` are set automatically from the current chart). + - `dashboard_query` (optional): Raw DashboardQuery fields merged over the generated `dashboardQuery` (`name` is set automatically from `chartDatasource.dashboardQuery`). + - `etag` (optional): Current chart etag; fetched automatically when omitted. + - `language_features` (optional): JOINS, STAGES, DATA_TABLES used by the query. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"nativeDashboard": {...}, "dashboardChart": {...}}` or `{"error": ...}`. + +- **`remove_dashboard_chart(dashboard_id, chart_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Remove a chart from a native SIEM dashboard. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name. + - `chart_id` (required): Chart ID or full `dashboardCharts/{id}` name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The updated NativeDashboard or `{"error": ...}`. + +- **`duplicate_dashboard_chart(dashboard_id, chart_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Duplicate a chart within a native SIEM dashboard. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name. + - `chart_id` (required): Chart ID or full `dashboardCharts/{id}` name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"nativeDashboard": {...}, "dashboardChart": {...}}` (the new chart) or `{"error": ...}`. + +- **`export_dashboards(dashboard_ids, project_id=None, customer_id=None, region=None)`** + - **Description:** Export native SIEM dashboards (with charts and queries) as JSON. + - **Parameters:** + - `dashboard_ids` (required): One or more dashboard IDs or full resource names. A JSON-array string is accepted. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"inlineDestination": {"dashboards": [...]}}` or `{"error": ...}`. + +- **`import_dashboards(dashboards, project_id=None, customer_id=None, region=None)`** + - **Description:** Import native SIEM dashboards from an inline export payload. + - **Parameters:** + - `dashboards` (required): Either the list of NativeDashboardWithChartsAndQueries items, a single item, or the full `export_dashboards` response. JSON strings are accepted. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"results": [{"dashboard": "projects/.../nativeDashboards/", "status": {"message": "Dashboard Imported Successfully"}}]}` or `{"error": ...}`. + +- **`execute_dashboard_query(query=None, time_unit=None, start_time_val=None, start_time=None, end_time=None, query_id=None, dialect=None, filters=None, clear_cache=None, use_previous_time_range=None, dashboard_query=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Execute a dashboard (UDM stats) query ad hoc and return tabular results. + - **Parameters:** + - `query` (optional): YARA-L 2.0 stats query (`match:` / `outcome:` sections) or SQL when `dialect="SQL"`. + - `time_unit` (optional): SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, YEAR. + - `start_time_val` (optional): Relative window length (e.g. 7). + - `start_time` (optional): Absolute window start (RFC 3339); with `end_time` replaces the relative window. + - `end_time` (optional): Absolute window end (RFC 3339). + - `query_id` (optional): Saved DashboardQuery ID or resource name to execute instead of an inline query. + - `dialect` (optional): YL2 (default) or SQL. + - `filters` (optional): DashboardFilter list, e.g. `[{"fieldPath": "principal.hostname", "dataSource": "EVENTS", "filterOperatorAndFieldValues": [{"filterOperator": "EQUAL", "fieldValues": ["host-1"]}]}]`. + - `clear_cache` (optional): Bypass the result cache. + - `use_previous_time_range` (optional): Run against the preceding window of the same length (for comparisons). + - `dashboard_query` (optional): Raw DashboardQuery fields merged over the generated `query` object. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"results": [{"column": ..., "values": [...]}], "timeWindow": {...}, "queryRuntimeErrors": [...]}` or `{"error": ...}`. + +- **`get_dashboard_query(query_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Get a saved dashboard query by ID or resource name. + - **Parameters:** + - `query_id` (required): Query ID or full `dashboardQueries/{id}` name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The DashboardQuery resource or `{"error": ...}`. + +- **`get_dashboard_chart(chart_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Get a dashboard chart by ID or resource name. + - **Parameters:** + - `chart_id` (required): Chart ID or full `dashboardCharts/{id}` name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The DashboardChart resource or `{"error": ...}`. + +- **`list_dashboard_charts(filter=None, page_size=None, page_token=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List dashboard charts in the instance (v1alpha only). + - **Parameters:** + - `filter` (optional): Server-side filter expression (e.g. `native_dashboard = "projects/.../nativeDashboards/{id}"`). + - `page_size` (optional): Maximum charts per page (<= 1000). + - `page_token` (optional): `nextPageToken` from a previous call. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"dashboardCharts": [...], "nextPageToken": ..., "totalSize": N, "total_charts": N}` or `{"error": ...}`. + +- **`batch_get_dashboard_charts(chart_ids, project_id=None, customer_id=None, region=None)`** + - **Description:** Get several dashboard charts in one call. + - **Parameters:** + - `chart_ids` (required): Chart IDs or full resource names. A JSON-array string is accepted. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"dashboardCharts": [...], "total_charts": N}` or `{"error": ...}`. + +### Dashboard Scheduled Report and Content Hub Tools + +- **`list_dashboard_scheduled_reports(page_size=None, page_token=None, filter_query=None, view=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List scheduled email/GCS reports configured for native dashboards. + - **Parameters:** + - `page_size` (optional): Maximum number of reports to return. + - `page_token` (optional): `nextPageToken` from a previous call. + - `filter_query` (optional): Case-insensitive substring match applied to `display_name` and `description`. + - `view` (optional): `DASHBOARD_SCHEDULED_REPORT_VIEW_BASIC` (default) or `DASHBOARD_SCHEDULED_REPORT_VIEW_FULL`. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** API response with `dashboardScheduledReports`, `nextPageToken`, plus `total_reports` for the page, or an error. + +- **`get_dashboard_scheduled_report(report_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Get a single dashboard scheduled report configuration. + - **Parameters:** + - `report_id` (required): Scheduled report ID or full resource name (`projects/.../dashboardScheduledReports/{id}`). + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The `DashboardScheduledReport` resource or an error. + +- **`create_dashboard_scheduled_report(display_name, dashboard_id, cron, recipients=None, time_zone=None, file_format=None, description=None, email_subject=None, email_body=None, delivery_type=None, data_access_scopes=None, scheduled_report=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Create a scheduled report that emails a native dashboard on a cron. + - **Parameters:** + - `display_name` (required): Report name shown in the UI (required). + - `dashboard_id` (required): Native dashboard ID or full resource name (`projects/.../nativeDashboards/{id}`); becomes `dashboard.name`. + - `cron` (required): Crontab schedule, 5 fields `min hour dom month dow` (e.g. "0 8 * * 1" = Mondays 08:00). + - `recipients` (optional): Email recipients (required by the API for email delivery). JSON array string ok. + - `time_zone` (optional): IANA time zone for the cron (default UTC). + - `file_format` (optional): `FILE_FORMAT_PDF`, `FILE_FORMAT_CSV`, or `FILE_FORMAT_PNG` (required by the API; defaults to PDF here). + - `description` (optional): Free-text description. + - `email_subject` (optional): Email subject line. + - `email_body` (optional): Email body text. + - `delivery_type` (optional): `DELIVERY_TYPE_EMAIL_ATTACHMENT` or `DELIVERY_TYPE_GCS_BUCKET`. + - `data_access_scopes` (optional): Full `dataAccessScopes` resource names for `scopeInfo`. + - `scheduled_report` (optional): Extra `DashboardScheduledReport` JSON (camelCase keys, e.g. `{"userData": {"pinned": true}, "scopeInfo": {...}}`) merged over the body built from the arguments above. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The created `DashboardScheduledReport` or an error. + +- **`update_dashboard_scheduled_report(report_id, update_mask=None, display_name=None, description=None, dashboard_id=None, cron=None, time_zone=None, recipients=None, email_subject=None, email_body=None, delivery_type=None, file_format=None, pinned=None, scheduled_report=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Update fields of an existing dashboard scheduled report (PATCH). + - **Parameters:** + - `report_id` (required): Scheduled report ID or full resource name. + - `update_mask` (optional): Field paths to update. Supported: `display_name`, `description`, `cron_details`, `format`, `delivery_details`, `scope_info`, `dashboard.name`, `user_data.pinned`. Comma-separated string, JSON array, or list. + - `display_name` (optional): New display name. + - `description` (optional): New description. + - `dashboard_id` (optional): New native dashboard ID or full name. + - `cron` (optional): New crontab string (5 fields). + - `time_zone` (optional): New IANA time zone. + - `recipients` (optional): Replacement recipient list (whole `delivery_details` is replaced, so pass all fields). + - `email_subject` (optional): New email subject. + - `email_body` (optional): New email body. + - `delivery_type` (optional): `DELIVERY_TYPE_EMAIL_ATTACHMENT` or `DELIVERY_TYPE_GCS_BUCKET`. + - `file_format` (optional): `FILE_FORMAT_PDF|CSV|PNG`. + - `pinned` (optional): Pin/unpin (`user_data.pinned`). + - `scheduled_report` (optional): Extra resource JSON (camelCase) merged over the generated body, e.g. `{"scopeInfo": {"dataAccessScopes": [...]}, "etag": "..."}`. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The updated `DashboardScheduledReport` or an error. + +- **`delete_dashboard_scheduled_report(report_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Delete a dashboard scheduled report. + - **Parameters:** + - `report_id` (required): Scheduled report ID or full resource name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"deleted": true, "report_id": ...}` or an error. + +- **`fetch_dashboard_scheduled_report_history(report_id, page_size=None, page_token=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Fetch the run history of a dashboard scheduled report (last year). + - **Parameters:** + - `report_id` (required): Scheduled report ID or full resource name. + - `page_size` (optional): Max entries (default 50, max 1000). + - `page_token` (optional): `nextPageToken` from a previous call. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `reportGenerationDetails`, `runCounts`, `totalSize`, `nextPageToken`, or an error. + +- **`duplicate_dashboard_scheduled_report(report_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Duplicate an existing dashboard scheduled report. + - **Parameters:** + - `report_id` (required): Source scheduled report ID or full resource name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The new `DashboardScheduledReport` or an error. + +- **`trigger_instant_dashboard_report(report_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Send a dashboard scheduled report immediately, outside its schedule. + - **Parameters:** + - `report_id` (required): Scheduled report ID or full resource name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The long-running `Operation` (`name`, `done`, optional `metadata`/`response`) or an error. + +- **`list_featured_content_dashboards(page_size=None, page_token=None, filter_query=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List Google-curated dashboards available in the Content Hub. + - **Parameters:** + - `page_size` (optional): Max results (default and max 100). + - `page_token` (optional): `nextPageToken` from a previous call. + - `filter_query` (optional): AIP-160 filter over `name` and `content_metadata.description`; a bare literal (e.g. `"okta"`) does a substring match on both. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `featuredContentNativeDashboards`, `nextPageToken`, plus `total_featured_dashboards` for the page, or an error. + +- **`get_featured_content_dashboard(featured_dashboard_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Get one Content Hub featured (curated) native dashboard. + - **Parameters:** + - `featured_dashboard_id` (required): Featured dashboard ID or full resource name (`projects/.../contentHub/featuredContentNativeDashboards/{id}`). + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The `FeaturedContentNativeDashboard` or an error. + +- **`install_featured_content_dashboard(featured_dashboard_id, featured_dashboard=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Install a Content Hub curated dashboard as a native dashboard. + - **Parameters:** + - `featured_dashboard_id` (required): Featured dashboard ID or full resource name. + - `featured_dashboard` (optional): Optional `FeaturedContentNativeDashboard` JSON sent as `featuredContentNativeDashboard` in the body, e.g. to override `dashboardContent.dashboard.displayName` before install. Usually omitted. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** `{"nativeDashboard": "projects/.../nativeDashboards/{id}"}` or an error. + +### Legacy (Looker) Dashboard Tools + +- **`get_legacy_dashboard(dashboard_id, view=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Get a legacy (Looker) Chronicle dashboard by ID. v1alpha-only. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name ``projects/.../instances/.../dashboards/{id}``. + - `view` (optional): ``DASHBOARD_VIEW_BASIC`` (default, no LookML), ``DASHBOARD_VIEW_EXPORT`` (LookML, no URI) or ``DASHBOARD_VIEW_FULL`` (everything). + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The Dashboard resource, or an error message. + +- **`list_legacy_dashboards(page_size=None, page_token=None, view=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List legacy (Looker) Chronicle dashboards. v1alpha-only. + - **Parameters:** + - `page_size` (optional): Maximum dashboards per page. + - `page_token` (optional): ``nextPageToken`` from a previous call. + - `view` (optional): ``DASHBOARD_VIEW_BASIC`` (default), ``DASHBOARD_VIEW_EXPORT`` or ``DASHBOARD_VIEW_FULL``. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"dashboards": [...], "nextPageToken": "...", "total_dashboards": N}`` or an error message. + +- **`delete_legacy_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Delete a legacy (Looker) Chronicle dashboard. v1alpha-only. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID or full resource name. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"status": "deleted", "dashboard_id": ...}`` or an error message. + +- **`copy_legacy_dashboard(dashboard_id, dashboard_type=None, display_name=None, default_dashboard=None, destination=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Copy a legacy (Looker) dashboard to another dashboard type. v1alpha-only. + - **Parameters:** + - `dashboard_id` (required): Source dashboard ID or full resource name. + - `dashboard_type` (optional): Destination type, required unless given in ``destination``: ``DASHBOARD_TYPE_USER`` or ``DASHBOARD_TYPE_CUSTOMER``. + - `display_name` (optional): Name for the copy. + - `default_dashboard` (optional): Make the copy the default. + - `destination` (optional): Destination Dashboard resource (dict or JSON string), e.g. ``{"displayName": "My copy", "type": "DASHBOARD_TYPE_USER"}``. Explicit args above override matching keys. The request body sent is ``{"destination": {"parent": , "dashboard": {...}}}``. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The newly created Dashboard resource, or an error. + +> **Future work (not included):** the Looker-to-native migration endpoints (`dashboards:fetchMigratable`, `dashboards:batchMigrate`, `dashboards:fetchMigrationMetrics`, `dashboards:fetchMigrationJobs`) and legacy dashboard creation (`POST /dashboards`) are intentionally not exposed yet. Migration is gated by a Chronicle feature flag that is still under development (`403 Looker migration wizard is not enabled for the customer`), and legacy creation currently fails server-side, so neither could be verified against a live tenant. Tools for them can be added once the APIs are generally available. + +### Legacy SOAR Dashboard Tools + +- **`legacy_soar_get_dashboard_cards(project_id=None, customer_id=None, region=None)`** + - **Description:** List legacy SOAR dashboards (ID to name map) visible to the caller. + - **Returns:** ``{"payload": {"": "", ...}, "total_dashboards": N}`` or ``{"error": ...}``. + +- **`legacy_soar_get_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Get a legacy SOAR dashboard with its configuration and widgets. + - **Parameters:** + - `dashboard_id` (required): Numeric dashboard ID (from legacy_soar_get_dashboard_cards). + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": {}}`` or ``{"error": ...}``. + +- **`legacy_soar_get_dashboard_widget_definitions(project_id=None, customer_id=None, region=None)`** + - **Description:** List the predefined widget definitions available for SOAR dashboards. + - **Returns:** ``{"payload": [], "total_definitions": N}`` or ``{"error": ...}``. + +- **`legacy_soar_get_dashboard_playbooks(project_id=None, customer_id=None, region=None)`** + - **Description:** List SOAR playbooks that have data available for dashboard reporting. + - **Returns:** ``{"payload": [], "total_playbooks": N}`` or ``{"error": ...}``. + +- **`legacy_soar_add_or_update_dashboard(dashboard_id, name=None, creator=None, default_time_range=None, environments=None, allowed_users=None, allowed_users_json=None, widgets=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Create or update a legacy SOAR (case management) dashboard. + - **Parameters:** + - `dashboard_id` (required): Dashboard ID to update, or 0 to create. + - `name` (optional): Dashboard display name. + - `creator` (optional): Creator username. + - `default_time_range` (optional): One of DASHBOARD_TIME_RANGE_TYPE_UNSPECIFIED, LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `environments` (optional): SOAR environment names the dashboard covers; list or JSON array string. + - `allowed_users` (optional): Usernames allowed to view the dashboard; list or JSON array string. + - `allowed_users_json` (optional): Legacy JSON-encoded allowed users string, if the backend expects it. + - `widgets` (optional): Widget definitions (LegacySoarDashboardWidgetDataModel, camelCase keys) as list or JSON array string. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The saved dashboard (``id``, ``name``, ``creator``, ``widgets``, ...) or ``{"error": ...}``. + +- **`legacy_soar_delete_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Delete a legacy SOAR dashboard and all of its widgets. + - **Parameters:** + - `dashboard_id` (required): Numeric dashboard ID to delete. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"deleted": True, "dashboard_id": ...}`` merged with the (normally empty) API response, or ``{"error": ...}``. + +- **`legacy_soar_import_dashboard(dashboard=None, name=None, creator=None, default_time_range=None, environments=None, allowed_users=None, widgets=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Import a legacy SOAR dashboard from an exported JSON definition. + - **Parameters:** + - `dashboard` (optional): Exported dashboard object with camelCase keys (``id``, ``name``, ``creator``, ``defaultTimeRange``, ``environments``, ``allowedUsers``, ``widgets``, ...). Dict or JSON string. + - `name` (optional): Override the dashboard name. + - `creator` (optional): Override the creator username. + - `default_time_range` (optional): One of DASHBOARD_TIME_RANGE_TYPE_UNSPECIFIED, LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `environments` (optional): Override environments. + - `allowed_users` (optional): Override allowed users. + - `widgets` (optional): Override widget list. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": {}}`` or ``{"error": ...}``. + +- **`legacy_soar_save_dashboard_as_report_template(name, template_id=None, category=None, report_widgets=None, is_system=None, has_scheduler=None, template=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Save a legacy SOAR dashboard's widgets as a SOAR report template. + - **Parameters:** + - `name` (required): Report template name. + - `template_id` (optional): Existing template ID to overwrite (``id``); omit to create. + - `category` (optional): ``ROI``, ``MANAGEMENT``, ``SLA``, ``TIER1`` or ``GENERAL``. + - `report_widgets` (optional): Widget definitions (camelCase) as list or JSON array string. + - `is_system` (optional): Mark as a system template. + - `has_scheduler` (optional): Whether a schedule is attached. + - `template` (optional): Full request object (camelCase keys) used as the base; explicit arguments override. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": {}}`` or ``{"error": ...}``. + +- **`legacy_soar_add_or_update_dashboard_widget(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Create or update a widget on a legacy SOAR dashboard. + - **Parameters:** + - `widget` (optional): Full widget definition (camelCase keys) as returned by legacy_soar_get_dashboard; dict or JSON string. Explicit arguments below override its keys. + - `widget_id` (optional): Widget ID (``id``; 0 for new). + - `dashboard_id` (optional): Owning dashboard ID (``dashboardId``). + - `title` (optional): Widget title. + - `widget_type` (optional): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + - `width_size` (optional): ``ONE_BLOCK``, ``TWO_BLOCKS``, ``THREE_BLOCKS``. + - `description` (optional): Widget description. + - `order` (optional): Position in the dashboard. + - `time_filter` (optional): LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `subject` (optional): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + - `dimension_a` (optional): Primary grouping (CustomWidgetObjectEnum), e.g. ``ANALYST``, ``TAG``, ``PRIORITY``, ``RULE_NAME``, ``ENVIRONMENT``, ``PLAYBOOK``, ``PRODUCT``, ``CASE_STATUS``, ``CASE_CLOSE_REASON``, ``STAGE``, ``SLA_STATUS``, ``TASK_STATUS``, ``PLAYBOOK_TOTAL_RUNS``, ``CASE_OPEN_AND_CLOSURE_TRENDS``. + - `dimension_b` (optional): Secondary grouping, same enum. + - `chart_type` (optional): ``PIE``, ``HORIZENTAL_BARS``, ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, ``SOCS_STATUS``, ``ROI``, ``TEXT``, ``IMAGE``, ``NUMBER_STATUS``. + - `results_amount` (optional): Max result buckets. + - `results_order` (optional): ``DESCENDING`` or ``ASCENDING``. + - `calculate_field` (optional): ``COUNT``, ``HANDLING_AVG_TIME``, ``SLA_AVG_REMAINING_TIME``, ``TASK_DUE_DATE_AVG_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + - `extra_fields` (optional): Other camelCase request fields, mainly list filters: ``tagFilters``, ``productFilters``, ``analystFilters``, ``priorityFilter`` (INFORMATIVE/LOW/MEDIUM/ HIGH/CRITICAL), ``environmentFilter``, ``entityTypeFilter``, ``caseStatusesFilter`` (OPENED/CLOSED/ALL/MERGED), ``alertNamesFilter``, ``importanceStatusesFilter`` (bools), ``incidentStatusesFilter`` (bools), ``exceededSlaFilter`` (bools), ``closeReasonsFilter`` (MALICIOUS/NOT_MALICIOUS/MAINTENANCE/ INCONCLUSIVE/UNKNOWN), ``caseRootCauseFilter``, ``caseStageFilter``, ``entityIdentifierFilter``, ``networksFilter``, ``portsFilter``, ``categoryOutcomeFilter``, ``playbooksFilter``, ``integrationsFilter``, ``playbookStatusFilter`` (FINISHED_SUCCESSFULLY/ERROR/ WAITING_FOR_USER_ACTION/DECLINED/PLAYBOOK_CLOSED_ALERT/PENDING/ TERMINATED), ``startStage``, ``endStage``, ``text``, ``groupByField``, ``socAnalystsLoadManagerUsername``. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": {}}`` or ``{"error": ...}``. + +- **`legacy_soar_delete_dashboard_widget(widget_id, project_id=None, customer_id=None, region=None)`** + - **Description:** Delete a single widget from a legacy SOAR dashboard. + - **Parameters:** + - `widget_id` (required): Numeric widget ID to delete. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"deleted": True, "widget_id": ...}`` merged with the API response, or ``{"error": ...}``. + +- **`legacy_soar_get_dashboard_widget_values(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Compute the data series for a legacy SOAR dashboard widget. + - **Parameters:** + - `widget` (optional): Full widget definition (camelCase keys) as returned by legacy_soar_get_dashboard; dict or JSON string. Explicit arguments below override its keys. + - `widget_id` (optional): Widget ID (``id``; 0 for new). + - `dashboard_id` (optional): Owning dashboard ID (``dashboardId``). + - `title` (optional): Widget title. + - `widget_type` (optional): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + - `width_size` (optional): ``ONE_BLOCK``, ``TWO_BLOCKS``, ``THREE_BLOCKS``. + - `description` (optional): Widget description. + - `order` (optional): Position in the dashboard. + - `time_filter` (optional): LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `subject` (optional): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + - `dimension_a` (optional): Primary grouping (CustomWidgetObjectEnum), e.g. ``ANALYST``, ``TAG``, ``PRIORITY``, ``RULE_NAME``, ``ENVIRONMENT``, ``PLAYBOOK``, ``PRODUCT``, ``CASE_STATUS``, ``CASE_CLOSE_REASON``, ``STAGE``, ``SLA_STATUS``, ``TASK_STATUS``, ``PLAYBOOK_TOTAL_RUNS``, ``CASE_OPEN_AND_CLOSURE_TRENDS``. + - `dimension_b` (optional): Secondary grouping, same enum. + - `chart_type` (optional): ``PIE``, ``HORIZENTAL_BARS``, ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, ``SOCS_STATUS``, ``ROI``, ``TEXT``, ``IMAGE``, ``NUMBER_STATUS``. + - `results_amount` (optional): Max result buckets. + - `results_order` (optional): ``DESCENDING`` or ``ASCENDING``. + - `calculate_field` (optional): ``COUNT``, ``HANDLING_AVG_TIME``, ``SLA_AVG_REMAINING_TIME``, ``TASK_DUE_DATE_AVG_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + - `extra_fields` (optional): Other camelCase request fields, mainly list filters: ``tagFilters``, ``productFilters``, ``analystFilters``, ``priorityFilter`` (INFORMATIVE/LOW/MEDIUM/ HIGH/CRITICAL), ``environmentFilter``, ``entityTypeFilter``, ``caseStatusesFilter`` (OPENED/CLOSED/ALL/MERGED), ``alertNamesFilter``, ``importanceStatusesFilter`` (bools), ``incidentStatusesFilter`` (bools), ``exceededSlaFilter`` (bools), ``closeReasonsFilter`` (MALICIOUS/NOT_MALICIOUS/MAINTENANCE/ INCONCLUSIVE/UNKNOWN), ``caseRootCauseFilter``, ``caseStageFilter``, ``entityIdentifierFilter``, ``networksFilter``, ``portsFilter``, ``categoryOutcomeFilter``, ``playbooksFilter``, ``integrationsFilter``, ``playbookStatusFilter`` (FINISHED_SUCCESSFULLY/ERROR/ WAITING_FOR_USER_ACTION/DECLINED/PLAYBOOK_CLOSED_ALERT/PENDING/ TERMINATED), ``startStage``, ``endStage``, ``text``, ``groupByField``, ``socAnalystsLoadManagerUsername``. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": {}}`` or ``{"error": ...}``. + +- **`legacy_soar_get_dashboard_widget_case_ids(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List the SOAR case IDs behind a legacy dashboard widget. + - **Parameters:** + - `widget` (optional): Full widget definition (camelCase keys) as returned by legacy_soar_get_dashboard; dict or JSON string. Explicit arguments below override its keys. + - `widget_id` (optional): Widget ID (``id``; 0 for an ad-hoc widget). + - `dashboard_id` (optional): Owning dashboard ID (``dashboardId``). + - `title` (optional): Widget title. + - `widget_type` (optional): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + - `width_size` (optional): ``ONE_BLOCK``, ``TWO_BLOCKS``, ``THREE_BLOCKS``. + - `description` (optional): Widget description. + - `order` (optional): Position in the dashboard. + - `time_filter` (optional): LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `subject` (optional): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + - `dimension_a` (optional): Primary grouping, e.g. ``ANALYST``, ``PRIORITY``, ``CASE_STATUS``, ``PLAYBOOK``, ``STAGE``. + - `dimension_b` (optional): Secondary grouping, same enum. + - `chart_type` (optional): ``PIE``, ``HORIZENTAL_BARS``, ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, ``NUMBER_STATUS`` and others. + - `results_amount` (optional): Max result buckets. + - `results_order` (optional): ``DESCENDING`` or ``ASCENDING``. + - `calculate_field` (optional): ``COUNT``, ``HANDLING_AVG_TIME``, ``SLA_AVG_REMAINING_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + - `extra_fields` (optional): Other camelCase request fields such as ``caseStatusesFilter``, ``priorityFilter``, ``analystFilters``, ``environmentFilter``, ``playbooksFilter``, ``playbookStatusFilter``; full key list in legacy_soar_add_or_update_dashboard_widget. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": [, ...], "total_results": N}`` or ``{"error": ...}``. + +- **`legacy_soar_get_dashboard_custom_widget_case_ids(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List the SOAR case IDs behind a CUSTOM legacy dashboard widget. + - **Parameters:** + - `widget` (optional): Full widget definition (camelCase keys) as returned by legacy_soar_get_dashboard; dict or JSON string. Explicit arguments below override its keys. + - `widget_id` (optional): Widget ID (``id``; 0 for an ad-hoc widget). + - `dashboard_id` (optional): Owning dashboard ID (``dashboardId``). + - `title` (optional): Widget title. + - `widget_type` (optional): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + - `width_size` (optional): ``ONE_BLOCK``, ``TWO_BLOCKS``, ``THREE_BLOCKS``. + - `description` (optional): Widget description. + - `order` (optional): Position in the dashboard. + - `time_filter` (optional): LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `subject` (optional): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + - `dimension_a` (optional): Primary grouping, e.g. ``ANALYST``, ``PRIORITY``, ``CASE_STATUS``, ``PLAYBOOK``, ``STAGE``. + - `dimension_b` (optional): Secondary grouping, same enum. + - `chart_type` (optional): ``PIE``, ``HORIZENTAL_BARS``, ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, ``NUMBER_STATUS`` and others. + - `results_amount` (optional): Max result buckets. + - `results_order` (optional): ``DESCENDING`` or ``ASCENDING``. + - `calculate_field` (optional): ``COUNT``, ``HANDLING_AVG_TIME``, ``SLA_AVG_REMAINING_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + - `extra_fields` (optional): Other camelCase request fields such as ``caseStatusesFilter``, ``priorityFilter``, ``analystFilters``, ``environmentFilter``, ``playbooksFilter``, ``playbookStatusFilter``; full key list in legacy_soar_add_or_update_dashboard_widget. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": [, ...], "total_results": N}`` or ``{"error": ...}``. + +- **`legacy_soar_get_dashboard_playbook_runs(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List recent SOAR playbook runs behind a legacy dashboard widget. + - **Parameters:** + - `widget` (optional): Full widget definition (camelCase keys) as returned by legacy_soar_get_dashboard; dict or JSON string. Explicit arguments below override its keys. + - `widget_id` (optional): Widget ID (``id``; 0 for an ad-hoc widget). + - `dashboard_id` (optional): Owning dashboard ID (``dashboardId``). + - `title` (optional): Widget title. + - `widget_type` (optional): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + - `width_size` (optional): ``ONE_BLOCK``, ``TWO_BLOCKS``, ``THREE_BLOCKS``. + - `description` (optional): Widget description. + - `order` (optional): Position in the dashboard. + - `time_filter` (optional): LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `subject` (optional): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + - `dimension_a` (optional): Primary grouping, e.g. ``ANALYST``, ``PRIORITY``, ``CASE_STATUS``, ``PLAYBOOK``, ``STAGE``. + - `dimension_b` (optional): Secondary grouping, same enum. + - `chart_type` (optional): ``PIE``, ``HORIZENTAL_BARS``, ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, ``NUMBER_STATUS`` and others. + - `results_amount` (optional): Max result buckets. + - `results_order` (optional): ``DESCENDING`` or ``ASCENDING``. + - `calculate_field` (optional): ``COUNT``, ``HANDLING_AVG_TIME``, ``SLA_AVG_REMAINING_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + - `extra_fields` (optional): Other camelCase request fields such as ``caseStatusesFilter``, ``priorityFilter``, ``analystFilters``, ``environmentFilter``, ``playbooksFilter``, ``playbookStatusFilter``; full key list in legacy_soar_add_or_update_dashboard_widget. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": [], "total_results": N}`` or ``{"error": ...}``. + +- **`legacy_soar_get_playbook_monitoring(workflow_identifier=None, time_filter=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Get the monitoring dashboard for a SOAR playbook. + - **Parameters:** + - `workflow_identifier` (optional): Playbook (workflow) identifier. + - `time_filter` (optional): One of DASHBOARD_TIME_RANGE_TYPE_UNSPECIFIED, LAST_DAY, LAST_3_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": {}}`` or ``{"error": ...}``. + +- **`legacy_soar_get_cases_time_to_respond(time_range_filter=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Get the average time-to-respond for SOAR cases in a time range. + - **Parameters:** + - `time_range_filter` (optional): One of LAST_DAY, LAST_2_DAYS, LAST_3_DAYS, LAST_4_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR, LAST_13_MONTHS (``CUSTOM`` is the proto default and is not useful here). + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"timeToResponseValue": }`` or ``{"error": ...}``. + +- **`legacy_soar_get_opened_and_closed_cases_trends(time_range_type, project_id=None, customer_id=None, region=None)`** + - **Description:** Get opened vs. closed SOAR case trends over a time range. + - **Parameters:** + - `time_range_type` (required): Required. One of LAST_DAY, LAST_2_DAYS, LAST_3_DAYS, LAST_4_DAYS, LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR, LAST_13_MONTHS. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"payload": [], "total_results": N}`` or ``{"error": ...}``. + ## Usage Examples ### Example 1: Natural Language Security Event Search @@ -1133,6 +1674,66 @@ Entities previously on this watchlist will now use their base risk scores without the 4.0x multiplier. ``` +### Example 9: Building and Scheduling a SIEM Dashboard + +``` +User: Build a private dashboard showing login activity by user over the past week. + +AI uses: create_dashboard( + display_name="Login Activity", + description="Logins by user, past 7 days" +) + +AI: Created dashboard "Login Activity" (ID: 41fff27d-...). Adding the chart now. + +AI uses: add_dashboard_chart( + dashboard_id="41fff27d-...", + display_name="Logins by user", + query="metadata.event_type = \"USER_LOGIN\"\nmatch:\n principal.user.userid\noutcome:\n $count = count(metadata.id)\norder:\n $count desc\nlimit: 20", + time_unit="DAY", + start_time_val=7 +) + +AI: Added the chart. Preview of the data behind it: + +AI uses: execute_dashboard_query( + query="metadata.event_type = \"USER_LOGIN\"\nmatch:\n principal.user.userid\noutcome:\n $count = count(metadata.id)\norder:\n $count desc\nlimit: 20", + time_unit="DAY", + start_time_val=7 +) + +AI: Top principals: alice (412 logins), svc-backup (288), bob (97). + +User: Widen the window to 30 days and email it to the SOC lead every Monday. + +AI uses: edit_dashboard_chart( + dashboard_id="41fff27d-...", + chart_id="8c86e30e-...", + edit_mask="dashboardQuery.input", + time_unit="DAY", + start_time_val=30 +) + +AI uses: create_dashboard_scheduled_report( + display_name="Weekly login activity", + dashboard_id="41fff27d-...", + cron="0 9 * * 1", + time_zone="America/New_York", + recipients=["soc-lead@example.com"] +) + +AI: The chart now covers 30 days and a PDF report is scheduled for 09:00 +every Monday (America/New_York). + +User: Back the dashboard up so we can restore it in the EU tenant. + +AI uses: export_dashboards(dashboard_ids=["41fff27d-..."]) +AI uses: import_dashboards(dashboards=, project_id="eu-project", region="europe") + +AI: Exported one dashboard with its chart and query and imported it into the +EU instance as nativeDashboards/06f7f12a-.... +``` + ## Additional Resources - [Chronicle API Documentation](https://cloud.google.com/chronicle/docs/reference/rest) diff --git a/server/secops/README.md b/server/secops/README.md index dd8af834..9936e399 100644 --- a/server/secops/README.md +++ b/server/secops/README.md @@ -212,6 +212,163 @@ Chronicle Security Operations suite. - **`fetch_associated_investigations(detection_type, alert_ids=None, case_ids=None, association_limit_per_detection=5, project_id=None, customer_id=None, region=None)`** - Retrieve investigations associated with alerts or cases. Supports filtering by detection type (ALERT or CASE). Returns investigation associations with verdict information. +### Dashboard Management Tools (Native SIEM Dashboards, Charts, Queries) + +- **`list_dashboards(view=None, page_size=None, page_token=None, project_id=None, customer_id=None, region=None)`** + - List native SIEM dashboards in the Chronicle instance. + +- **`get_dashboard(dashboard_id, view=None, project_id=None, customer_id=None, region=None)`** + - Get a native SIEM dashboard by ID or resource name. + +- **`create_dashboard(display_name, description=None, access_type="DASHBOARD_PRIVATE", filters=None, dashboard=None, project_id=None, customer_id=None, region=None)`** + - Create a new (empty) native SIEM dashboard. + +- **`update_dashboard(dashboard_id, display_name=None, description=None, filters=None, dashboard=None, update_mask=None, etag=None, project_id=None, customer_id=None, region=None)`** + - Update fields of a native SIEM dashboard (PATCH with update mask). + +- **`duplicate_dashboard(dashboard_id, display_name, description=None, access_type="DASHBOARD_PRIVATE", dashboard=None, project_id=None, customer_id=None, region=None)`** + - Duplicate a native SIEM dashboard (including its charts and queries). + +- **`delete_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - Delete a custom native SIEM dashboard. + +- **`add_dashboard_chart(dashboard_id, display_name, query=None, time_unit=None, start_time_val=None, start_time=None, end_time=None, visualization=None, description=None, tile_type=None, start_x=None, start_y=None, span_x=None, span_y=None, data_sources=None, chart=None, dashboard_query=None, chart_layout=None, project_id=None, customer_id=None, region=None)`** + - Add a chart (and its UDM stats query) to a native SIEM dashboard. + +- **`edit_dashboard_chart(dashboard_id, chart_id, edit_mask, display_name=None, description=None, query=None, time_unit=None, start_time_val=None, start_time=None, end_time=None, visualization=None, chart=None, dashboard_query=None, etag=None, language_features=None, project_id=None, customer_id=None, region=None)`** + - Edit an existing chart and/or its query on a native SIEM dashboard. + +- **`remove_dashboard_chart(dashboard_id, chart_id, project_id=None, customer_id=None, region=None)`** + - Remove a chart from a native SIEM dashboard. + +- **`duplicate_dashboard_chart(dashboard_id, chart_id, project_id=None, customer_id=None, region=None)`** + - Duplicate a chart within a native SIEM dashboard. + +- **`export_dashboards(dashboard_ids, project_id=None, customer_id=None, region=None)`** + - Export native SIEM dashboards (with charts and queries) as JSON. + +- **`import_dashboards(dashboards, project_id=None, customer_id=None, region=None)`** + - Import native SIEM dashboards from an inline export payload. + +- **`execute_dashboard_query(query=None, time_unit=None, start_time_val=None, start_time=None, end_time=None, query_id=None, dialect=None, filters=None, clear_cache=None, use_previous_time_range=None, dashboard_query=None, project_id=None, customer_id=None, region=None)`** + - Execute a dashboard (UDM stats) query ad hoc and return tabular results. + +- **`get_dashboard_query(query_id, project_id=None, customer_id=None, region=None)`** + - Get a saved dashboard query by ID or resource name. + +- **`get_dashboard_chart(chart_id, project_id=None, customer_id=None, region=None)`** + - Get a dashboard chart by ID or resource name. + +- **`list_dashboard_charts(filter=None, page_size=None, page_token=None, project_id=None, customer_id=None, region=None)`** + - List dashboard charts in the instance (v1alpha only). + +- **`batch_get_dashboard_charts(chart_ids, project_id=None, customer_id=None, region=None)`** + - Get several dashboard charts in one call. + +### Dashboard Scheduled Report and Content Hub Tools + +- **`list_dashboard_scheduled_reports(page_size=None, page_token=None, filter_query=None, view=None, project_id=None, customer_id=None, region=None)`** + - List scheduled email/GCS reports configured for native dashboards. + +- **`get_dashboard_scheduled_report(report_id, project_id=None, customer_id=None, region=None)`** + - Get a single dashboard scheduled report configuration. + +- **`create_dashboard_scheduled_report(display_name, dashboard_id, cron, recipients=None, time_zone=None, file_format=None, description=None, email_subject=None, email_body=None, delivery_type=None, data_access_scopes=None, scheduled_report=None, project_id=None, customer_id=None, region=None)`** + - Create a scheduled report that emails a native dashboard on a cron. + +- **`update_dashboard_scheduled_report(report_id, update_mask=None, display_name=None, description=None, dashboard_id=None, cron=None, time_zone=None, recipients=None, email_subject=None, email_body=None, delivery_type=None, file_format=None, pinned=None, scheduled_report=None, project_id=None, customer_id=None, region=None)`** + - Update fields of an existing dashboard scheduled report (PATCH). + +- **`delete_dashboard_scheduled_report(report_id, project_id=None, customer_id=None, region=None)`** + - Delete a dashboard scheduled report. + +- **`fetch_dashboard_scheduled_report_history(report_id, page_size=None, page_token=None, project_id=None, customer_id=None, region=None)`** + - Fetch the run history of a dashboard scheduled report (last year). + +- **`duplicate_dashboard_scheduled_report(report_id, project_id=None, customer_id=None, region=None)`** + - Duplicate an existing dashboard scheduled report. + +- **`trigger_instant_dashboard_report(report_id, project_id=None, customer_id=None, region=None)`** + - Send a dashboard scheduled report immediately, outside its schedule. + +- **`list_featured_content_dashboards(page_size=None, page_token=None, filter_query=None, project_id=None, customer_id=None, region=None)`** + - List Google-curated dashboards available in the Content Hub. + +- **`get_featured_content_dashboard(featured_dashboard_id, project_id=None, customer_id=None, region=None)`** + - Get one Content Hub featured (curated) native dashboard. + +- **`install_featured_content_dashboard(featured_dashboard_id, featured_dashboard=None, project_id=None, customer_id=None, region=None)`** + - Install a Content Hub curated dashboard as a native dashboard. + +### Legacy (Looker) Dashboard Tools + +- **`get_legacy_dashboard(dashboard_id, view=None, project_id=None, customer_id=None, region=None)`** + - Get a legacy (Looker) Chronicle dashboard by ID. v1alpha-only. + +- **`list_legacy_dashboards(page_size=None, page_token=None, view=None, project_id=None, customer_id=None, region=None)`** + - List legacy (Looker) Chronicle dashboards. v1alpha-only. + +- **`delete_legacy_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - Delete a legacy (Looker) Chronicle dashboard. v1alpha-only. + +- **`copy_legacy_dashboard(dashboard_id, dashboard_type=None, display_name=None, default_dashboard=None, destination=None, project_id=None, customer_id=None, region=None)`** + - Copy a legacy (Looker) dashboard to another dashboard type. v1alpha-only. + +> **Future work (not included):** the Looker-to-native migration endpoints (`dashboards:fetchMigratable`, `dashboards:batchMigrate`, `dashboards:fetchMigrationMetrics`, `dashboards:fetchMigrationJobs`) and legacy dashboard creation (`POST /dashboards`) are intentionally not exposed yet. Migration is gated by a Chronicle feature flag that is still under development (`403 Looker migration wizard is not enabled for the customer`), and legacy creation currently fails server-side, so neither could be verified against a live tenant. Tools for them can be added once the APIs are generally available. + +### Legacy SOAR Dashboard Tools + +- **`legacy_soar_get_dashboard_cards(project_id=None, customer_id=None, region=None)`** + - List legacy SOAR dashboards (ID to name map) visible to the caller. + +- **`legacy_soar_get_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - Get a legacy SOAR dashboard with its configuration and widgets. + +- **`legacy_soar_get_dashboard_widget_definitions(project_id=None, customer_id=None, region=None)`** + - List the predefined widget definitions available for SOAR dashboards. + +- **`legacy_soar_get_dashboard_playbooks(project_id=None, customer_id=None, region=None)`** + - List SOAR playbooks that have data available for dashboard reporting. + +- **`legacy_soar_add_or_update_dashboard(dashboard_id, name=None, creator=None, default_time_range=None, environments=None, allowed_users=None, allowed_users_json=None, widgets=None, project_id=None, customer_id=None, region=None)`** + - Create or update a legacy SOAR (case management) dashboard. + +- **`legacy_soar_delete_dashboard(dashboard_id, project_id=None, customer_id=None, region=None)`** + - Delete a legacy SOAR dashboard and all of its widgets. + +- **`legacy_soar_import_dashboard(dashboard=None, name=None, creator=None, default_time_range=None, environments=None, allowed_users=None, widgets=None, project_id=None, customer_id=None, region=None)`** + - Import a legacy SOAR dashboard from an exported JSON definition. + +- **`legacy_soar_save_dashboard_as_report_template(name, template_id=None, category=None, report_widgets=None, is_system=None, has_scheduler=None, template=None, project_id=None, customer_id=None, region=None)`** + - Save a legacy SOAR dashboard's widgets as a SOAR report template. + +- **`legacy_soar_add_or_update_dashboard_widget(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - Create or update a widget on a legacy SOAR dashboard. + +- **`legacy_soar_delete_dashboard_widget(widget_id, project_id=None, customer_id=None, region=None)`** + - Delete a single widget from a legacy SOAR dashboard. + +- **`legacy_soar_get_dashboard_widget_values(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - Compute the data series for a legacy SOAR dashboard widget. + +- **`legacy_soar_get_dashboard_widget_case_ids(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - List the SOAR case IDs behind a legacy dashboard widget. + +- **`legacy_soar_get_dashboard_custom_widget_case_ids(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - List the SOAR case IDs behind a CUSTOM legacy dashboard widget. + +- **`legacy_soar_get_dashboard_playbook_runs(widget=None, widget_id=None, dashboard_id=None, title=None, widget_type=None, width_size=None, description=None, order=None, time_filter=None, subject=None, dimension_a=None, dimension_b=None, chart_type=None, results_amount=None, results_order=None, calculate_field=None, extra_fields=None, project_id=None, customer_id=None, region=None)`** + - List recent SOAR playbook runs behind a legacy dashboard widget. + +- **`legacy_soar_get_playbook_monitoring(workflow_identifier=None, time_filter=None, project_id=None, customer_id=None, region=None)`** + - Get the monitoring dashboard for a SOAR playbook. + +- **`legacy_soar_get_cases_time_to_respond(time_range_filter=None, project_id=None, customer_id=None, region=None)`** + - Get the average time-to-respond for SOAR cases in a time range. + +- **`legacy_soar_get_opened_and_closed_cases_trends(time_range_type, project_id=None, customer_id=None, region=None)`** + - Get opened vs. closed SOAR case trends over a time range. + ### API Capabilities The MCP server provides the following capabilities: @@ -233,6 +390,7 @@ The MCP server provides the following capabilities: 15. **Rule Exclusions Management**: Create, manage, and monitor rule exclusions to filter false positives and tune detection rules 16. **Watchlist Management**: Create, update, delete, and list watchlists for entity risk scoring 17. **Investigation Management**: Manage cases and investigations, trigger investigations, and fetch associated investigations +18. **Dashboard Management**: Create, update, duplicate, export/import native SIEM dashboards, manage charts and run dashboard (stats) queries, schedule dashboard reports, install Content Hub dashboards, inspect and copy legacy (Looker) dashboards, and manage legacy SOAR dashboards 18. **UDM Search & Export**: Direct UDM querying, field value autocomplete, and CSV export ### Example @@ -254,6 +412,7 @@ These tools focus on core security operations tasks: - **Rule Exclusions Management**: Use `create_rule_exclusion`, `get_rule_exclusion`, `list_rule_exclusions`, `patch_rule_exclusion`, `update_rule_exclusion_deployment`, and `compute_rule_exclusion_activity` to manage false positive filtering and detection rule tuning - **Watchlist Management**: Use `create_watchlist`, `update_watchlist`, `list_watchlists`, `get_watchlist`, and `delete_watchlist` to manage entity watchlists with risk score multipliers for prioritizing high-risk entities - **Investigation Management**: Use `list_investigations`, `get_investigation`, `trigger_investigation`, and `fetch_associated_investigations` to manage investigations and cases +- **Dashboard Management**: Use `list_dashboards`, `get_dashboard`, `create_dashboard`, `add_dashboard_chart`, `execute_dashboard_query`, `export_dashboards`/`import_dashboards`, `create_dashboard_scheduled_report`, `install_featured_content_dashboard`, `copy_legacy_dashboard`, and `legacy_soar_get_dashboard_cards` to build, query, report on, and audit SIEM and SOAR dashboards - **UDM Analysis & Export**: Use `search_udm`, `export_udm_search_csv`, and `find_udm_field_values` for direct UDM querying, data export, and field discovery ### Data Ingestion & Parsing Tools diff --git a/server/secops/secops_mcp/tools/__init__.py b/server/secops/secops_mcp/tools/__init__.py index 1b16e316..39e75714 100644 --- a/server/secops/secops_mcp/tools/__init__.py +++ b/server/secops/secops_mcp/tools/__init__.py @@ -14,11 +14,15 @@ """Security Operations MCP tools package.""" from .curated_rules_management import * +from .dashboard_management import * +from .dashboard_reports import * from .data_table_management import * from .entity_lookup import * from .feed_management import * from .investigation_management import * from .ioc_matches import * +from .legacy_dashboards import * +from .legacy_soar_dashboard import * from .log_ingestion import * from .parser_management import * from .reference_list_management import * diff --git a/server/secops/secops_mcp/tools/dashboard_management.py b/server/secops/secops_mcp/tools/dashboard_management.py new file mode 100644 index 00000000..114674a9 --- /dev/null +++ b/server/secops/secops_mcp/tools/dashboard_management.py @@ -0,0 +1,1292 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Security Operations MCP tools for native SIEM dashboard management. + +Covers the Chronicle ``NativeDashboardService`` (``/nativeDashboards``), +``DashboardQueryService`` (``/dashboardQueries``) and +``DashboardChartService`` (``/dashboardCharts``) REST surfaces. +""" + +import logging +from typing import Any, Dict, List, Optional, Union + +from secops_mcp.server import get_chronicle_client, server +from secops_mcp.utils import chronicle_api_request, parse_json_arg + +# Configure logging +logger = logging.getLogger("secops-mcp") + +_DASHBOARDS = "/nativeDashboards" +_QUERIES = "/dashboardQueries" +_CHARTS = "/dashboardCharts" + + +def _compact(d: Dict[str, Any]) -> Dict[str, Any]: + """Drop ``None`` values from a JSON body.""" + return {k: v for k, v in d.items() if v is not None} + + +def _short_id(value: str) -> str: + """Return the last path segment of a resource name (or the value as-is).""" + return value.rsplit("/", 1)[-1] if "/" in value else value + + +def _full_name(chronicle: Any, collection: str, value: str) -> str: + """Return a full resource name for a bare ID under ``collection``.""" + if value.startswith("projects/"): + return value + return f"{chronicle.instance_id}/{collection}/{_short_id(value)}" + + +def _query_input( + time_unit: Optional[str], + start_time_val: Optional[int], + start_time: Optional[str], + end_time: Optional[str], +) -> Optional[Dict[str, Any]]: + """Build a ``DashboardQuery.Input`` from relative or absolute time args.""" + if start_time or end_time: + return { + "timeWindow": _compact( + {"startTime": start_time, "endTime": end_time} + ) + } + if time_unit or start_time_val is not None: + return { + "relativeTime": _compact( + {"timeUnit": time_unit, "startTimeVal": start_time_val} + ) + } + return None + + +@server.tool() +async def list_dashboards( + view: Optional[str] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List native SIEM dashboards in the Chronicle instance. + + Returns curated (out-of-the-box) and custom dashboards with their names, + display names, type, access and timestamps. Use the FULL view to include + each dashboard's definition (filters and chart layout). + + **Workflow Integration:** + - Start here to discover dashboard IDs before get/update/export calls. + - Pair with `list_dashboard_charts` to inventory charts per dashboard. + + **Use Cases:** + - Inventory dashboards and find curated templates to duplicate + - Audit which dashboards are public versus private + + Args: + view (Optional[str]): NATIVE_DASHBOARD_VIEW_BASIC (default) or + NATIVE_DASHBOARD_VIEW_FULL (includes `definition`). + page_size (Optional[int]): Maximum dashboards per page. + page_token (Optional[str]): `nextPageToken` from a previous call. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"nativeDashboards": [...], "nextPageToken": ..., + "total_dashboards": N}` or `{"error": ...}`. + + Example Usage: + list_dashboards(view="NATIVE_DASHBOARD_VIEW_FULL", page_size=50) + + Next Steps (using MCP-enabled tools): + - `get_dashboard` for the full definition of one dashboard + - `duplicate_dashboard` to clone a curated dashboard for editing + """ + try: + logger.info("Listing native dashboards (view=%s)", view) + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, + "GET", + _DASHBOARDS, + params={"pageSize": page_size, "pageToken": page_token, "view": view}, + ) + result["total_dashboards"] = len(result.get("nativeDashboards", [])) + return result + except Exception as e: + logger.error("Error listing dashboards: %s", e, exc_info=True) + return {"error": f"Error listing dashboards: {e}"} + + +@server.tool() +async def get_dashboard( + dashboard_id: str, + view: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get a native SIEM dashboard by ID or resource name. + + Returns the dashboard metadata and, with the FULL view, its `definition` + containing `filters` and `charts` (each with `dashboardChart` reference + and `chartLayout`). + + **Workflow Integration:** + - Read the definition before `update_dashboard` or `edit_dashboard_chart`. + - Resolve chart resource names to pass to `get_dashboard_chart`. + + **Use Cases:** + - Inspect dashboard layout and filters + - Verify the state after add/remove chart operations + + Args: + dashboard_id (str): Dashboard ID or full resource name + (`projects/.../nativeDashboards/{id}`). + view (Optional[str]): NATIVE_DASHBOARD_VIEW_BASIC or + NATIVE_DASHBOARD_VIEW_FULL. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The NativeDashboard resource or `{"error": ...}`. + + Example Usage: + get_dashboard("a1b2c3d4-...", view="NATIVE_DASHBOARD_VIEW_FULL") + + Next Steps (using MCP-enabled tools): + - `batch_get_dashboard_charts` for all charts referenced in `definition` + - `execute_dashboard_query` to re-run a chart's query ad hoc + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Getting dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, + "GET", + f"{_DASHBOARDS}/{dashboard_id}", + params={"view": view}, + ) + except Exception as e: + logger.error("Error getting dashboard %s: %s", dashboard_id, e, exc_info=True) + return {"error": f"Error getting dashboard: {e}"} + + +@server.tool() +async def create_dashboard( + display_name: str, + description: Optional[str] = None, + access_type: str = "DASHBOARD_PRIVATE", + filters: Optional[Union[List[Dict[str, Any]], str]] = None, + dashboard: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Create a new (empty) native SIEM dashboard. + + Creates the dashboard shell; add charts afterwards with + `add_dashboard_chart`. Dashboard-level filters can be supplied at + creation time or later via `update_dashboard`. + + **Workflow Integration:** + - First step of building a custom dashboard from scratch. + - Use `duplicate_dashboard` instead when starting from a curated one. + + **Use Cases:** + - Build a team-specific dashboard for a detection coverage review + - Script dashboard provisioning across tenants + + Args: + display_name (str): Title shown to users. + description (Optional[str]): Free-text description. + access_type (str): DASHBOARD_PRIVATE (default) or DASHBOARD_PUBLIC; + the API rejects requests without it. + filters (Optional[Union[List[Dict], str]]): DashboardFilter list placed + under `definition.filters`, e.g. + `[{"id": "f1", "displayName": "Hostname", "dataSource": "EVENTS", + "fieldPath": "principal.hostname", + "filterOperatorAndFieldValues": [{"filterOperator": "EQUAL", + "fieldValues": ["host-1"]}]}]`. + dashboard (Optional[Union[Dict, str]]): Additional NativeDashboard + fields (camelCase) merged over the generated body, e.g. + `{"definition": {...}}`. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The created NativeDashboard (with `name`) or + `{"error": ...}`. + + Example Usage: + create_dashboard("Phishing Overview", access_type="DASHBOARD_PUBLIC") + + Next Steps (using MCP-enabled tools): + - `add_dashboard_chart` to populate the dashboard + - `get_dashboard` to confirm the result + """ + try: + logger.info("Creating dashboard %s", display_name) + chronicle = get_chronicle_client(project_id, customer_id, region) + filters = parse_json_arg(filters, "filters") + extra = parse_json_arg(dashboard, "dashboard") or {} + body: Dict[str, Any] = _compact( + { + "displayName": display_name, + "description": description, + "access": access_type, + "type": "CUSTOM", + "definition": {"filters": filters} if filters else None, + } + ) + body.update(extra) + return chronicle_api_request(chronicle, "POST", _DASHBOARDS, json_body=body) + except Exception as e: + logger.error("Error creating dashboard: %s", e, exc_info=True) + return {"error": f"Error creating dashboard: {e}"} + + +@server.tool() +async def update_dashboard( + dashboard_id: str, + display_name: Optional[str] = None, + description: Optional[str] = None, + filters: Optional[Union[List[Dict[str, Any]], str]] = None, + dashboard: Optional[Union[Dict[str, Any], str]] = None, + update_mask: Optional[str] = None, + etag: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Update fields of a native SIEM dashboard (PATCH with update mask). + + The update mask is derived from the convenience arguments supplied + (`displayName`, `description`, `definition.filters`). Pass `dashboard` + plus `update_mask` for any other field, e.g. chart layout changes under + `definition.charts`. + + **Workflow Integration:** + - Rename or re-describe dashboards after `create_dashboard`. + - Change dashboard-level filters without touching charts. + + **Use Cases:** + - Add a mandatory time-range filter to a dashboard + - Rearrange chart layout by patching `definition.charts` + + Args: + dashboard_id (str): Dashboard ID or full resource name. + display_name (Optional[str]): New title. + description (Optional[str]): New description. + filters (Optional[Union[List[Dict], str]]): Replacement DashboardFilter + list for `definition.filters`. + dashboard (Optional[Union[Dict, str]]): Raw NativeDashboard fields + (camelCase) merged over the generated body. + update_mask (Optional[str]): Comma-separated camelCase field paths + (e.g. "displayName,definition.charts"). Merged with the + auto-derived mask. + etag (Optional[str]): Optimistic-concurrency etag from `get_dashboard`. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The updated NativeDashboard or `{"error": ...}`. + + Example Usage: + update_dashboard("a1b2...", display_name="SOC Overview v2") + + Next Steps (using MCP-enabled tools): + - `get_dashboard` with the FULL view to confirm changes + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Updating dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + filters = parse_json_arg(filters, "filters") + extra = parse_json_arg(dashboard, "dashboard") or {} + body: Dict[str, Any] = _compact( + { + "displayName": display_name, + "description": description, + "etag": etag, + "definition": {"filters": filters} if filters is not None else None, + } + ) + body.update(extra) + mask_parts: List[str] = [] + if display_name is not None: + mask_parts.append("displayName") + if description is not None: + mask_parts.append("description") + if filters is not None: + mask_parts.append("definition.filters") + if update_mask: + mask_parts.extend(p.strip() for p in update_mask.split(",") if p.strip()) + if not mask_parts: + return {"error": "update_dashboard requires at least one field to update"} + mask = ",".join(dict.fromkeys(mask_parts)) + return chronicle_api_request( + chronicle, + "PATCH", + f"{_DASHBOARDS}/{dashboard_id}", + params={"updateMask": mask}, + json_body=body, + ) + except Exception as e: + logger.error("Error updating dashboard %s: %s", dashboard_id, e, exc_info=True) + return {"error": f"Error updating dashboard: {e}"} + + +@server.tool() +async def duplicate_dashboard( + dashboard_id: str, + display_name: str, + description: Optional[str] = None, + access_type: str = "DASHBOARD_PRIVATE", + dashboard: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Duplicate a native SIEM dashboard (including its charts and queries). + + Copies an existing dashboard, typically a curated one, into a new custom + dashboard that can be edited freely. + + **Workflow Integration:** + - Preferred way to customize an out-of-the-box dashboard. + - Follow with `add_dashboard_chart` / `edit_dashboard_chart`. + + **Use Cases:** + - Clone the curated "Main Dashboard" and tailor it for a customer + - Snapshot a dashboard before making large edits + + Args: + dashboard_id (str): Source dashboard ID or full resource name. + display_name (str): Title for the copy. + description (Optional[str]): Description for the copy. + access_type (str): DASHBOARD_PRIVATE (default) or DASHBOARD_PUBLIC; + the API rejects requests without it. + dashboard (Optional[Union[Dict, str]]): Extra NativeDashboard fields + (camelCase) merged into the `nativeDashboard` body. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The new NativeDashboard or `{"error": ...}`. + + Example Usage: + duplicate_dashboard("curated-id", display_name="My Main Dashboard") + + Next Steps (using MCP-enabled tools): + - `list_dashboard_charts` filtered to the new dashboard + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Duplicating dashboard %s as %s", dashboard_id, display_name) + chronicle = get_chronicle_client(project_id, customer_id, region) + extra = parse_json_arg(dashboard, "dashboard") or {} + native = _compact( + { + "displayName": display_name, + "description": description, + "access": access_type, + "type": "CUSTOM", + } + ) + native.update(extra) + return chronicle_api_request( + chronicle, + "POST", + f"{_DASHBOARDS}/{dashboard_id}:duplicate", + json_body={"nativeDashboard": native}, + ) + except Exception as e: + logger.error( + "Error duplicating dashboard %s: %s", dashboard_id, e, exc_info=True + ) + return {"error": f"Error duplicating dashboard: {e}"} + + +@server.tool() +async def delete_dashboard( + dashboard_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Delete a custom native SIEM dashboard. + + Permanently removes the dashboard. Curated dashboards cannot be deleted. + + **Workflow Integration:** + - Clean up after `duplicate_dashboard` experiments or `import_dashboards`. + + **Use Cases:** + - Remove obsolete or duplicate dashboards + + Args: + dashboard_id (str): Dashboard ID or full resource name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"status": "deleted", "dashboard_id": ...}` or + `{"error": ...}`. + + Example Usage: + delete_dashboard("a1b2c3d4-...") + + Next Steps (using MCP-enabled tools): + - `list_dashboards` to confirm removal + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Deleting dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, "DELETE", f"{_DASHBOARDS}/{dashboard_id}" + ) + return {"status": "deleted", "dashboard_id": dashboard_id, "response": result} + except Exception as e: + logger.error("Error deleting dashboard %s: %s", dashboard_id, e, exc_info=True) + return {"error": f"Error deleting dashboard: {e}"} + + +@server.tool() +async def add_dashboard_chart( + dashboard_id: str, + display_name: str, + query: Optional[str] = None, + time_unit: Optional[str] = None, + start_time_val: Optional[int] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + visualization: Optional[Union[Dict[str, Any], str]] = None, + description: Optional[str] = None, + tile_type: Optional[str] = None, + start_x: Optional[int] = None, + start_y: Optional[int] = None, + span_x: Optional[int] = None, + span_y: Optional[int] = None, + data_sources: Optional[Union[List[str], str]] = None, + chart: Optional[Union[Dict[str, Any], str]] = None, + dashboard_query: Optional[Union[Dict[str, Any], str]] = None, + chart_layout: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Add a chart (and its UDM stats query) to a native SIEM dashboard. + + Builds an `AddChartRequest` with `dashboardChart`, `dashboardQuery` and + `chartLayout`. Validate the query first with `execute_dashboard_query`. + + **Workflow Integration:** + - Follows `create_dashboard` / `duplicate_dashboard`. + - Layout defaults to `startX=0, startY=0, spanX=48, spanY=26` (curated + charts use spans like 45x26; the API accepts spanX up to 96). `startX` + and `startY` are always sent because the API rejects layouts without + them. + + **Use Cases:** + - Add a "Top alerting rules" table to a SOC overview dashboard + - Add a markdown or button tile (`tile_type`, no query) + + Args: + dashboard_id (str): Target dashboard ID or full resource name. + display_name (str): Chart title. + query (Optional[str]): YARA-L 2.0 UDM stats query, e.g. + `metadata.event_type = "USER_LOGIN" match: principal.user.userid + outcome: $count = count(metadata.id) order: $count desc`. + time_unit (Optional[str]): Relative window unit: SECOND, MINUTE, HOUR, + DAY, WEEK, MONTH, YEAR (default DAY when a query is given). + start_time_val (Optional[int]): Relative window length (default 1). + start_time (Optional[str]): Absolute window start (RFC 3339); with + `end_time` replaces the relative window. + end_time (Optional[str]): Absolute window end (RFC 3339). + visualization (Optional[Union[Dict, str]]): DashboardChart + `visualization` config (ECharts-style), e.g. + `{"series": [{"chartType": "TABLE"}]}`. + description (Optional[str]): Chart description. + tile_type (Optional[str]): TILE_TYPE_VISUALIZATION (default), + TILE_TYPE_BUTTON or TILE_TYPE_MARKDOWN. + start_x, start_y, span_x, span_y (Optional[int]): Grid layout. + data_sources (Optional[Union[List[str], str]]): `chartDatasource. + dataSources` for query-backed charts; defaults to `["UDM"]` + when a query is given (the API rejects charts without it). + chart (Optional[Union[Dict, str]]): Raw DashboardChart fields merged + over the generated `dashboardChart`. + dashboard_query (Optional[Union[Dict, str]]): Raw DashboardQuery + fields merged over the generated `dashboardQuery`. + chart_layout (Optional[Union[Dict, str]]): Raw ChartLayout + (`{"startX":0,"startY":0,"spanX":48,"spanY":26}`). + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"nativeDashboard": {...}, "dashboardChart": {...}}` + or `{"error": ...}`. + + Example Usage: + add_dashboard_chart("a1b2...", "Logins by user", + query='metadata.event_type = "USER_LOGIN" match: principal.user.userid outcome: $c = count(metadata.id)', + time_unit="DAY", start_time_val=7, visualization={"series": [{"chartType": "TABLE"}]}) + + Next Steps (using MCP-enabled tools): + - `get_dashboard_chart` on the returned chart name + - `edit_dashboard_chart` to tweak the query or visualization + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Adding chart %s to dashboard %s", display_name, dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + chart_extra = parse_json_arg(chart, "chart") or {} + query_extra = parse_json_arg(dashboard_query, "dashboard_query") or {} + layout_extra = parse_json_arg(chart_layout, "chart_layout") or {} + visualization = parse_json_arg(visualization, "visualization") + + chart_body: Dict[str, Any] = _compact( + { + "displayName": display_name, + "description": description, + "tileType": tile_type, + "visualization": visualization, + } + ) + chart_body.update(chart_extra) + data_sources = parse_json_arg(data_sources, "data_sources") + if (query or query_extra or data_sources) and "chartDatasource" not in chart_body: + chart_body["chartDatasource"] = {"dataSources": data_sources or ["UDM"]} + + query_body: Optional[Dict[str, Any]] = None + if query or query_extra: + if query and not (time_unit or start_time_val is not None or start_time): + time_unit, start_time_val = "DAY", 1 + query_body = _compact( + { + "query": query, + "input": _query_input( + time_unit, start_time_val, start_time, end_time + ), + } + ) + query_body.update(query_extra) + + layout: Dict[str, Any] = { + "startX": start_x if start_x is not None else 0, + "startY": start_y if start_y is not None else 0, + "spanX": span_x if span_x is not None else 48, + "spanY": span_y if span_y is not None else 26, + } + layout.update(layout_extra) + + body = _compact( + { + "dashboardChart": chart_body, + "dashboardQuery": query_body, + "chartLayout": layout, + } + ) + return chronicle_api_request( + chronicle, "POST", f"{_DASHBOARDS}/{dashboard_id}:addChart", json_body=body + ) + except Exception as e: + logger.error( + "Error adding chart to dashboard %s: %s", dashboard_id, e, exc_info=True + ) + return {"error": f"Error adding dashboard chart: {e}"} + + +@server.tool() +async def edit_dashboard_chart( + dashboard_id: str, + chart_id: str, + edit_mask: str, + display_name: Optional[str] = None, + description: Optional[str] = None, + query: Optional[str] = None, + time_unit: Optional[str] = None, + start_time_val: Optional[int] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + visualization: Optional[Union[Dict[str, Any], str]] = None, + chart: Optional[Union[Dict[str, Any], str]] = None, + dashboard_query: Optional[Union[Dict[str, Any], str]] = None, + etag: Optional[str] = None, + language_features: Optional[Union[List[str], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Edit an existing chart and/or its query on a native SIEM dashboard. + + Sends an `EditChartRequest`. `edit_mask` names the fields being changed, + using camelCase `dashboardChart.*` and `dashboardQuery.*` paths. The API + enforces optimistic concurrency: the tool fetches the chart (and, when + the query is edited, its saved DashboardQuery) first and sends the current + `etag` values plus the query `name`, unless you pass them in `chart` / + `dashboard_query`. + + **Workflow Integration:** + - Use after `get_dashboard_chart` / `get_dashboard_query` to see current + values and the chart's `name`. + + **Use Cases:** + - Change a chart's time window from 1 day to 7 days + - Switch a visualization from bar to table + + Args: + dashboard_id (str): Dashboard ID or full resource name. + chart_id (str): Chart ID or full `dashboardCharts/{id}` name. + edit_mask (str): Comma-separated camelCase paths, e.g. + "dashboardChart.displayName,dashboardQuery.query,dashboardQuery.input". + display_name (Optional[str]): New chart title. + description (Optional[str]): New chart description. + query (Optional[str]): New YARA-L 2.0 stats query. + time_unit (Optional[str]): Relative window unit (SECOND..YEAR). + start_time_val (Optional[int]): Relative window length. + start_time (Optional[str]): Absolute window start (RFC 3339). + end_time (Optional[str]): Absolute window end (RFC 3339). + visualization (Optional[Union[Dict, str]]): New `visualization`. + chart (Optional[Union[Dict, str]]): Raw DashboardChart fields merged + over the generated `dashboardChart` (`name` and `etag` are set + automatically from the current chart). + dashboard_query (Optional[Union[Dict, str]]): Raw DashboardQuery + fields merged over the generated `dashboardQuery` (`name` is set + automatically from `chartDatasource.dashboardQuery`). + etag (Optional[str]): Current chart etag; fetched automatically when + omitted. + language_features (Optional[Union[List[str], str]]): JOINS, STAGES, + DATA_TABLES used by the query. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"nativeDashboard": {...}, "dashboardChart": {...}}` + or `{"error": ...}`. + + Example Usage: + edit_dashboard_chart("dash-id", "chart-id", + edit_mask="dashboardQuery.input", time_unit="DAY", start_time_val=7) + + Next Steps (using MCP-enabled tools): + - `execute_dashboard_query` to preview the new results + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Editing chart %s on dashboard %s", chart_id, dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + chart_extra = parse_json_arg(chart, "chart") or {} + query_extra = parse_json_arg(dashboard_query, "dashboard_query") or {} + visualization = parse_json_arg(visualization, "visualization") + language_features = parse_json_arg(language_features, "language_features") + chart_name = _full_name(chronicle, "dashboardCharts", chart_id) + + current: Dict[str, Any] = {} + if not (etag or chart_extra.get("etag")) or ( + (query or query_extra) and not query_extra.get("name") + ): + current = chronicle_api_request( + chronicle, "GET", f"{_CHARTS}/{_short_id(chart_id)}" + ) + + chart_body: Dict[str, Any] = _compact( + { + "name": chart_name, + "displayName": display_name, + "description": description, + "visualization": visualization, + "etag": etag or current.get("etag"), + } + ) + chart_body.update(chart_extra) + + query_body: Dict[str, Any] = _compact( + { + "name": (current.get("chartDatasource") or {}).get("dashboardQuery"), + "query": query, + "input": _query_input(time_unit, start_time_val, start_time, end_time), + } + ) + query_body.update(query_extra) + if list(query_body) == ["name"]: + query_body = {} + elif query_body and query_body.get("name") and not query_body.get("etag"): + saved_query = chronicle_api_request( + chronicle, "GET", f"{_QUERIES}/{_short_id(query_body['name'])}" + ) + if saved_query.get("etag"): + query_body["etag"] = saved_query["etag"] + + body = _compact( + { + "dashboardChart": chart_body, + "dashboardQuery": query_body or None, + "editMask": edit_mask, + "languageFeatures": language_features, + } + ) + return chronicle_api_request( + chronicle, "POST", f"{_DASHBOARDS}/{dashboard_id}:editChart", json_body=body + ) + except Exception as e: + logger.error( + "Error editing chart %s on dashboard %s: %s", + chart_id, + dashboard_id, + e, + exc_info=True, + ) + return {"error": f"Error editing dashboard chart: {e}"} + + +@server.tool() +async def remove_dashboard_chart( + dashboard_id: str, + chart_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Remove a chart from a native SIEM dashboard. + + Sends `:removeChart` with the chart resource name; the chart and its + query are detached from the dashboard. + + **Workflow Integration:** + - Use `get_dashboard` (FULL view) to find chart names first. + + **Use Cases:** + - Prune unused or broken charts from a dashboard + + Args: + dashboard_id (str): Dashboard ID or full resource name. + chart_id (str): Chart ID or full `dashboardCharts/{id}` name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The updated NativeDashboard or `{"error": ...}`. + + Example Usage: + remove_dashboard_chart("dash-id", "chart-id") + + Next Steps (using MCP-enabled tools): + - `get_dashboard` to verify the layout + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Removing chart %s from dashboard %s", chart_id, dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = {"dashboardChart": _full_name(chronicle, "dashboardCharts", chart_id)} + return chronicle_api_request( + chronicle, + "POST", + f"{_DASHBOARDS}/{dashboard_id}:removeChart", + json_body=body, + ) + except Exception as e: + logger.error( + "Error removing chart %s from dashboard %s: %s", + chart_id, + dashboard_id, + e, + exc_info=True, + ) + return {"error": f"Error removing dashboard chart: {e}"} + + +@server.tool() +async def duplicate_dashboard_chart( + dashboard_id: str, + chart_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Duplicate a chart within a native SIEM dashboard. + + Sends `:duplicateChart`; the copy (chart plus query) is appended to the + same dashboard. + + **Workflow Integration:** + - Clone a chart, then `edit_dashboard_chart` the copy with a variant query. + + **Use Cases:** + - Create per-environment variants of the same visualization + + Args: + dashboard_id (str): Dashboard ID or full resource name. + chart_id (str): Chart ID or full `dashboardCharts/{id}` name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"nativeDashboard": {...}, "dashboardChart": {...}}` + (the new chart) or `{"error": ...}`. + + Example Usage: + duplicate_dashboard_chart("dash-id", "chart-id") + + Next Steps (using MCP-enabled tools): + - `edit_dashboard_chart` on the returned `dashboardChart.name` + """ + try: + dashboard_id = _short_id(dashboard_id) + logger.info("Duplicating chart %s on dashboard %s", chart_id, dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = {"dashboardChart": _full_name(chronicle, "dashboardCharts", chart_id)} + return chronicle_api_request( + chronicle, + "POST", + f"{_DASHBOARDS}/{dashboard_id}:duplicateChart", + json_body=body, + ) + except Exception as e: + logger.error( + "Error duplicating chart %s on dashboard %s: %s", + chart_id, + dashboard_id, + e, + exc_info=True, + ) + return {"error": f"Error duplicating dashboard chart: {e}"} + + +@server.tool() +async def export_dashboards( + dashboard_ids: Union[List[str], str], + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Export native SIEM dashboards (with charts and queries) as JSON. + + Returns an inline export (`inlineDestination.dashboards[]`, each holding + `dashboard`, `dashboardCharts` and `dashboardQueries`) that can be fed to + `import_dashboards` in another tenant. + + **Workflow Integration:** + - Back up dashboards before edits; migrate dashboards between tenants. + + **Use Cases:** + - Version-control dashboard definitions + - Promote a dashboard from a lab tenant to production + + Args: + dashboard_ids (Union[List[str], str]): One or more dashboard IDs or + full resource names. A JSON-array string is accepted. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"inlineDestination": {"dashboards": [...]}}` or + `{"error": ...}`. + + Example Usage: + export_dashboards(["dash-1", "dash-2"]) + + Next Steps (using MCP-enabled tools): + - `import_dashboards` with `inlineDestination.dashboards` + """ + try: + logger.info("Exporting dashboards") + chronicle = get_chronicle_client(project_id, customer_id, region) + if isinstance(dashboard_ids, str) and not dashboard_ids.lstrip().startswith("["): + ids: List[str] = [dashboard_ids] + else: + ids = parse_json_arg(dashboard_ids, "dashboard_ids") or [] + names = [_full_name(chronicle, "nativeDashboards", i) for i in ids] + return chronicle_api_request( + chronicle, "POST", f"{_DASHBOARDS}:export", json_body={"names": names} + ) + except Exception as e: + logger.error("Error exporting dashboards: %s", e, exc_info=True) + return {"error": f"Error exporting dashboards: {e}"} + + +@server.tool() +async def import_dashboards( + dashboards: Union[List[Dict[str, Any]], Dict[str, Any], str], + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Import native SIEM dashboards from an inline export payload. + + Accepts the `dashboards` array produced by `export_dashboards` (items of + `{"dashboard": {...}, "dashboardCharts": [...], "dashboardQueries": [...]}`). + Send the export payload unmodified: the API requires `dashboard.name` + and `dashboard.etag` to be present and assigns a new ID on import. Each + `results[]` entry carries the new dashboard resource name and a status. + + **Workflow Integration:** + - Second half of an export/import migration between tenants. + + **Use Cases:** + - Restore dashboards from backup + - Deploy a standard dashboard set to a new tenant + + Args: + dashboards (Union[List[Dict], Dict, str]): Either the list of + NativeDashboardWithChartsAndQueries items, a single item, or the + full `export_dashboards` response. JSON strings are accepted. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"results": [{"dashboard": "projects/.../nativeDashboards/", + "status": {"message": "Dashboard Imported Successfully"}}]}` or + `{"error": ...}`. + + Example Usage: + import_dashboards(export_result["inlineDestination"]["dashboards"]) + + Next Steps (using MCP-enabled tools): + - `list_dashboards` to find the imported dashboards + """ + try: + logger.info("Importing dashboards") + chronicle = get_chronicle_client(project_id, customer_id, region) + payload = parse_json_arg(dashboards, "dashboards") + if isinstance(payload, dict): + if "inlineDestination" in payload: + payload = payload["inlineDestination"].get("dashboards", []) + elif "dashboards" in payload: + payload = payload["dashboards"] + else: + payload = [payload] + if not payload: + return {"error": "import_dashboards requires at least one dashboard"} + body = {"source": {"dashboards": payload}} + return chronicle_api_request( + chronicle, "POST", f"{_DASHBOARDS}:import", json_body=body + ) + except Exception as e: + logger.error("Error importing dashboards: %s", e, exc_info=True) + return {"error": f"Error importing dashboards: {e}"} + + +@server.tool() +async def execute_dashboard_query( + query: Optional[str] = None, + time_unit: Optional[str] = None, + start_time_val: Optional[int] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + query_id: Optional[str] = None, + dialect: Optional[str] = None, + filters: Optional[Union[List[Dict[str, Any]], str]] = None, + clear_cache: Optional[bool] = None, + use_previous_time_range: Optional[bool] = None, + dashboard_query: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Execute a dashboard (UDM stats) query ad hoc and return tabular results. + + Runs the same engine that powers dashboard charts, without saving + anything. Provide either an inline `query` or the `query_id` of a saved + DashboardQuery. Defaults to the past 1 DAY when no window is given. + + **Workflow Integration:** + - Validate a query before `add_dashboard_chart` / `edit_dashboard_chart`. + - Re-run a chart's query with different filters for investigation. + + **Use Cases:** + - Quick aggregation such as top hosts by alert count + - Preview what a chart will render + + Args: + query (Optional[str]): YARA-L 2.0 stats query (`match:` / `outcome:` + sections) or SQL when `dialect="SQL"`. + time_unit (Optional[str]): SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, YEAR. + start_time_val (Optional[int]): Relative window length (e.g. 7). + start_time (Optional[str]): Absolute window start (RFC 3339); with + `end_time` replaces the relative window. + end_time (Optional[str]): Absolute window end (RFC 3339). + query_id (Optional[str]): Saved DashboardQuery ID or resource name to + execute instead of an inline query. + dialect (Optional[str]): YL2 (default) or SQL. + filters (Optional[Union[List[Dict], str]]): DashboardFilter list, e.g. + `[{"fieldPath": "principal.hostname", "dataSource": "EVENTS", + "filterOperatorAndFieldValues": [{"filterOperator": "EQUAL", + "fieldValues": ["host-1"]}]}]`. + clear_cache (Optional[bool]): Bypass the result cache. + use_previous_time_range (Optional[bool]): Run against the preceding + window of the same length (for comparisons). + dashboard_query (Optional[Union[Dict, str]]): Raw DashboardQuery + fields merged over the generated `query` object. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"results": [{"column": ..., "values": [...]}], + "timeWindow": {...}, "queryRuntimeErrors": [...]}` or `{"error": ...}`. + + Example Usage: + execute_dashboard_query( + query='metadata.event_type = "NETWORK_CONNECTION" match: principal.hostname outcome: $c = count(metadata.id) order: $c desc limit: 10', + time_unit="DAY", start_time_val=7) + + Next Steps (using MCP-enabled tools): + - `add_dashboard_chart` with the validated query + - `search_security_events` to drill into underlying events + """ + try: + logger.info("Executing dashboard query") + chronicle = get_chronicle_client(project_id, customer_id, region) + filters = parse_json_arg(filters, "filters") + query_extra = parse_json_arg(dashboard_query, "dashboard_query") or {} + if not (time_unit or start_time_val is not None or start_time or query_extra.get("input")): + time_unit, start_time_val = "DAY", 1 + query_obj: Dict[str, Any] = _compact( + { + "name": _full_name(chronicle, "dashboardQueries", query_id) + if query_id + else None, + "query": query, + "input": _query_input(time_unit, start_time_val, start_time, end_time), + "dialect": dialect, + } + ) + query_obj.update(query_extra) + if not (query_obj.get("query") or query_obj.get("name")): + return {"error": "execute_dashboard_query requires `query` or `query_id`"} + body = _compact( + { + "query": query_obj, + "filters": filters, + "clearCache": clear_cache, + "usePreviousTimeRange": use_previous_time_range, + } + ) + return chronicle_api_request( + chronicle, "POST", f"{_QUERIES}:execute", json_body=body + ) + except Exception as e: + logger.error("Error executing dashboard query: %s", e, exc_info=True) + return {"error": f"Error executing dashboard query: {e}"} + + +@server.tool() +async def get_dashboard_query( + query_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get a saved dashboard query by ID or resource name. + + Returns the DashboardQuery (`query`, `input`, `dialect`, `dashboardChart` + back-reference, `etag`). + + **Workflow Integration:** + - Resolve a chart's `chartDatasource.dashboardQuery` to its query text. + + **Use Cases:** + - Inspect the exact query behind a chart before editing it + + Args: + query_id (str): Query ID or full `dashboardQueries/{id}` name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The DashboardQuery resource or `{"error": ...}`. + + Example Usage: + get_dashboard_query("q-123") + + Next Steps (using MCP-enabled tools): + - `execute_dashboard_query` with `query_id` to run it + """ + try: + query_id = _short_id(query_id) + logger.info("Getting dashboard query %s", query_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request(chronicle, "GET", f"{_QUERIES}/{query_id}") + except Exception as e: + logger.error("Error getting dashboard query %s: %s", query_id, e, exc_info=True) + return {"error": f"Error getting dashboard query: {e}"} + + +@server.tool() +async def get_dashboard_chart( + chart_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get a dashboard chart by ID or resource name. + + Returns the DashboardChart (`displayName`, `tileType`, `chartDatasource` + with the query reference, `visualization`, `nativeDashboard` owner). + + **Workflow Integration:** + - Inspect a chart before `edit_dashboard_chart`. + + **Use Cases:** + - Read a chart's visualization config to replicate it elsewhere + + Args: + chart_id (str): Chart ID or full `dashboardCharts/{id}` name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The DashboardChart resource or `{"error": ...}`. + + Example Usage: + get_dashboard_chart("chart-123") + + Next Steps (using MCP-enabled tools): + - `get_dashboard_query` on `chartDatasource.dashboardQuery` + """ + try: + chart_id = _short_id(chart_id) + logger.info("Getting dashboard chart %s", chart_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request(chronicle, "GET", f"{_CHARTS}/{chart_id}") + except Exception as e: + logger.error("Error getting dashboard chart %s: %s", chart_id, e, exc_info=True) + return {"error": f"Error getting dashboard chart: {e}"} + + +@server.tool() +async def list_dashboard_charts( + filter: Optional[str] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List dashboard charts in the instance (v1alpha only). + + Returns `dashboardCharts`, `nextPageToken` and `totalSize`. Default page + size is 100 (max 1000). + + **Workflow Integration:** + - Inventory charts across dashboards, or find charts by display name. + + **Use Cases:** + - Find every chart that belongs to a given dashboard + - Locate charts by title for bulk edits + + Args: + filter (Optional[str]): Server-side filter expression (e.g. + `native_dashboard = "projects/.../nativeDashboards/{id}"`). + page_size (Optional[int]): Maximum charts per page (<= 1000). + page_token (Optional[str]): `nextPageToken` from a previous call. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"dashboardCharts": [...], "nextPageToken": ..., + "totalSize": N, "total_charts": N}` or `{"error": ...}`. + + Example Usage: + list_dashboard_charts(page_size=200) + + Next Steps (using MCP-enabled tools): + - `get_dashboard_chart` for full chart details + """ + try: + logger.info("Listing dashboard charts (filter=%s)", filter) + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, + "GET", + _CHARTS, + params={"pageSize": page_size, "pageToken": page_token, "filter": filter}, + ) + result["total_charts"] = len(result.get("dashboardCharts", [])) + return result + except Exception as e: + logger.error("Error listing dashboard charts: %s", e, exc_info=True) + return {"error": f"Error listing dashboard charts: {e}"} + + +@server.tool() +async def batch_get_dashboard_charts( + chart_ids: Union[List[str], str], + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get several dashboard charts in one call. + + Sends `GET /dashboardCharts:batchGet` with repeated `names` query + parameters. Handy after `get_dashboard` (FULL view), whose + `definition.charts[].dashboardChart` lists the chart resource names. + + **Workflow Integration:** + - Fetch all charts of a dashboard in a single request. + + **Use Cases:** + - Render or audit a whole dashboard's chart configs at once + + Args: + chart_ids (Union[List[str], str]): Chart IDs or full resource names. + A JSON-array string is accepted. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"dashboardCharts": [...], "total_charts": N}` or + `{"error": ...}`. + + Example Usage: + batch_get_dashboard_charts(["chart-1", "chart-2"]) + + Next Steps (using MCP-enabled tools): + - `get_dashboard_query` for each `chartDatasource.dashboardQuery` + """ + try: + logger.info("Batch getting dashboard charts") + chronicle = get_chronicle_client(project_id, customer_id, region) + if isinstance(chart_ids, str) and not chart_ids.lstrip().startswith("["): + ids: List[str] = [chart_ids] + else: + ids = parse_json_arg(chart_ids, "chart_ids") or [] + names = [_full_name(chronicle, "dashboardCharts", i) for i in ids] + result = chronicle_api_request( + chronicle, "GET", f"{_CHARTS}:batchGet", params={"names": names} + ) + result["total_charts"] = len(result.get("dashboardCharts", [])) + return result + except Exception as e: + logger.error("Error batch getting dashboard charts: %s", e, exc_info=True) + return {"error": f"Error batch getting dashboard charts: {e}"} diff --git a/server/secops/secops_mcp/tools/dashboard_reports.py b/server/secops/secops_mcp/tools/dashboard_reports.py new file mode 100644 index 00000000..1ba2c0f2 --- /dev/null +++ b/server/secops/secops_mcp/tools/dashboard_reports.py @@ -0,0 +1,814 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Security Operations MCP tools for dashboard scheduled reports and +Content Hub featured dashboards. + +Covers the Chronicle ``DashboardScheduledReportService`` (v1alpha-only) and +``FeaturedContentNativeDashboardService`` REST surfaces. +""" + +import logging +from typing import Any, Dict, List, Optional, Union + +from secops_mcp.server import get_chronicle_client, server +from secops_mcp.utils import chronicle_api_request, parse_json_arg + +# Configure logging +logger = logging.getLogger("secops-mcp") + +_SCHEDULED_REPORTS = "/dashboardScheduledReports" +_FEATURED_DASHBOARDS = "/contentHub/featuredContentNativeDashboards" + + +def _compact(value: Any) -> Any: + """Recursively drop ``None`` values from dicts (and nested lists).""" + if isinstance(value, dict): + return {k: _compact(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [_compact(v) for v in value if v is not None] + return value + + +def _resource_id(value: str) -> str: + """Return the last path segment of a resource name or a bare ID.""" + return value.rstrip("/").rsplit("/", 1)[-1] if "/" in value else value + + +def _dashboard_name(chronicle: Any, dashboard: str) -> str: + """Return the bare native dashboard ID expected in ``dashboard.name``. + + The scheduled-report backend stores and resolves ``dashboard.name`` as the + bare dashboard ID (verified live: a full + ``projects/.../nativeDashboards/{id}`` name fails with ``INTERNAL: failed + to fetch native dashboard details``), so full names are reduced to their + last path segment. + """ + del chronicle # kept for signature stability; no expansion needed + return _resource_id(dashboard) + + +def _merge(base: Dict[str, Any], extra: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Shallow-merge ``extra`` over ``base`` (nested dicts merged one level).""" + if not extra: + return base + merged = dict(base) + for key, val in extra.items(): + if isinstance(val, dict) and isinstance(merged.get(key), dict): + merged[key] = {**merged[key], **val} + else: + merged[key] = val + return merged + + +# --------------------------------------------------------------------------- +# Dashboard scheduled reports +# --------------------------------------------------------------------------- + + +@server.tool() +async def list_dashboard_scheduled_reports( + page_size: Optional[int] = None, + page_token: Optional[str] = None, + filter_query: Optional[str] = None, + view: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List scheduled email/GCS reports configured for native dashboards. + + Returns the scheduled report configurations (schedule, delivery targets, + file format, status, last run) in the Chronicle instance. v1alpha-only. + + **Workflow Integration:** + - Start here to inventory which dashboards are being emailed on a + schedule before creating, updating, or deleting a report. + - Pair with `fetch_dashboard_scheduled_report_history` to audit delivery + success rates. + + **Use Cases:** + - Audit who receives automated SOC dashboard reports + - Find reports in SUSPENDED state that need attention + - Locate a report by name before editing it + + Args: + page_size (Optional[int]): Maximum number of reports to return. + page_token (Optional[str]): `nextPageToken` from a previous call. + filter_query (Optional[str]): Case-insensitive substring match applied + to `display_name` and `description`. + view (Optional[str]): `DASHBOARD_SCHEDULED_REPORT_VIEW_BASIC` + (default) or `DASHBOARD_SCHEDULED_REPORT_VIEW_FULL`. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: API response with `dashboardScheduledReports`, + `nextPageToken`, plus `total_reports` for the page, or an error. + + Example Usage: + list_dashboard_scheduled_reports(filter_query="weekly") + + Next Steps (using MCP-enabled tools): + - `get_dashboard_scheduled_report` for full detail on one report + - `fetch_dashboard_scheduled_report_history` to check past runs + """ + try: + logger.info("Listing dashboard scheduled reports") + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, + "GET", + _SCHEDULED_REPORTS, + params={ + "pageSize": page_size, + "pageToken": page_token, + "filter": filter_query, + "view": view, + }, + ) + result["total_reports"] = len( + result.get("dashboardScheduledReports", []) or [] + ) + return result + except Exception as e: + logger.error("Error listing dashboard scheduled reports: %s", e, exc_info=True) + return {"error": f"Error listing dashboard scheduled reports: {e}"} + + +@server.tool() +async def get_dashboard_scheduled_report( + report_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get a single dashboard scheduled report configuration. + + Returns the full `DashboardScheduledReport` resource: display name, + dashboard reference, cron schedule and time zone, delivery details + (email recipients, subject, body), file format, scope, status, etag, + and the last report generation detail. v1alpha-only. + + **Workflow Integration:** + - Call before `update_dashboard_scheduled_report` to see current values + and obtain the `etag`. + + **Use Cases:** + - Confirm recipients and schedule of a report + - Check `lastReportGenerationDetail` for the latest failure message + + Args: + report_id (str): Scheduled report ID or full resource name + (`projects/.../dashboardScheduledReports/{id}`). + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The `DashboardScheduledReport` resource or an error. + + Example Usage: + get_dashboard_scheduled_report(report_id="a1b2c3d4") + + Next Steps (using MCP-enabled tools): + - `update_dashboard_scheduled_report` to change recipients/schedule + - `trigger_instant_dashboard_report` to send it now + """ + try: + rid = _resource_id(report_id) + logger.info("Getting dashboard scheduled report %s", rid) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request(chronicle, "GET", f"{_SCHEDULED_REPORTS}/{rid}") + except Exception as e: + logger.error("Error getting dashboard scheduled report: %s", e, exc_info=True) + return {"error": f"Error getting dashboard scheduled report: {e}"} + + +@server.tool() +async def create_dashboard_scheduled_report( + display_name: str, + dashboard_id: str, + cron: str, + recipients: Optional[Union[List[str], str]] = None, + time_zone: Optional[str] = None, + file_format: Optional[str] = None, + description: Optional[str] = None, + email_subject: Optional[str] = None, + email_body: Optional[str] = None, + delivery_type: Optional[str] = None, + data_access_scopes: Optional[Union[List[str], str]] = None, + scheduled_report: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Create a scheduled report that emails a native dashboard on a cron. + + Builds a `DashboardScheduledReport` from the common fields and POSTs it + to `/dashboardScheduledReports`. Any extra or nested fields can be + supplied via `scheduled_report`, which is merged over the generated + body. v1alpha-only. + + **Workflow Integration:** + - Use `list_native_dashboards` (native dashboard tools) to find the + dashboard ID first. + - Follow up with `trigger_instant_dashboard_report` to test delivery. + + **Use Cases:** + - Email a weekly SOC metrics dashboard as PDF to leadership + - Deliver a daily CSV export of a detections dashboard + + Args: + display_name (str): Report name shown in the UI (required). + dashboard_id (str): Native dashboard ID or full resource name + (`projects/.../nativeDashboards/{id}`); sent as the bare ID in + `dashboard.name`, which is what the API expects. + cron (str): Crontab schedule, 5 fields `min hour dom month dow` + (e.g. "0 8 * * 1" = Mondays 08:00). + recipients (Optional[Union[List[str], str]]): Email recipients + (required by the API for email delivery). JSON array string ok. + time_zone (Optional[str]): IANA time zone for the cron (default UTC). + file_format (Optional[str]): `FILE_FORMAT_PDF`, `FILE_FORMAT_CSV`, or + `FILE_FORMAT_PNG` (required by the API; defaults to PDF here). + description (Optional[str]): Free-text description. + email_subject (Optional[str]): Email subject line. + email_body (Optional[str]): Email body text. + delivery_type (Optional[str]): `DELIVERY_TYPE_EMAIL_ATTACHMENT` or + `DELIVERY_TYPE_GCS_BUCKET`. + data_access_scopes (Optional[Union[List[str], str]]): Full + `dataAccessScopes` resource names for `scopeInfo`. + scheduled_report (Optional[Union[Dict[str, Any], str]]): Extra + `DashboardScheduledReport` JSON (camelCase keys, e.g. + `{"userData": {"pinned": true}, "scopeInfo": {...}}`) merged over + the body built from the arguments above. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The created `DashboardScheduledReport` or an error. + + Example Usage: + create_dashboard_scheduled_report( + display_name="Weekly SOC Summary", dashboard_id="abc123", + cron="0 8 * * 1", time_zone="America/New_York", + recipients=["soc-leads@example.com"], file_format="FILE_FORMAT_PDF") + + Next Steps (using MCP-enabled tools): + - `trigger_instant_dashboard_report` to verify delivery immediately + - `list_dashboard_scheduled_reports` to confirm it is ACTIVE + """ + try: + logger.info("Creating dashboard scheduled report %s", display_name) + chronicle = get_chronicle_client(project_id, customer_id, region) + recipient_list = parse_json_arg(recipients, "recipients") + scopes = parse_json_arg(data_access_scopes, "data_access_scopes") + extra = parse_json_arg(scheduled_report, "scheduled_report") + + body: Dict[str, Any] = { + "displayName": display_name, + "description": description, + "dashboard": {"name": _dashboard_name(chronicle, dashboard_id)}, + "cronDetails": {"cron": cron, "timeZone": time_zone}, + "deliveryDetails": { + "emailDelivery": { + "recipients": recipient_list, + "subject": email_subject, + "emailBody": email_body, + }, + "deliveryType": delivery_type, + }, + "format": {"fileFormat": file_format or "FILE_FORMAT_PDF"}, + "scopeInfo": {"dataAccessScopes": scopes} if scopes else None, + } + body = _compact(_merge(body, extra)) + return chronicle_api_request( + chronicle, "POST", _SCHEDULED_REPORTS, json_body=body + ) + except Exception as e: + logger.error("Error creating dashboard scheduled report: %s", e, exc_info=True) + return {"error": f"Error creating dashboard scheduled report: {e}"} + + +@server.tool() +async def update_dashboard_scheduled_report( + report_id: str, + update_mask: Optional[Union[List[str], str]] = None, + display_name: Optional[str] = None, + description: Optional[str] = None, + dashboard_id: Optional[str] = None, + cron: Optional[str] = None, + time_zone: Optional[str] = None, + recipients: Optional[Union[List[str], str]] = None, + email_subject: Optional[str] = None, + email_body: Optional[str] = None, + delivery_type: Optional[str] = None, + file_format: Optional[str] = None, + pinned: Optional[bool] = None, + scheduled_report: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Update fields of an existing dashboard scheduled report (PATCH). + + Sends `PATCH /dashboardScheduledReports/{id}?updateMask=...` with the + changed fields. If `update_mask` is omitted it is derived from the + arguments you pass. v1alpha-only. + + **Workflow Integration:** + - Call `get_dashboard_scheduled_report` first to see current values. + + **Use Cases:** + - Add or remove email recipients + - Change the cron schedule or time zone + - Switch the file format from PDF to CSV + - Pin a report in the UI + + Args: + report_id (str): Scheduled report ID or full resource name. + update_mask (Optional[Union[List[str], str]]): Field paths to update. + Supported: `display_name`, `description`, `cron_details`, + `format`, `delivery_details`, `scope_info`, `dashboard.name`, + `user_data.pinned`. Comma-separated string, JSON array, or list. + display_name (Optional[str]): New display name. + description (Optional[str]): New description. + dashboard_id (Optional[str]): New native dashboard ID or full name. + cron (Optional[str]): New crontab string (5 fields). + time_zone (Optional[str]): New IANA time zone. + recipients (Optional[Union[List[str], str]]): Replacement recipient + list (whole `delivery_details` is replaced, so pass all fields). + email_subject (Optional[str]): New email subject. + email_body (Optional[str]): New email body. + delivery_type (Optional[str]): `DELIVERY_TYPE_EMAIL_ATTACHMENT` or + `DELIVERY_TYPE_GCS_BUCKET`. + file_format (Optional[str]): `FILE_FORMAT_PDF|CSV|PNG`. + pinned (Optional[bool]): Pin/unpin (`user_data.pinned`). + scheduled_report (Optional[Union[Dict[str, Any], str]]): Extra + resource JSON (camelCase) merged over the generated body, e.g. + `{"scopeInfo": {"dataAccessScopes": [...]}, "etag": "..."}`. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The updated `DashboardScheduledReport` or an error. + + Example Usage: + update_dashboard_scheduled_report(report_id="a1b2c3d4", + cron="0 7 * * 1-5", time_zone="UTC") + + Next Steps (using MCP-enabled tools): + - `get_dashboard_scheduled_report` to confirm the change + """ + try: + rid = _resource_id(report_id) + logger.info("Updating dashboard scheduled report %s", rid) + chronicle = get_chronicle_client(project_id, customer_id, region) + recipient_list = parse_json_arg(recipients, "recipients") + extra = parse_json_arg(scheduled_report, "scheduled_report") + + body: Dict[str, Any] = { + "displayName": display_name, + "description": description, + } + mask: List[str] = [] + if display_name is not None: + mask.append("display_name") + if description is not None: + mask.append("description") + if dashboard_id is not None: + body["dashboard"] = {"name": _dashboard_name(chronicle, dashboard_id)} + mask.append("dashboard.name") + if cron is not None or time_zone is not None: + body["cronDetails"] = {"cron": cron, "timeZone": time_zone} + mask.append("cron_details") + if any( + v is not None + for v in (recipient_list, email_subject, email_body, delivery_type) + ): + body["deliveryDetails"] = { + "emailDelivery": { + "recipients": recipient_list, + "subject": email_subject, + "emailBody": email_body, + }, + "deliveryType": delivery_type, + } + mask.append("delivery_details") + if file_format is not None: + body["format"] = {"fileFormat": file_format} + mask.append("format") + if pinned is not None: + body["userData"] = {"pinned": pinned} + mask.append("user_data.pinned") + if extra and "scopeInfo" in extra: + mask.append("scope_info") + + if update_mask is not None: + if isinstance(update_mask, str) and not update_mask.lstrip().startswith("["): + mask = [p.strip() for p in update_mask.split(",") if p.strip()] + else: + mask = list(parse_json_arg(update_mask, "update_mask") or []) + + body = _compact(_merge(body, extra)) + return chronicle_api_request( + chronicle, + "PATCH", + f"{_SCHEDULED_REPORTS}/{rid}", + params={"updateMask": ",".join(mask) if mask else None}, + json_body=body, + ) + except Exception as e: + logger.error("Error updating dashboard scheduled report: %s", e, exc_info=True) + return {"error": f"Error updating dashboard scheduled report: {e}"} + + +@server.tool() +async def delete_dashboard_scheduled_report( + report_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Delete a dashboard scheduled report. + + Permanently removes the schedule; future deliveries stop. The underlying + native dashboard is not affected. v1alpha-only. + + **Workflow Integration:** + - Verify the target with `get_dashboard_scheduled_report` first. + + **Use Cases:** + - Remove reports for decommissioned dashboards + - Clean up duplicate or test schedules + + Args: + report_id (str): Scheduled report ID or full resource name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"deleted": true, "report_id": ...}` or an error. + + Example Usage: + delete_dashboard_scheduled_report(report_id="a1b2c3d4") + + Next Steps (using MCP-enabled tools): + - `list_dashboard_scheduled_reports` to confirm removal + """ + try: + rid = _resource_id(report_id) + logger.info("Deleting dashboard scheduled report %s", rid) + chronicle = get_chronicle_client(project_id, customer_id, region) + chronicle_api_request(chronicle, "DELETE", f"{_SCHEDULED_REPORTS}/{rid}") + return {"deleted": True, "report_id": rid} + except Exception as e: + logger.error("Error deleting dashboard scheduled report: %s", e, exc_info=True) + return {"error": f"Error deleting dashboard scheduled report: {e}"} + + +@server.tool() +async def fetch_dashboard_scheduled_report_history( + report_id: str, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Fetch the run history of a dashboard scheduled report (last year). + + Calls `GET /dashboardScheduledReports/{id}:fetchHistory`. Each entry has + `reportTime`, `deliveryStatus` (`DELIVERY_STATE_SUCCEEDED`, `_FAILED`, + `_IN_PROGRESS`) and `successDetails` or `errorDetails`. Newest first. + v1alpha-only. + + **Workflow Integration:** + - Use after a recipient reports a missing email, or periodically to + audit delivery reliability. + + **Use Cases:** + - Diagnose why a report stopped arriving + - Count successful runs (`runCounts`) vs all attempts (`totalSize`) + + Args: + report_id (str): Scheduled report ID or full resource name. + page_size (Optional[int]): Max entries (default 50, max 1000). + page_token (Optional[str]): `nextPageToken` from a previous call. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `reportGenerationDetails`, `runCounts`, `totalSize`, + `nextPageToken`, or an error. + + Example Usage: + fetch_dashboard_scheduled_report_history(report_id="a1b2c3d4", + page_size=20) + + Next Steps (using MCP-enabled tools): + - `update_dashboard_scheduled_report` to fix bad recipients + - `trigger_instant_dashboard_report` to retry delivery now + """ + try: + rid = _resource_id(report_id) + logger.info("Fetching history for dashboard scheduled report %s", rid) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, + "GET", + f"{_SCHEDULED_REPORTS}/{rid}:fetchHistory", + params={"pageSize": page_size, "pageToken": page_token}, + ) + except Exception as e: + logger.error( + "Error fetching dashboard scheduled report history: %s", e, exc_info=True + ) + return {"error": f"Error fetching dashboard scheduled report history: {e}"} + + +@server.tool() +async def duplicate_dashboard_scheduled_report( + report_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Duplicate an existing dashboard scheduled report. + + Calls `POST /dashboardScheduledReports/{id}:duplicate` and returns the + newly created copy, which can then be edited independently. v1alpha-only. + + **Workflow Integration:** + - Clone a known-good report, then `update_dashboard_scheduled_report` + the copy with a different recipient list or schedule. + + **Use Cases:** + - Create per-team variants of the same dashboard report + - Stage schedule changes without touching the original + + Args: + report_id (str): Source scheduled report ID or full resource name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The new `DashboardScheduledReport` or an error. + + Example Usage: + duplicate_dashboard_scheduled_report(report_id="a1b2c3d4") + + Next Steps (using MCP-enabled tools): + - `update_dashboard_scheduled_report` on the returned `name` + """ + try: + rid = _resource_id(report_id) + logger.info("Duplicating dashboard scheduled report %s", rid) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, "POST", f"{_SCHEDULED_REPORTS}/{rid}:duplicate", json_body={} + ) + except Exception as e: + logger.error("Error duplicating dashboard scheduled report: %s", e, exc_info=True) + return {"error": f"Error duplicating dashboard scheduled report: {e}"} + + +@server.tool() +async def trigger_instant_dashboard_report( + report_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Send a dashboard scheduled report immediately, outside its schedule. + + Calls `POST /dashboardScheduledReports/{id}:trigger`. This is a + long-running operation; the response is an `Operation` whose `done` + flag may be false initially. v1alpha-only. + + **Workflow Integration:** + - Use right after creating or updating a report to validate delivery. + + **Use Cases:** + - Send an on-demand copy of a dashboard to stakeholders + - Retry a failed scheduled delivery + + Args: + report_id (str): Scheduled report ID or full resource name. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The long-running `Operation` (`name`, `done`, + optional `metadata`/`response`) or an error. + + Example Usage: + trigger_instant_dashboard_report(report_id="a1b2c3d4") + + Next Steps (using MCP-enabled tools): + - `fetch_dashboard_scheduled_report_history` to see the run result + """ + try: + rid = _resource_id(report_id) + logger.info("Triggering instant dashboard report %s", rid) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, "POST", f"{_SCHEDULED_REPORTS}/{rid}:trigger", json_body={} + ) + except Exception as e: + logger.error("Error triggering instant dashboard report: %s", e, exc_info=True) + return {"error": f"Error triggering instant dashboard report: {e}"} + + +# --------------------------------------------------------------------------- +# Content Hub featured (curated) native dashboards +# --------------------------------------------------------------------------- + + +@server.tool() +async def list_featured_content_dashboards( + page_size: Optional[int] = None, + page_token: Optional[str] = None, + filter_query: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List Google-curated dashboards available in the Content Hub. + + Calls `GET /contentHub/featuredContentNativeDashboards`. Each entry has + a `name`, `contentMetadata` (description, etc.), and the full + `dashboardContent` (dashboard, charts, queries) that would be installed. + + **Workflow Integration:** + - Browse here before `install_featured_content_dashboard` to add a + ready-made dashboard to the instance. + + **Use Cases:** + - Discover curated dashboards for a log source or use case + - Compare curated chart definitions against custom dashboards + + Args: + page_size (Optional[int]): Max results (default and max 100). + page_token (Optional[str]): `nextPageToken` from a previous call. + filter_query (Optional[str]): AIP-160 filter over `name` and + `content_metadata.description`; a bare literal (e.g. `"okta"`) + does a substring match on both. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `featuredContentNativeDashboards`, `nextPageToken`, + plus `total_featured_dashboards` for the page, or an error. + + Example Usage: + list_featured_content_dashboards(filter_query="okta") + + Next Steps (using MCP-enabled tools): + - `get_featured_content_dashboard` for one entry + - `install_featured_content_dashboard` to install it + """ + try: + logger.info("Listing featured content native dashboards") + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, + "GET", + _FEATURED_DASHBOARDS, + params={ + "pageSize": page_size, + "pageToken": page_token, + "filter": filter_query, + }, + ) + result["total_featured_dashboards"] = len( + result.get("featuredContentNativeDashboards", []) or [] + ) + return result + except Exception as e: + logger.error("Error listing featured content dashboards: %s", e, exc_info=True) + return {"error": f"Error listing featured content dashboards: {e}"} + + +@server.tool() +async def get_featured_content_dashboard( + featured_dashboard_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get one Content Hub featured (curated) native dashboard. + + Calls `GET /contentHub/featuredContentNativeDashboards/{id}` and returns + the `FeaturedContentNativeDashboard` including its `dashboardContent` + (dashboard definition, charts and queries). + + **Workflow Integration:** + - Inspect the curated charts and UDM queries before installing. + + **Use Cases:** + - Review what a curated dashboard contains + - Borrow chart/query definitions for a custom dashboard + + Args: + featured_dashboard_id (str): Featured dashboard ID or full resource + name (`projects/.../contentHub/featuredContentNativeDashboards/{id}`). + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: The `FeaturedContentNativeDashboard` or an error. + + Example Usage: + get_featured_content_dashboard(featured_dashboard_id="okta-overview") + + Next Steps (using MCP-enabled tools): + - `install_featured_content_dashboard` to install it + """ + try: + fid = _resource_id(featured_dashboard_id) + logger.info("Getting featured content dashboard %s", fid) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request(chronicle, "GET", f"{_FEATURED_DASHBOARDS}/{fid}") + except Exception as e: + logger.error("Error getting featured content dashboard: %s", e, exc_info=True) + return {"error": f"Error getting featured content dashboard: {e}"} + + +@server.tool() +async def install_featured_content_dashboard( + featured_dashboard_id: str, + featured_dashboard: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Install a Content Hub curated dashboard as a native dashboard. + + Calls `POST /contentHub/featuredContentNativeDashboards/{id}:install`. + The server copies the curated dashboard, charts and queries into a new + `NativeDashboard` owned by the instance and returns its resource name. + + **Workflow Integration:** + - Browse with `list_featured_content_dashboards`, then install; the + returned `nativeDashboard` can be managed with the native dashboard + tools (edit charts, schedule reports). + + **Use Cases:** + - Quickly stand up a curated dashboard for a newly onboarded log source + - Re-install a curated dashboard after local edits went wrong + + Args: + featured_dashboard_id (str): Featured dashboard ID or full resource + name. + featured_dashboard (Optional[Union[Dict[str, Any], str]]): Optional + `FeaturedContentNativeDashboard` JSON sent as + `featuredContentNativeDashboard` in the body, e.g. to override + `dashboardContent.dashboard.displayName` before install. Usually + omitted. + project_id (Optional[str]): Google Cloud project ID. + customer_id (Optional[str]): Chronicle customer ID. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + + Returns: + Dict[str, Any]: `{"nativeDashboard": "projects/.../nativeDashboards/{id}"}` + or an error. + + Example Usage: + install_featured_content_dashboard(featured_dashboard_id="okta-overview") + + Next Steps (using MCP-enabled tools): + - `get_native_dashboard` on the returned name to inspect it + - `create_dashboard_scheduled_report` to email it on a schedule + """ + try: + fid = _resource_id(featured_dashboard_id) + logger.info("Installing featured content dashboard %s", fid) + chronicle = get_chronicle_client(project_id, customer_id, region) + overrides = parse_json_arg(featured_dashboard, "featured_dashboard") + body = _compact({"featuredContentNativeDashboard": overrides}) + return chronicle_api_request( + chronicle, "POST", f"{_FEATURED_DASHBOARDS}/{fid}:install", json_body=body + ) + except Exception as e: + logger.error("Error installing featured content dashboard: %s", e, exc_info=True) + return {"error": f"Error installing featured content dashboard: {e}"} diff --git a/server/secops/secops_mcp/tools/legacy_dashboards.py b/server/secops/secops_mcp/tools/legacy_dashboards.py new file mode 100644 index 00000000..d163e6c5 --- /dev/null +++ b/server/secops/secops_mcp/tools/legacy_dashboards.py @@ -0,0 +1,336 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Security Operations MCP tools for legacy (Looker) dashboards. + +Chronicle's legacy dashboards live under ``/dashboards`` and are backed by +Looker (LookML). They are distinct from the native dashboards under +``/nativeDashboards`` (see ``list_dashboards`` / ``get_dashboard``). Every +method in this module is v1alpha-only. The migration tools convert legacy +dashboards into native dashboards and report on migration progress. +""" + +import logging +from typing import Any, Dict, Optional, Union + +from secops_mcp.server import get_chronicle_client, server +from secops_mcp.utils import chronicle_api_request, parse_json_arg + +# Configure logging +logger = logging.getLogger("secops-mcp") + +_DASHBOARDS_PATH = "/dashboards" + + +def _compact(d: Dict[str, Any]) -> Dict[str, Any]: + """Drop ``None`` values from a JSON body.""" + return {k: v for k, v in d.items() if v is not None} + + +def _dashboard_id(value: str) -> str: + """Return the bare dashboard ID from an ID or a full resource name.""" + return value.rsplit("/", 1)[-1] if "/" in value else value + + +def _dashboard_name(chronicle: Any, value: str) -> str: + """Return the full ``.../dashboards/{id}`` resource name.""" + if value.startswith("projects/"): + return value + return f"{chronicle.instance_id}{_DASHBOARDS_PATH}/{_dashboard_id(value)}" + + +def _build_dashboard_resource( + dashboard: Optional[Union[Dict[str, Any], str]], + display_name: Optional[str], + dashboard_type: Optional[str], + lookml: Optional[str], + default_dashboard: Optional[bool], +) -> Dict[str, Any]: + """Merge a raw Dashboard dict with convenience fields.""" + body = dict(parse_json_arg(dashboard, "dashboard") or {}) + body.update( + _compact( + { + "displayName": display_name, + "type": dashboard_type, + "lookml": lookml, + "defaultDashboard": default_dashboard, + } + ) + ) + return body + + +@server.tool() +async def get_legacy_dashboard( + dashboard_id: str, + view: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get a legacy (Looker) Chronicle dashboard by ID. v1alpha-only. + + Retrieves one dashboard from the legacy ``/dashboards`` collection. + Use ``view="DASHBOARD_VIEW_EXPORT"`` to request the LookML definition. + The response's ``migrationInfo`` (when present) shows whether this + dashboard has already been migrated to a native dashboard and which one. + + **Workflow Integration:** + - Inspect a legacy dashboard before deciding to migrate or delete it. + - Export LookML for backup or re-creation. + + **Use Cases:** + - Check ``migrationInfo.state`` / ``migrationInfo.nativeDashboard`` + - Export a dashboard definition as LookML + + Args: + dashboard_id (str): Dashboard ID or full resource name + ``projects/.../instances/.../dashboards/{id}``. + view (Optional[str]): ``DASHBOARD_VIEW_BASIC`` (default, no LookML), + ``DASHBOARD_VIEW_EXPORT`` (LookML, no URI) or + ``DASHBOARD_VIEW_FULL`` (everything). + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: The Dashboard resource, or an error message. + + Example Usage: + get_legacy_dashboard("1234abcd", view="DASHBOARD_VIEW_EXPORT") + + Next Steps (using MCP-enabled tools): + - ``get_dashboard`` (native) on ``migrationInfo.nativeDashboard`` + - ``copy_legacy_dashboard`` to make an editable copy + """ + try: + logger.info("Getting legacy dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, + "GET", + f"{_DASHBOARDS_PATH}/{_dashboard_id(dashboard_id)}", + params={"view": view}, + ) + except Exception as e: + logger.error("Error getting legacy dashboard: %s", e, exc_info=True) + return {"error": f"Error getting legacy dashboard: {e}"} + + +@server.tool() +async def list_legacy_dashboards( + page_size: Optional[int] = None, + page_token: Optional[str] = None, + view: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List legacy (Looker) Chronicle dashboards. v1alpha-only. + + Lists the legacy ``/dashboards`` collection: predefined (read-only, + Chronicle-provided), user and customer dashboards. This is NOT the native + dashboard list; use ``list_dashboards`` for ``/nativeDashboards``. The API + exposes no filter parameter, only paging and ``view``. + + **Workflow Integration:** + - Inventory legacy dashboards ahead of a migration to native dashboards. + + **Use Cases:** + - Audit which Looker dashboards still exist in the instance + - Find dashboard IDs for ``get_legacy_dashboard`` / ``delete_legacy_dashboard`` + + Args: + page_size (Optional[int]): Maximum dashboards per page. + page_token (Optional[str]): ``nextPageToken`` from a previous call. + view (Optional[str]): ``DASHBOARD_VIEW_BASIC`` (default), + ``DASHBOARD_VIEW_EXPORT`` or ``DASHBOARD_VIEW_FULL``. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"dashboards": [...], "nextPageToken": "...", + "total_dashboards": N}`` or an error message. + + Example Usage: + list_legacy_dashboards(page_size=50) + + Next Steps (using MCP-enabled tools): + - ``get_legacy_dashboard`` for details and ``migrationInfo`` + - ``list_dashboards`` to view the native dashboards + """ + try: + logger.info("Listing legacy dashboards (page_size=%s)", page_size) + chronicle = get_chronicle_client(project_id, customer_id, region) + response = chronicle_api_request( + chronicle, + "GET", + _DASHBOARDS_PATH, + params={ + "pageSize": page_size, + "pageToken": page_token, + "view": view, + }, + ) + response["total_dashboards"] = len(response.get("dashboards", [])) + return response + except Exception as e: + logger.error("Error listing legacy dashboards: %s", e, exc_info=True) + return {"error": f"Error listing legacy dashboards: {e}"} + + +@server.tool() +async def delete_legacy_dashboard( + dashboard_id: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Delete a legacy (Looker) Chronicle dashboard. v1alpha-only. + + Permanently removes a dashboard from the legacy ``/dashboards`` + collection. Predefined and other ``readOnly`` dashboards cannot be + deleted. Deleting a legacy dashboard does not affect any native dashboard + it was migrated into. + + **Workflow Integration:** + - Clean up legacy dashboards after confirming the migrated native + dashboard (``migrationInfo.nativeDashboard``) is correct. + + **Use Cases:** + - Retire Looker dashboards once migration is complete + - Remove obsolete user or customer dashboards + + Args: + dashboard_id (str): Dashboard ID or full resource name. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"status": "deleted", "dashboard_id": ...}`` or an + error message. + + Example Usage: + delete_legacy_dashboard("1234abcd") + + Next Steps (using MCP-enabled tools): + - ``list_legacy_dashboards`` to confirm removal + """ + try: + logger.info("Deleting legacy dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + bare_id = _dashboard_id(dashboard_id) + chronicle_api_request( + chronicle, "DELETE", f"{_DASHBOARDS_PATH}/{bare_id}" + ) + return {"status": "deleted", "dashboard_id": bare_id} + except Exception as e: + logger.error("Error deleting legacy dashboard: %s", e, exc_info=True) + return {"error": f"Error deleting legacy dashboard: {e}"} + + +@server.tool() +async def copy_legacy_dashboard( + dashboard_id: str, + dashboard_type: Optional[str] = None, + display_name: Optional[str] = None, + default_dashboard: Optional[bool] = None, + destination: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Copy a legacy (Looker) dashboard to another dashboard type. v1alpha-only. + + Calls ``POST /dashboards/{id}:copy``. The copy is created as a new legacy + dashboard of the destination type (``DASHBOARD_TYPE_USER`` or + ``DASHBOARD_TYPE_CUSTOMER``); ``DASHBOARD_TYPE_PREDEFINED`` cannot be a + destination. Typical use is turning a read-only predefined dashboard into + an editable user copy. This stays within the legacy family; migrating to + a native dashboard is done in the Chronicle UI migration wizard. + + **Workflow Integration:** + - Copy a predefined dashboard so it can be edited. + - Promote a personal dashboard to a customer-wide one. + + **Use Cases:** + - Create an editable copy of a Chronicle-provided dashboard + - Share a user dashboard with all users of the instance + + Args: + dashboard_id (str): Source dashboard ID or full resource name. + dashboard_type (Optional[str]): Destination type, required unless + given in ``destination``: ``DASHBOARD_TYPE_USER`` or + ``DASHBOARD_TYPE_CUSTOMER``. + display_name (Optional[str]): Name for the copy. + default_dashboard (Optional[bool]): Make the copy the default. + destination (Optional[Union[Dict[str, Any], str]]): Destination + Dashboard resource (dict or JSON string), e.g. + ``{"displayName": "My copy", "type": "DASHBOARD_TYPE_USER"}``. + Explicit args above override matching keys. The request body sent + is ``{"destination": {"parent": , "dashboard": {...}}}``. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: The newly created Dashboard resource, or an error. + + Example Usage: + copy_legacy_dashboard("predef-1", dashboard_type="DASHBOARD_TYPE_USER", + display_name="My Overview") + + Next Steps (using MCP-enabled tools): + - ``get_legacy_dashboard`` on the new dashboard's ``name`` + """ + try: + logger.info("Copying legacy dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + dest_dashboard = _build_dashboard_resource( + destination, display_name, dashboard_type, None, default_dashboard + ) + if not dest_dashboard.get("type"): + return { + "error": "dashboard_type (or destination.type) is required " + "and must be DASHBOARD_TYPE_USER or DASHBOARD_TYPE_CUSTOMER" + } + body = { + "destination": { + "parent": chronicle.instance_id, + "dashboard": dest_dashboard, + } + } + return chronicle_api_request( + chronicle, + "POST", + f"{_DASHBOARDS_PATH}/{_dashboard_id(dashboard_id)}:copy", + json_body=body, + ) + except Exception as e: + logger.error("Error copying legacy dashboard: %s", e, exc_info=True) + return {"error": f"Error copying legacy dashboard: {e}"} diff --git a/server/secops/secops_mcp/tools/legacy_soar_dashboard.py b/server/secops/secops_mcp/tools/legacy_soar_dashboard.py new file mode 100644 index 00000000..bb264bc3 --- /dev/null +++ b/server/secops/secops_mcp/tools/legacy_soar_dashboard.py @@ -0,0 +1,1582 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Security Operations MCP tools for legacy SOAR dashboards. + +These tools wrap the ``LegacySoarDashboardService`` custom verbs +(``legacySoarDashboard:legacy*``) of the Chronicle API. The service is +v1alpha-only and proxies to the SOAR (case management) backend, so the +caller's identity must be mapped to a SOAR user. The dashboards managed here +are SOAR case/playbook dashboards, not SIEM native dashboards. +""" + +import logging +from typing import Any, Dict, List, Optional, Union + +from secops_mcp.server import get_chronicle_client, server +from secops_mcp.utils import chronicle_api_request, parse_json_arg + +# Configure logging +logger = logging.getLogger("secops-mcp") + +_VERB_PREFIX = "legacySoarDashboard:" + +# Fields the SOAR backend requires on every widget definition payload. +_WIDGET_REQUIRED_FIELDS = ("id", "dashboardId", "title", "widgetType", "widthSize") + + +def _compact(d: Dict[str, Any]) -> Dict[str, Any]: + """Drop ``None`` values from a JSON body.""" + return {k: v for k, v in d.items() if v is not None} + + +def _verb(name: str) -> str: + """Build the instance-level custom verb path for a legacy SOAR method.""" + return f"{_VERB_PREFIX}{name}" + + +def _parse_list(value: Any, name: str) -> Optional[List[Any]]: + """Normalize a list argument that may arrive as a JSON string.""" + parsed = parse_json_arg(value, name) + if parsed is not None and not isinstance(parsed, list): + raise TypeError(f"{name} must be a JSON array") + return parsed + + +def _parse_dict(value: Any, name: str) -> Dict[str, Any]: + """Normalize a dict argument that may arrive as a JSON string.""" + parsed = parse_json_arg(value, name) + if parsed is None: + return {} + if not isinstance(parsed, dict): + raise TypeError(f"{name} must be a JSON object") + return dict(parsed) + + +def _widget_body( + *, + widget: Any, + widget_id: Optional[int], + dashboard_id: Optional[int], + title: Optional[str], + widget_type: Optional[str], + width_size: Optional[str], + description: Optional[str], + order: Optional[int], + time_filter: Optional[str], + subject: Optional[str], + dimension_a: Optional[str], + dimension_b: Optional[str], + chart_type: Optional[str], + results_amount: Optional[int], + results_order: Optional[str], + calculate_field: Optional[str], + extra_fields: Any, +) -> Dict[str, Any]: + """Merge a widget passthrough object with explicit overrides. + + Precedence (lowest to highest): ``widget`` -> ``extra_fields`` -> explicit + keyword arguments. Validates the fields the SOAR backend requires. + """ + body = _parse_dict(widget, "widget") + body.update(_parse_dict(extra_fields, "extra_fields")) + body.update( + _compact( + { + "id": widget_id, + "dashboardId": dashboard_id, + "title": title, + "widgetType": widget_type, + "widthSize": width_size, + "description": description, + "order": order, + "timeFilter": time_filter, + "subject": subject, + "dimensionA": dimension_a, + "dimensionB": dimension_b, + "chartType": chart_type, + "resultsAmount": results_amount, + "resultsOrder": results_order, + "calculateField": calculate_field, + } + ) + ) + missing = [f for f in _WIDGET_REQUIRED_FIELDS if body.get(f) in (None, "")] + if missing: + raise ValueError( + "Widget definition is missing required field(s): " + ", ".join(missing) + ) + return body + + +# --------------------------------------------------------------------------- +# Dashboard discovery and retrieval +# --------------------------------------------------------------------------- + + +@server.tool() +async def legacy_soar_get_dashboard_cards( + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List legacy SOAR dashboards (ID to name map) visible to the caller. + + SOAR (case management) dashboards are served through the Chronicle API + (v1alpha-only, legacy) and require the caller to be mapped to a SOAR user. + This is the entry point for discovering dashboard IDs. + + **Workflow Integration:** + - Call first to resolve a dashboard name to the numeric ID used by every + other legacy_soar_* tool. + + **Use Cases:** + - Inventory SOC dashboards before reporting or cleanup + - Find the ID of the "SOC Overview" dashboard to fetch its widgets + + Args: + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": {"": "", ...}, + "total_dashboards": N}`` or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard_cards() + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard(dashboard_id=...) for widgets and config + - legacy_soar_get_dashboard_widget_values(...) to render a widget + """ + try: + logger.info("Listing legacy SOAR dashboard cards") + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, "GET", _verb("legacyGetDashboardCards") + ) + cards = result.get("payload") or {} + return {**result, "total_dashboards": len(cards)} + except Exception as e: + logger.error("Error listing legacy SOAR dashboard cards: %s", e, exc_info=True) + return {"error": f"Error listing legacy SOAR dashboard cards: {e}"} + + +@server.tool() +async def legacy_soar_get_dashboard( + dashboard_id: int, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get a legacy SOAR dashboard with its configuration and widgets. + + Returns the SOAR (case management) dashboard data model: name, creator, + default time range, environments, allowed users and the full list of + widget definitions. Served through the Chronicle API (v1alpha-only, + legacy); requires a SOAR-mapped user identity. The API uses POST with the + ID in the body even though this is a read. + + **Workflow Integration:** + - Widget objects in ``payload.widgets`` can be passed as the ``widget`` + argument of legacy_soar_get_dashboard_widget_values and related tools. + + **Use Cases:** + - Review which filters a SOC dashboard applies + - Export a dashboard definition for legacy_soar_import_dashboard + + Args: + dashboard_id (int): Numeric dashboard ID (from + legacy_soar_get_dashboard_cards). + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": {}}`` or + ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard(dashboard_id=12) + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard_widget_values(widget=) + - legacy_soar_add_or_update_dashboard_widget(...) to modify a widget + """ + try: + logger.info("Getting legacy SOAR dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, + "POST", + _verb("legacyGetDashboard"), + json_body={"legacyPayload": dashboard_id}, + ) + except Exception as e: + logger.error( + "Error getting legacy SOAR dashboard %s: %s", dashboard_id, e, exc_info=True + ) + return {"error": f"Error getting legacy SOAR dashboard {dashboard_id}: {e}"} + + +@server.tool() +async def legacy_soar_get_dashboard_widget_definitions( + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List the predefined widget definitions available for SOAR dashboards. + + Returns the catalog of widget templates (SOC metrics and chart types) that + can be placed on a legacy SOAR (case management) dashboard. Served through + the Chronicle API (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Use before legacy_soar_add_or_update_dashboard_widget to pick a valid + widget type, subject, dimensions and chart type. + + **Use Cases:** + - Discover which playbook or SLA widgets exist + - Validate a widget configuration before creating it + + Args: + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": [], + "total_definitions": N}`` or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard_widget_definitions() + + Next Steps (using MCP-enabled tools): + - legacy_soar_add_or_update_dashboard_widget(...) + """ + try: + logger.info("Listing legacy SOAR dashboard widget definitions") + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, "GET", _verb("legacyGetDashboardWidgetDefinitions") + ) + return {**result, "total_definitions": len(result.get("payload") or [])} + except Exception as e: + logger.error( + "Error listing legacy SOAR widget definitions: %s", e, exc_info=True + ) + return {"error": f"Error listing legacy SOAR widget definitions: {e}"} + + +@server.tool() +async def legacy_soar_get_dashboard_playbooks( + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List SOAR playbooks that have data available for dashboard reporting. + + Served through the Chronicle API (v1alpha-only, legacy) and proxied to + the SOAR backend; requires a SOAR-mapped user identity. + + **Workflow Integration:** + - Use the returned playbook identifiers with + legacy_soar_get_playbook_monitoring or as ``playbooksFilter`` values. + + **Use Cases:** + - Find which playbooks can be monitored on a dashboard + - Build a playbook performance report + + Args: + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": [], + "total_playbooks": N}`` or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard_playbooks() + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_playbook_monitoring(workflow_identifier=...) + """ + try: + logger.info("Listing legacy SOAR dashboard playbooks") + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, "GET", _verb("legacyGetDashboardPlaybooks") + ) + return {**result, "total_playbooks": len(result.get("payload") or [])} + except Exception as e: + logger.error( + "Error listing legacy SOAR dashboard playbooks: %s", e, exc_info=True + ) + return {"error": f"Error listing legacy SOAR dashboard playbooks: {e}"} + + +# --------------------------------------------------------------------------- +# Dashboard mutation +# --------------------------------------------------------------------------- + + +@server.tool() +async def legacy_soar_add_or_update_dashboard( + dashboard_id: int, + name: Optional[str] = None, + creator: Optional[str] = None, + default_time_range: Optional[str] = None, + environments: Optional[Union[List[str], str]] = None, + allowed_users: Optional[Union[List[str], str]] = None, + allowed_users_json: Optional[str] = None, + widgets: Optional[Union[List[Dict[str, Any]], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Create or update a legacy SOAR (case management) dashboard. + + The dashboard is created when ``dashboard_id`` is new (use 0 for a new + dashboard) and updated otherwise. Served through the Chronicle API + (v1alpha-only, legacy); requires a SOAR-mapped user identity. + + **Workflow Integration:** + - Fetch the current definition with legacy_soar_get_dashboard, modify, and + send it back here to avoid dropping fields. + + **Use Cases:** + - Create a new SOC overview dashboard scoped to specific environments + - Rename a dashboard or change who can view it + + Args: + dashboard_id (int): Dashboard ID to update, or 0 to create. + name (Optional[str]): Dashboard display name. + creator (Optional[str]): Creator username. + default_time_range (Optional[str]): One of DASHBOARD_TIME_RANGE_TYPE_UNSPECIFIED, LAST_DAY, LAST_3_DAYS, + LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, + LAST_YEAR. + environments (Optional[Union[List[str], str]]): SOAR environment + names the dashboard covers; list or JSON array string. + allowed_users (Optional[Union[List[str], str]]): Usernames allowed to + view the dashboard; list or JSON array string. + allowed_users_json (Optional[str]): Legacy JSON-encoded allowed users + string, if the backend expects it. + widgets (Optional[Union[List[Dict], str]]): Widget definitions + (LegacySoarDashboardWidgetDataModel, camelCase keys) as list or + JSON array string. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: The saved dashboard (``id``, ``name``, ``creator``, + ``widgets``, ...) or ``{"error": ...}``. + + Example Usage: + legacy_soar_add_or_update_dashboard(dashboard_id=0, name="SOC Overview", + default_time_range="LAST_WEEK", environments=["Default Environment"]) + + Next Steps (using MCP-enabled tools): + - legacy_soar_add_or_update_dashboard_widget(dashboard_id=, ...) + """ + try: + logger.info("Adding/updating legacy SOAR dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = _compact( + { + "id": dashboard_id, + "name": name, + "creator": creator, + "defaultTimeRange": default_time_range, + "environments": _parse_list(environments, "environments"), + "allowedUsers": _parse_list(allowed_users, "allowed_users"), + "allowedUsersJson": allowed_users_json, + "widgets": _parse_list(widgets, "widgets"), + } + ) + return chronicle_api_request( + chronicle, "POST", _verb("legacyAddOrUpdateDashboard"), json_body=body + ) + except Exception as e: + logger.error( + "Error adding/updating legacy SOAR dashboard %s: %s", + dashboard_id, + e, + exc_info=True, + ) + return { + "error": f"Error adding/updating legacy SOAR dashboard {dashboard_id}: {e}" + } + + +@server.tool() +async def legacy_soar_delete_dashboard( + dashboard_id: int, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Delete a legacy SOAR dashboard and all of its widgets. + + Irreversible. Served through the Chronicle API (v1alpha-only, legacy); + requires a SOAR-mapped user with dashboard action permissions. The API + uses POST with the ID in the body. + + **Workflow Integration:** + - Confirm the target with legacy_soar_get_dashboard before deleting. + + **Use Cases:** + - Remove obsolete or duplicate SOC dashboards + + Args: + dashboard_id (int): Numeric dashboard ID to delete. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"deleted": True, "dashboard_id": ...}`` merged with + the (normally empty) API response, or ``{"error": ...}``. + + Example Usage: + legacy_soar_delete_dashboard(dashboard_id=12) + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard_cards() to confirm removal + """ + try: + logger.info("Deleting legacy SOAR dashboard %s", dashboard_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, + "POST", + _verb("legacyDeleteDashboard"), + json_body={"legacyPayload": dashboard_id}, + ) + return {**result, "deleted": True, "dashboard_id": dashboard_id} + except Exception as e: + logger.error( + "Error deleting legacy SOAR dashboard %s: %s", + dashboard_id, + e, + exc_info=True, + ) + return {"error": f"Error deleting legacy SOAR dashboard {dashboard_id}: {e}"} + + +@server.tool() +async def legacy_soar_import_dashboard( + dashboard: Optional[Union[Dict[str, Any], str]] = None, + name: Optional[str] = None, + creator: Optional[str] = None, + default_time_range: Optional[str] = None, + environments: Optional[Union[List[str], str]] = None, + allowed_users: Optional[Union[List[str], str]] = None, + widgets: Optional[Union[List[Dict[str, Any]], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Import a legacy SOAR dashboard from an exported JSON definition. + + Creates a dashboard from a definition exported from another SecOps/SOAR + instance (or from legacy_soar_get_dashboard). Served through the Chronicle + API (v1alpha-only, legacy); requires a SOAR-mapped user identity. + + **Workflow Integration:** + - Pass the exported object as ``dashboard``; explicit arguments override + individual keys (e.g. to rename on import). + + **Use Cases:** + - Copy a dashboard between tenants + - Restore a dashboard from a saved definition + + Args: + dashboard (Optional[Union[Dict, str]]): Exported dashboard object with + camelCase keys (``id``, ``name``, ``creator``, ``defaultTimeRange``, + ``environments``, ``allowedUsers``, ``widgets``, ...). Dict or + JSON string. + name (Optional[str]): Override the dashboard name. + creator (Optional[str]): Override the creator username. + default_time_range (Optional[str]): One of DASHBOARD_TIME_RANGE_TYPE_UNSPECIFIED, LAST_DAY, LAST_3_DAYS, + LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, + LAST_YEAR. + environments (Optional[Union[List[str], str]]): Override environments. + allowed_users (Optional[Union[List[str], str]]): Override allowed + users. + widgets (Optional[Union[List[Dict], str]]): Override widget list. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": {}}`` or + ``{"error": ...}``. + + Example Usage: + legacy_soar_import_dashboard(dashboard=exported, name="SOC Overview (copy)") + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard(dashboard_id=) + """ + try: + logger.info("Importing legacy SOAR dashboard") + chronicle = get_chronicle_client(project_id, customer_id, region) + body = _parse_dict(dashboard, "dashboard") + body.update( + _compact( + { + "name": name, + "creator": creator, + "defaultTimeRange": default_time_range, + "environments": _parse_list(environments, "environments"), + "allowedUsers": _parse_list(allowed_users, "allowed_users"), + "widgets": _parse_list(widgets, "widgets"), + } + ) + ) + if not body: + raise ValueError("Provide a dashboard definition or at least a name") + return chronicle_api_request( + chronicle, "POST", _verb("legacyImportDashboard"), json_body=body + ) + except Exception as e: + logger.error("Error importing legacy SOAR dashboard: %s", e, exc_info=True) + return {"error": f"Error importing legacy SOAR dashboard: {e}"} + + +@server.tool() +async def legacy_soar_save_dashboard_as_report_template( + name: str, + template_id: Optional[int] = None, + category: Optional[str] = None, + report_widgets: Optional[Union[List[Dict[str, Any]], str]] = None, + is_system: Optional[bool] = None, + has_scheduler: Optional[bool] = None, + template: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Save a legacy SOAR dashboard's widgets as a SOAR report template. + + Turns a set of dashboard widgets into a reusable report template that can + be rendered or scheduled from the SOAR Reports page. Served through the + Chronicle API (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Take ``payload.widgets`` from legacy_soar_get_dashboard and pass them as + ``report_widgets``. + + **Use Cases:** + - Produce a weekly management report from the SOC dashboard + - Snapshot a dashboard layout for scheduled distribution + + Args: + name (str): Report template name. + template_id (Optional[int]): Existing template ID to overwrite + (``id``); omit to create. + category (Optional[str]): ``ROI``, ``MANAGEMENT``, ``SLA``, ``TIER1`` + or ``GENERAL``. + report_widgets (Optional[Union[List[Dict], str]]): Widget definitions + (camelCase) as list or JSON array string. + is_system (Optional[bool]): Mark as a system template. + has_scheduler (Optional[bool]): Whether a schedule is attached. + template (Optional[Union[Dict, str]]): Full request object + (camelCase keys) used as the base; explicit arguments override. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": {}}`` or + ``{"error": ...}``. + + Example Usage: + legacy_soar_save_dashboard_as_report_template(name="Weekly SOC", + category="MANAGEMENT", report_widgets=dashboard["payload"]["widgets"]) + + Next Steps (using MCP-enabled tools): + - Review the template in the SOAR Reports UI + """ + try: + logger.info("Saving legacy SOAR dashboard as report template %s", name) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = _parse_dict(template, "template") + body.update( + _compact( + { + "id": template_id, + "name": name, + "category": category, + "reportWidgets": _parse_list(report_widgets, "report_widgets"), + "isSystem": is_system, + "hasScheduler": has_scheduler, + } + ) + ) + return chronicle_api_request( + chronicle, + "POST", + _verb("legacySaveDashboardAsReportTemplate"), + json_body=body, + ) + except Exception as e: + logger.error( + "Error saving legacy SOAR dashboard as report template: %s", + e, + exc_info=True, + ) + return {"error": f"Error saving legacy SOAR dashboard as report template: {e}"} + + +# --------------------------------------------------------------------------- +# Widget mutation +# --------------------------------------------------------------------------- + + +@server.tool() +async def legacy_soar_add_or_update_dashboard_widget( + widget: Optional[Union[Dict[str, Any], str]] = None, + widget_id: Optional[int] = None, + dashboard_id: Optional[int] = None, + title: Optional[str] = None, + widget_type: Optional[str] = None, + width_size: Optional[str] = None, + description: Optional[str] = None, + order: Optional[int] = None, + time_filter: Optional[str] = None, + subject: Optional[str] = None, + dimension_a: Optional[str] = None, + dimension_b: Optional[str] = None, + chart_type: Optional[str] = None, + results_amount: Optional[int] = None, + results_order: Optional[str] = None, + calculate_field: Optional[str] = None, + extra_fields: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Create or update a widget on a legacy SOAR dashboard. + + Creates the widget when ``widget_id`` is new (use 0) and updates it + otherwise. Widgets are pie/bar/table/time-series charts over SOAR cases, + alerts, tasks or playbooks with extensive filters. Served through the + Chronicle API (v1alpha-only, legacy); requires a SOAR-mapped user. + Required fields: ``id``, ``dashboardId``, ``title``, ``widgetType``, + ``widthSize``. + + **Workflow Integration:** + - Start from a widget returned by legacy_soar_get_dashboard (pass it as + ``widget``) and override only what changes. + + **Use Cases:** + - Add a "Cases by priority" pie chart to a SOC dashboard + - Change a widget's time range or analyst filter + + Args: + widget (Optional[Union[Dict, str]]): Full widget definition (camelCase + keys) as returned by legacy_soar_get_dashboard; dict or JSON + string. Explicit arguments below override its keys. + widget_id (Optional[int]): Widget ID (``id``; 0 for new). + dashboard_id (Optional[int]): Owning dashboard ID (``dashboardId``). + title (Optional[str]): Widget title. + widget_type (Optional[str]): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + width_size (Optional[str]): ``ONE_BLOCK``, ``TWO_BLOCKS``, + ``THREE_BLOCKS``. + description (Optional[str]): Widget description. + order (Optional[int]): Position in the dashboard. + time_filter (Optional[str]): LAST_DAY, LAST_3_DAYS, LAST_WEEK, + LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + subject (Optional[str]): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + dimension_a (Optional[str]): Primary grouping (CustomWidgetObjectEnum), + e.g. ``ANALYST``, ``TAG``, ``PRIORITY``, ``RULE_NAME``, + ``ENVIRONMENT``, ``PLAYBOOK``, ``PRODUCT``, ``CASE_STATUS``, + ``CASE_CLOSE_REASON``, ``STAGE``, ``SLA_STATUS``, ``TASK_STATUS``, + ``PLAYBOOK_TOTAL_RUNS``, ``CASE_OPEN_AND_CLOSURE_TRENDS``. + dimension_b (Optional[str]): Secondary grouping, same enum. + chart_type (Optional[str]): ``PIE``, ``HORIZENTAL_BARS``, + ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, + ``SOCS_STATUS``, ``ROI``, ``TEXT``, ``IMAGE``, ``NUMBER_STATUS``. + results_amount (Optional[int]): Max result buckets. + results_order (Optional[str]): ``DESCENDING`` or ``ASCENDING``. + calculate_field (Optional[str]): ``COUNT``, ``HANDLING_AVG_TIME``, + ``SLA_AVG_REMAINING_TIME``, ``TASK_DUE_DATE_AVG_TIME``, + ``PERCENTAGE``, ``AVG_RUNTIME``. + extra_fields (Optional[Union[Dict, str]]): Other camelCase request + fields, mainly list filters: ``tagFilters``, ``productFilters``, + ``analystFilters``, ``priorityFilter`` (INFORMATIVE/LOW/MEDIUM/ + HIGH/CRITICAL), ``environmentFilter``, ``entityTypeFilter``, + ``caseStatusesFilter`` (OPENED/CLOSED/ALL/MERGED), + ``alertNamesFilter``, ``importanceStatusesFilter`` (bools), + ``incidentStatusesFilter`` (bools), ``exceededSlaFilter`` (bools), + ``closeReasonsFilter`` (MALICIOUS/NOT_MALICIOUS/MAINTENANCE/ + INCONCLUSIVE/UNKNOWN), ``caseRootCauseFilter``, + ``caseStageFilter``, ``entityIdentifierFilter``, + ``networksFilter``, ``portsFilter``, ``categoryOutcomeFilter``, + ``playbooksFilter``, ``integrationsFilter``, + ``playbookStatusFilter`` (FINISHED_SUCCESSFULLY/ERROR/ + WAITING_FOR_USER_ACTION/DECLINED/PLAYBOOK_CLOSED_ALERT/PENDING/ + TERMINATED), ``startStage``, ``endStage``, ``text``, + ``groupByField``, ``socAnalystsLoadManagerUsername``. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": {}}`` or ``{"error": ...}``. + + Example Usage: + legacy_soar_add_or_update_dashboard_widget(widget_id=0, dashboard_id=12, + title="Cases by priority", widget_type="CUSTOM", width_size="ONE_BLOCK", + subject="CASES", dimension_a="PRIORITY", chart_type="PIE", + time_filter="LAST_WEEK") + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard_widget_values(widget=) + """ + try: + logger.info( + "Adding/updating legacy SOAR dashboard widget %s on dashboard %s", + widget_id, + dashboard_id, + ) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = _widget_body( + widget=widget, + widget_id=widget_id, + dashboard_id=dashboard_id, + title=title, + widget_type=widget_type, + width_size=width_size, + description=description, + order=order, + time_filter=time_filter, + subject=subject, + dimension_a=dimension_a, + dimension_b=dimension_b, + chart_type=chart_type, + results_amount=results_amount, + results_order=results_order, + calculate_field=calculate_field, + extra_fields=extra_fields, + ) + return chronicle_api_request( + chronicle, + "POST", + _verb("legacyAddOrUpdateDashboardWidget"), + json_body=body, + ) + except Exception as e: + logger.error( + "Error adding/updating legacy SOAR dashboard widget: %s", e, exc_info=True + ) + return {"error": f"Error adding/updating legacy SOAR dashboard widget: {e}"} + + +@server.tool() +async def legacy_soar_delete_dashboard_widget( + widget_id: int, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Delete a single widget from a legacy SOAR dashboard. + + Served through the Chronicle API (v1alpha-only, legacy); requires a + SOAR-mapped user identity. The API uses POST with the ID in the body. + + **Workflow Integration:** + - Find widget IDs in ``payload.widgets[].id`` from legacy_soar_get_dashboard. + + **Use Cases:** + - Remove a stale chart from a SOC dashboard + + Args: + widget_id (int): Numeric widget ID to delete. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"deleted": True, "widget_id": ...}`` merged with + the API response, or ``{"error": ...}``. + + Example Usage: + legacy_soar_delete_dashboard_widget(widget_id=345) + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard(dashboard_id=...) to confirm + """ + try: + logger.info("Deleting legacy SOAR dashboard widget %s", widget_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, + "POST", + _verb("legacyDeleteDashboardWidget"), + json_body={"legacyPayload": widget_id}, + ) + return {**result, "deleted": True, "widget_id": widget_id} + except Exception as e: + logger.error( + "Error deleting legacy SOAR dashboard widget %s: %s", + widget_id, + e, + exc_info=True, + ) + return { + "error": f"Error deleting legacy SOAR dashboard widget {widget_id}: {e}" + } + + +# --------------------------------------------------------------------------- +# Widget data queries (all take a widget definition as the POST body) +# --------------------------------------------------------------------------- + + +async def _widget_query( + verb: str, + label: str, + *, + widget: Any, + widget_id: Optional[int], + dashboard_id: Optional[int], + title: Optional[str], + widget_type: Optional[str], + width_size: Optional[str], + description: Optional[str], + order: Optional[int], + time_filter: Optional[str], + subject: Optional[str], + dimension_a: Optional[str], + dimension_b: Optional[str], + chart_type: Optional[str], + results_amount: Optional[int], + results_order: Optional[str], + calculate_field: Optional[str], + extra_fields: Any, + project_id: Optional[str], + customer_id: Optional[str], + region: Optional[str], +) -> Dict[str, Any]: + """Shared body for the widget-definition driven query verbs.""" + try: + logger.info("Getting legacy SOAR %s for widget %s", label, widget_id) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = _widget_body( + widget=widget, + widget_id=widget_id, + dashboard_id=dashboard_id, + title=title, + widget_type=widget_type, + width_size=width_size, + description=description, + order=order, + time_filter=time_filter, + subject=subject, + dimension_a=dimension_a, + dimension_b=dimension_b, + chart_type=chart_type, + results_amount=results_amount, + results_order=results_order, + calculate_field=calculate_field, + extra_fields=extra_fields, + ) + result = chronicle_api_request(chronicle, "POST", _verb(verb), json_body=body) + payload = result.get("payload") + if isinstance(payload, list): + return {**result, "total_results": len(payload)} + return result + except Exception as e: + logger.error("Error getting legacy SOAR %s: %s", label, e, exc_info=True) + return {"error": f"Error getting legacy SOAR {label}: {e}"} + + +@server.tool() +async def legacy_soar_get_dashboard_widget_values( + widget: Optional[Union[Dict[str, Any], str]] = None, + widget_id: Optional[int] = None, + dashboard_id: Optional[int] = None, + title: Optional[str] = None, + widget_type: Optional[str] = None, + width_size: Optional[str] = None, + description: Optional[str] = None, + order: Optional[int] = None, + time_filter: Optional[str] = None, + subject: Optional[str] = None, + dimension_a: Optional[str] = None, + dimension_b: Optional[str] = None, + chart_type: Optional[str] = None, + results_amount: Optional[int] = None, + results_order: Optional[str] = None, + calculate_field: Optional[str] = None, + extra_fields: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Compute the data series for a legacy SOAR dashboard widget. + + Evaluates a widget definition against SOAR case/alert/playbook data and + returns the series used to draw the chart. The widget does not have to be + saved, so this doubles as an ad-hoc SOC metrics query. Served through the + Chronicle API (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Pass a widget from legacy_soar_get_dashboard as ``widget``, or describe + an ad-hoc widget with the explicit arguments. + + **Use Cases:** + - "How many open cases per priority this week?" + - Fetch the numbers behind a dashboard chart for a report + + Args: + widget (Optional[Union[Dict, str]]): Full widget definition (camelCase + keys) as returned by legacy_soar_get_dashboard; dict or JSON + string. Explicit arguments below override its keys. + widget_id (Optional[int]): Widget ID (``id``; 0 for new). + dashboard_id (Optional[int]): Owning dashboard ID (``dashboardId``). + title (Optional[str]): Widget title. + widget_type (Optional[str]): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + width_size (Optional[str]): ``ONE_BLOCK``, ``TWO_BLOCKS``, + ``THREE_BLOCKS``. + description (Optional[str]): Widget description. + order (Optional[int]): Position in the dashboard. + time_filter (Optional[str]): LAST_DAY, LAST_3_DAYS, LAST_WEEK, + LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + subject (Optional[str]): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + dimension_a (Optional[str]): Primary grouping (CustomWidgetObjectEnum), + e.g. ``ANALYST``, ``TAG``, ``PRIORITY``, ``RULE_NAME``, + ``ENVIRONMENT``, ``PLAYBOOK``, ``PRODUCT``, ``CASE_STATUS``, + ``CASE_CLOSE_REASON``, ``STAGE``, ``SLA_STATUS``, ``TASK_STATUS``, + ``PLAYBOOK_TOTAL_RUNS``, ``CASE_OPEN_AND_CLOSURE_TRENDS``. + dimension_b (Optional[str]): Secondary grouping, same enum. + chart_type (Optional[str]): ``PIE``, ``HORIZENTAL_BARS``, + ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, + ``SOCS_STATUS``, ``ROI``, ``TEXT``, ``IMAGE``, ``NUMBER_STATUS``. + results_amount (Optional[int]): Max result buckets. + results_order (Optional[str]): ``DESCENDING`` or ``ASCENDING``. + calculate_field (Optional[str]): ``COUNT``, ``HANDLING_AVG_TIME``, + ``SLA_AVG_REMAINING_TIME``, ``TASK_DUE_DATE_AVG_TIME``, + ``PERCENTAGE``, ``AVG_RUNTIME``. + extra_fields (Optional[Union[Dict, str]]): Other camelCase request + fields, mainly list filters: ``tagFilters``, ``productFilters``, + ``analystFilters``, ``priorityFilter`` (INFORMATIVE/LOW/MEDIUM/ + HIGH/CRITICAL), ``environmentFilter``, ``entityTypeFilter``, + ``caseStatusesFilter`` (OPENED/CLOSED/ALL/MERGED), + ``alertNamesFilter``, ``importanceStatusesFilter`` (bools), + ``incidentStatusesFilter`` (bools), ``exceededSlaFilter`` (bools), + ``closeReasonsFilter`` (MALICIOUS/NOT_MALICIOUS/MAINTENANCE/ + INCONCLUSIVE/UNKNOWN), ``caseRootCauseFilter``, + ``caseStageFilter``, ``entityIdentifierFilter``, + ``networksFilter``, ``portsFilter``, ``categoryOutcomeFilter``, + ``playbooksFilter``, ``integrationsFilter``, + ``playbookStatusFilter`` (FINISHED_SUCCESSFULLY/ERROR/ + WAITING_FOR_USER_ACTION/DECLINED/PLAYBOOK_CLOSED_ALERT/PENDING/ + TERMINATED), ``startStage``, ``endStage``, ``text``, + ``groupByField``, ``socAnalystsLoadManagerUsername``. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": {}}`` + or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard_widget_values(widget_id=0, dashboard_id=12, + title="Open cases by priority", widget_type="CUSTOM", + width_size="ONE_BLOCK", subject="CASES", dimension_a="PRIORITY", + chart_type="PIE", time_filter="LAST_WEEK", + extra_fields={"caseStatusesFilter": ["OPENED"]}) + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard_widget_case_ids(...) to drill into cases + """ + return await _widget_query( + "legacyGetDashboardWidgetValues", + "dashboard widget values", + widget=widget, + widget_id=widget_id, + dashboard_id=dashboard_id, + title=title, + widget_type=widget_type, + width_size=width_size, + description=description, + order=order, + time_filter=time_filter, + subject=subject, + dimension_a=dimension_a, + dimension_b=dimension_b, + chart_type=chart_type, + results_amount=results_amount, + results_order=results_order, + calculate_field=calculate_field, + extra_fields=extra_fields, + project_id=project_id, + customer_id=customer_id, + region=region, + ) + + +@server.tool() +async def legacy_soar_get_dashboard_widget_case_ids( + widget: Optional[Union[Dict[str, Any], str]] = None, + widget_id: Optional[int] = None, + dashboard_id: Optional[int] = None, + title: Optional[str] = None, + widget_type: Optional[str] = None, + width_size: Optional[str] = None, + description: Optional[str] = None, + order: Optional[int] = None, + time_filter: Optional[str] = None, + subject: Optional[str] = None, + dimension_a: Optional[str] = None, + dimension_b: Optional[str] = None, + chart_type: Optional[str] = None, + results_amount: Optional[int] = None, + results_order: Optional[str] = None, + calculate_field: Optional[str] = None, + extra_fields: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List the SOAR case IDs behind a legacy dashboard widget. + + Drill-down from a dashboard metric to the underlying cases. Served through + the Chronicle API (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Use the returned IDs with SOAR case tools (get_case_full_details, etc.). + + **Use Cases:** + - Find which cases make up the "exceeded SLA" count + - Build a case list for a specific analyst and time range + + Args: + widget (Optional[Union[Dict, str]]): Full widget definition (camelCase + keys) as returned by legacy_soar_get_dashboard; dict or JSON + string. Explicit arguments below override its keys. + widget_id (Optional[int]): Widget ID (``id``; 0 for an ad-hoc widget). + dashboard_id (Optional[int]): Owning dashboard ID (``dashboardId``). + title (Optional[str]): Widget title. + widget_type (Optional[str]): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + width_size (Optional[str]): ``ONE_BLOCK``, ``TWO_BLOCKS``, + ``THREE_BLOCKS``. + description (Optional[str]): Widget description. + order (Optional[int]): Position in the dashboard. + time_filter (Optional[str]): LAST_DAY, LAST_3_DAYS, LAST_WEEK, + LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + subject (Optional[str]): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + dimension_a (Optional[str]): Primary grouping, e.g. ``ANALYST``, + ``PRIORITY``, ``CASE_STATUS``, ``PLAYBOOK``, ``STAGE``. + dimension_b (Optional[str]): Secondary grouping, same enum. + chart_type (Optional[str]): ``PIE``, ``HORIZENTAL_BARS``, + ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, + ``NUMBER_STATUS`` and others. + results_amount (Optional[int]): Max result buckets. + results_order (Optional[str]): ``DESCENDING`` or ``ASCENDING``. + calculate_field (Optional[str]): ``COUNT``, ``HANDLING_AVG_TIME``, + ``SLA_AVG_REMAINING_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + extra_fields (Optional[Union[Dict, str]]): Other camelCase request + fields such as ``caseStatusesFilter``, ``priorityFilter``, + ``analystFilters``, ``environmentFilter``, ``playbooksFilter``, + ``playbookStatusFilter``; full key list in + legacy_soar_add_or_update_dashboard_widget. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": [, ...], "total_results": N}`` + or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard_widget_case_ids(widget=dashboard_widget) + + Next Steps (using MCP-enabled tools): + - Inspect individual cases with the SOAR case tools + """ + return await _widget_query( + "legacyGetDashboardWidgetCaseIds", + "dashboard widget case IDs", + widget=widget, + widget_id=widget_id, + dashboard_id=dashboard_id, + title=title, + widget_type=widget_type, + width_size=width_size, + description=description, + order=order, + time_filter=time_filter, + subject=subject, + dimension_a=dimension_a, + dimension_b=dimension_b, + chart_type=chart_type, + results_amount=results_amount, + results_order=results_order, + calculate_field=calculate_field, + extra_fields=extra_fields, + project_id=project_id, + customer_id=customer_id, + region=region, + ) + + +@server.tool() +async def legacy_soar_get_dashboard_custom_widget_case_ids( + widget: Optional[Union[Dict[str, Any], str]] = None, + widget_id: Optional[int] = None, + dashboard_id: Optional[int] = None, + title: Optional[str] = None, + widget_type: Optional[str] = None, + width_size: Optional[str] = None, + description: Optional[str] = None, + order: Optional[int] = None, + time_filter: Optional[str] = None, + subject: Optional[str] = None, + dimension_a: Optional[str] = None, + dimension_b: Optional[str] = None, + chart_type: Optional[str] = None, + results_amount: Optional[int] = None, + results_order: Optional[str] = None, + calculate_field: Optional[str] = None, + extra_fields: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List the SOAR case IDs behind a CUSTOM legacy dashboard widget. + + Same as legacy_soar_get_dashboard_widget_case_ids but for widgets of type + ``CUSTOM`` with user-defined dimensions (``subject``/``dimensionA``/ + ``dimensionB``) and filters. Served through the Chronicle API + (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Use when the widget has ``widgetType: "CUSTOM"``; otherwise prefer + legacy_soar_get_dashboard_widget_case_ids. + + **Use Cases:** + - Drill into a custom "Cases by analyst and priority" chart + + Args: + widget (Optional[Union[Dict, str]]): Full widget definition (camelCase + keys) as returned by legacy_soar_get_dashboard; dict or JSON + string. Explicit arguments below override its keys. + widget_id (Optional[int]): Widget ID (``id``; 0 for an ad-hoc widget). + dashboard_id (Optional[int]): Owning dashboard ID (``dashboardId``). + title (Optional[str]): Widget title. + widget_type (Optional[str]): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + width_size (Optional[str]): ``ONE_BLOCK``, ``TWO_BLOCKS``, + ``THREE_BLOCKS``. + description (Optional[str]): Widget description. + order (Optional[int]): Position in the dashboard. + time_filter (Optional[str]): LAST_DAY, LAST_3_DAYS, LAST_WEEK, + LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + subject (Optional[str]): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + dimension_a (Optional[str]): Primary grouping, e.g. ``ANALYST``, + ``PRIORITY``, ``CASE_STATUS``, ``PLAYBOOK``, ``STAGE``. + dimension_b (Optional[str]): Secondary grouping, same enum. + chart_type (Optional[str]): ``PIE``, ``HORIZENTAL_BARS``, + ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, + ``NUMBER_STATUS`` and others. + results_amount (Optional[int]): Max result buckets. + results_order (Optional[str]): ``DESCENDING`` or ``ASCENDING``. + calculate_field (Optional[str]): ``COUNT``, ``HANDLING_AVG_TIME``, + ``SLA_AVG_REMAINING_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + extra_fields (Optional[Union[Dict, str]]): Other camelCase request + fields such as ``caseStatusesFilter``, ``priorityFilter``, + ``analystFilters``, ``environmentFilter``, ``playbooksFilter``, + ``playbookStatusFilter``; full key list in + legacy_soar_add_or_update_dashboard_widget. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": [, ...], "total_results": N}`` + or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard_custom_widget_case_ids(widget=custom_widget) + + Next Steps (using MCP-enabled tools): + - Inspect individual cases with the SOAR case tools + """ + return await _widget_query( + "legacyGetDashboardCustomWidgetCaseIds", + "dashboard custom widget case IDs", + widget=widget, + widget_id=widget_id, + dashboard_id=dashboard_id, + title=title, + widget_type=widget_type, + width_size=width_size, + description=description, + order=order, + time_filter=time_filter, + subject=subject, + dimension_a=dimension_a, + dimension_b=dimension_b, + chart_type=chart_type, + results_amount=results_amount, + results_order=results_order, + calculate_field=calculate_field, + extra_fields=extra_fields, + project_id=project_id, + customer_id=customer_id, + region=region, + ) + + +@server.tool() +async def legacy_soar_get_dashboard_playbook_runs( + widget: Optional[Union[Dict[str, Any], str]] = None, + widget_id: Optional[int] = None, + dashboard_id: Optional[int] = None, + title: Optional[str] = None, + widget_type: Optional[str] = None, + width_size: Optional[str] = None, + description: Optional[str] = None, + order: Optional[int] = None, + time_filter: Optional[str] = None, + subject: Optional[str] = None, + dimension_a: Optional[str] = None, + dimension_b: Optional[str] = None, + chart_type: Optional[str] = None, + results_amount: Optional[int] = None, + results_order: Optional[str] = None, + calculate_field: Optional[str] = None, + extra_fields: Optional[Union[Dict[str, Any], str]] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """List recent SOAR playbook runs behind a legacy dashboard widget. + + Returns execution records (status, timing, case) for the playbook widget + described by the request. Served through the Chronicle API (v1alpha-only, + legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Typically used with ``subject="PLAYBOOKS"`` widgets and the + ``playbooksFilter`` / ``playbookStatusFilter`` keys in ``extra_fields``. + + **Use Cases:** + - List failed runs of a playbook over the last week + - Audit which cases a playbook touched + + Args: + widget (Optional[Union[Dict, str]]): Full widget definition (camelCase + keys) as returned by legacy_soar_get_dashboard; dict or JSON + string. Explicit arguments below override its keys. + widget_id (Optional[int]): Widget ID (``id``; 0 for an ad-hoc widget). + dashboard_id (Optional[int]): Owning dashboard ID (``dashboardId``). + title (Optional[str]): Widget title. + widget_type (Optional[str]): ``CUSTOM`` or ``PLAYBOOK_MONITORING``. + width_size (Optional[str]): ``ONE_BLOCK``, ``TWO_BLOCKS``, + ``THREE_BLOCKS``. + description (Optional[str]): Widget description. + order (Optional[int]): Position in the dashboard. + time_filter (Optional[str]): LAST_DAY, LAST_3_DAYS, LAST_WEEK, + LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_YEAR. + subject (Optional[str]): ``CASES``, ``ALERTS``, ``TASKS``, ``PLAYBOOKS``. + dimension_a (Optional[str]): Primary grouping, e.g. ``ANALYST``, + ``PRIORITY``, ``CASE_STATUS``, ``PLAYBOOK``, ``STAGE``. + dimension_b (Optional[str]): Secondary grouping, same enum. + chart_type (Optional[str]): ``PIE``, ``HORIZENTAL_BARS``, + ``VERTICAL_BARS``, ``TABLE``, ``TIME_SERIES``, ``TRENDS``, + ``NUMBER_STATUS`` and others. + results_amount (Optional[int]): Max result buckets. + results_order (Optional[str]): ``DESCENDING`` or ``ASCENDING``. + calculate_field (Optional[str]): ``COUNT``, ``HANDLING_AVG_TIME``, + ``SLA_AVG_REMAINING_TIME``, ``PERCENTAGE``, ``AVG_RUNTIME``. + extra_fields (Optional[Union[Dict, str]]): Other camelCase request + fields such as ``caseStatusesFilter``, ``priorityFilter``, + ``analystFilters``, ``environmentFilter``, ``playbooksFilter``, + ``playbookStatusFilter``; full key list in + legacy_soar_add_or_update_dashboard_widget. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": [], + "total_results": N}`` or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_dashboard_playbook_runs(widget_id=0, dashboard_id=12, + title="Failed runs", widget_type="CUSTOM", width_size="ONE_BLOCK", + subject="PLAYBOOKS", time_filter="LAST_WEEK", + extra_fields={"playbookStatusFilter": ["ERROR"]}) + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_playbook_monitoring(workflow_identifier=...) + """ + return await _widget_query( + "legacyGetDashboardPlaybookRuns", + "dashboard playbook runs", + widget=widget, + widget_id=widget_id, + dashboard_id=dashboard_id, + title=title, + widget_type=widget_type, + width_size=width_size, + description=description, + order=order, + time_filter=time_filter, + subject=subject, + dimension_a=dimension_a, + dimension_b=dimension_b, + chart_type=chart_type, + results_amount=results_amount, + results_order=results_order, + calculate_field=calculate_field, + extra_fields=extra_fields, + project_id=project_id, + customer_id=customer_id, + region=region, + ) + + +# --------------------------------------------------------------------------- +# SOC metrics +# --------------------------------------------------------------------------- + + +@server.tool() +async def legacy_soar_get_playbook_monitoring( + workflow_identifier: Optional[str] = None, + time_filter: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get the monitoring dashboard for a SOAR playbook. + + Returns a dashboard-shaped view with total runs, success/failure rates, + average runtime and trends for one playbook. Served through the Chronicle + API (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Get playbook identifiers from legacy_soar_get_dashboard_playbooks. + + **Use Cases:** + - Check whether a playbook's error rate rose this week + - Report average playbook runtime to management + + Args: + workflow_identifier (Optional[str]): Playbook (workflow) identifier. + time_filter (Optional[str]): One of DASHBOARD_TIME_RANGE_TYPE_UNSPECIFIED, LAST_DAY, LAST_3_DAYS, + LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, + LAST_YEAR. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": {}}`` or + ``{"error": ...}``. + + Example Usage: + legacy_soar_get_playbook_monitoring( + workflow_identifier="", time_filter="LAST_WEEK") + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard_playbook_runs(...) for individual runs + """ + try: + logger.info( + "Getting legacy SOAR playbook monitoring for %s", workflow_identifier + ) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = _compact( + {"workflowIdentifier": workflow_identifier, "timeFilter": time_filter} + ) + return chronicle_api_request( + chronicle, "POST", _verb("legacyGetPlaybookMonitoring"), json_body=body + ) + except Exception as e: + logger.error( + "Error getting legacy SOAR playbook monitoring: %s", e, exc_info=True + ) + return {"error": f"Error getting legacy SOAR playbook monitoring: {e}"} + + +@server.tool() +async def legacy_soar_get_cases_time_to_respond( + time_range_filter: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get the average time-to-respond for SOAR cases in a time range. + + SOC responsiveness KPI from the SOAR backend. Served through the + Chronicle API (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Pair with legacy_soar_get_opened_and_closed_cases_trends for a workload + and responsiveness summary. + + **Use Cases:** + - Weekly SOC KPI reporting + - Compare response time across time windows + + Args: + time_range_filter (Optional[str]): One of LAST_DAY, LAST_2_DAYS, LAST_3_DAYS, LAST_4_DAYS, + LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, + LAST_YEAR, LAST_13_MONTHS + (``CUSTOM`` is the proto default and is not useful here). + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"timeToResponseValue": }`` or + ``{"error": ...}``. + + Example Usage: + legacy_soar_get_cases_time_to_respond(time_range_filter="LAST_WEEK") + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_opened_and_closed_cases_trends(time_range_type="LAST_WEEK") + """ + try: + logger.info("Getting legacy SOAR cases time to respond (%s)", time_range_filter) + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, + "GET", + _verb("legacyGetCasesTimeToRespond"), + params={"timeRangeFilter": time_range_filter}, + ) + except Exception as e: + logger.error( + "Error getting legacy SOAR cases time to respond: %s", e, exc_info=True + ) + return {"error": f"Error getting legacy SOAR cases time to respond: {e}"} + + +@server.tool() +async def legacy_soar_get_opened_and_closed_cases_trends( + time_range_type: str, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get opened vs. closed SOAR case trends over a time range. + + Returns a time series of case openings and closures for SOC workload and + resolution-efficiency reporting. Served through the Chronicle API + (v1alpha-only, legacy); requires a SOAR-mapped user. + + **Workflow Integration:** + - Combine with legacy_soar_get_cases_time_to_respond for KPI summaries. + + **Use Cases:** + - Is the case backlog growing or shrinking? + - Month-over-month SOC throughput + + Args: + time_range_type (str): Required. One of LAST_DAY, LAST_2_DAYS, LAST_3_DAYS, LAST_4_DAYS, + LAST_WEEK, LAST_2_WEEKS, LAST_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, + LAST_YEAR, LAST_13_MONTHS. + project_id (Optional[str]): Google Cloud project ID. Defaults to + environment configuration. + customer_id (Optional[str]): Chronicle customer ID. Defaults to + environment configuration. + region (Optional[str]): Chronicle region (e.g., "us", "europe"). + Defaults to environment configuration. + + Returns: + Dict[str, Any]: ``{"payload": [], + "total_results": N}`` or ``{"error": ...}``. + + Example Usage: + legacy_soar_get_opened_and_closed_cases_trends(time_range_type="LAST_MONTH") + + Next Steps (using MCP-enabled tools): + - legacy_soar_get_dashboard_widget_case_ids(...) to drill into cases + """ + try: + logger.info( + "Getting legacy SOAR opened/closed case trends (%s)", time_range_type + ) + chronicle = get_chronicle_client(project_id, customer_id, region) + result = chronicle_api_request( + chronicle, + "GET", + _verb("legacyGetOpenedAndClosedCasesTrends"), + params={"timeRangeType": time_range_type}, + ) + return {**result, "total_results": len(result.get("payload") or [])} + except Exception as e: + logger.error( + "Error getting legacy SOAR opened/closed case trends: %s", e, exc_info=True + ) + return {"error": f"Error getting legacy SOAR opened/closed case trends: {e}"} diff --git a/server/secops/secops_mcp/utils.py b/server/secops/secops_mcp/utils.py index 4a2a1eb7..d38f7ee2 100644 --- a/server/secops/secops_mcp/utils.py +++ b/server/secops/secops_mcp/utils.py @@ -1,7 +1,12 @@ """Utility functions for SecOps MCP.""" +import json +import re from datetime import datetime, timedelta, timezone -from typing import Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union + +_API_VERSION_SUFFIX = re.compile(r"/v1(alpha|beta)?/?$") + def parse_time_range( start_time: Optional[str], @@ -42,3 +47,111 @@ def parse_time_range( raise ValueError(f"Start time ({start_dt}) cannot be after end time ({end_dt})") return start_dt, end_dt + + +def parse_json_arg( + value: Union[None, str, Dict[str, Any], List[Any]], + name: str, +) -> Union[None, Dict[str, Any], List[Any]]: + """Accept a JSON object/array passed either natively or as a JSON string. + + MCP clients differ in whether they pass structured arguments as JSON + values or as JSON-encoded strings. This normalizes both forms. + + Args: + value: The raw argument value. + name: Argument name used in error messages. + + Returns: + The parsed dict/list, or None when value is None or empty string. + + Raises: + ValueError: If value is a string that is not valid JSON. + """ + if value is None or value == "": + return None + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError as e: + raise ValueError(f"{name} must be valid JSON: {e}") from e + raise ValueError(f"{name} must be a JSON object/array or JSON string") + + +def instance_base_url(chronicle: Any, api_version: str = "v1alpha") -> str: + """Return ``https://-chronicle.googleapis.com//``. + + Works across ``secops`` SDK releases: newer releases expose + ``chronicle.base_url`` as a callable ``BaseUrl`` object that accepts an API + version, older releases expose a plain string ending in ``/v1alpha``. + """ + base = chronicle.base_url + if callable(base): + base = base(api_version) + else: + base = _API_VERSION_SUFFIX.sub(f"/{api_version}", str(base).rstrip("/")) + return f"{base}/{chronicle.instance_id}" + + +def chronicle_api_request( + chronicle: Any, + method: str, + path: str, + *, + api_version: str = "v1alpha", + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + timeout: int = 120, +) -> Dict[str, Any]: + """Issue an authenticated Chronicle REST call relative to the instance. + + Args: + chronicle: Chronicle client from ``get_chronicle_client``. + method: HTTP method (``GET``, ``POST``, ``PATCH``, ``DELETE``). + path: Path after ``.../instances/{instance}``. May start with ``/`` + (sub-resource, e.g. ``/nativeDashboards``) or be a custom verb on + the instance itself (e.g. ``legacySoarDashboard:legacyGetDashboard``). + Absolute resource names beginning with ``projects/`` are used as-is + after the API-version prefix. + api_version: ``v1alpha`` (default), ``v1beta`` or ``v1``. + params: Optional query parameters. ``None`` values are dropped. + json_body: Optional JSON request body. + timeout: Request timeout in seconds. + + Returns: + The decoded JSON response (``{}`` for empty bodies). + + Raises: + RuntimeError: On non-2xx responses, with status and body included. + """ + base = instance_base_url(chronicle, api_version) + if path.startswith("projects/"): + url = f"{base.rsplit('/projects/', 1)[0]}/{path}" + elif path.startswith("/"): + url = f"{base}{path}" + else: + url = f"{base}/{path}" + + clean_params = ( + {k: v for k, v in params.items() if v is not None} if params else None + ) + response = chronicle.session.request( + method, + url, + params=clean_params, + json=json_body, + timeout=timeout, + ) + if response.status_code >= 400: + raise RuntimeError( + f"Chronicle API {method} {url} failed with status " + f"{response.status_code}: {response.text}" + ) + if not response.content: + return {} + try: + return response.json() + except ValueError: + return {"raw": response.text} diff --git a/server/secops/tests/test_dashboard_management_unit.py b/server/secops/tests/test_dashboard_management_unit.py new file mode 100644 index 00000000..fc11cecf --- /dev/null +++ b/server/secops/tests/test_dashboard_management_unit.py @@ -0,0 +1,427 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for native SIEM dashboard management tools.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from secops_mcp.tools import dashboard_management as dm + +MODULE = "secops_mcp.tools.dashboard_management" +INSTANCE = "projects/p/locations/us/instances/c" + + +@pytest.fixture +def chronicle_client(): + with patch(f"{MODULE}.get_chronicle_client") as factory: + client = MagicMock() + client.instance_id = INSTANCE + factory.return_value = client + yield client + + +@pytest.fixture +def api(chronicle_client): + with patch(f"{MODULE}.chronicle_api_request") as req: + req.return_value = {"ok": True} + yield req + + +def call(api): + """Return (method, path, params, json_body) of the single API call.""" + api.assert_called_once() + args, kwargs = api.call_args + return args[1], args[2], kwargs.get("params"), kwargs.get("json_body") + + +# --- NativeDashboardService --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_dashboards(api): + api.return_value = {"nativeDashboards": [{"name": "a"}, {"name": "b"}]} + result = await dm.list_dashboards( + view="NATIVE_DASHBOARD_VIEW_FULL", page_size=10, page_token="t" + ) + method, path, params, body = call(api) + assert (method, path) == ("GET", "/nativeDashboards") + assert params == { + "pageSize": 10, + "pageToken": "t", + "view": "NATIVE_DASHBOARD_VIEW_FULL", + } + assert body is None + assert result["total_dashboards"] == 2 + + +@pytest.mark.asyncio +async def test_get_dashboard_normalizes_resource_name(api): + await dm.get_dashboard( + f"{INSTANCE}/nativeDashboards/d1", view="NATIVE_DASHBOARD_VIEW_BASIC" + ) + method, path, params, _ = call(api) + assert (method, path) == ("GET", "/nativeDashboards/d1") + assert params == {"view": "NATIVE_DASHBOARD_VIEW_BASIC"} + + +@pytest.mark.asyncio +async def test_create_dashboard_merges_passthrough(api): + await dm.create_dashboard( + "SOC", + description="desc", + access_type="DASHBOARD_PUBLIC", + filters=[{"id": "f1"}], + dashboard={"etag": "x"}, + ) + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards") + assert body == { + "displayName": "SOC", + "description": "desc", + "access": "DASHBOARD_PUBLIC", + "type": "CUSTOM", + "definition": {"filters": [{"id": "f1"}]}, + "etag": "x", + } + + +@pytest.mark.asyncio +async def test_create_dashboard_parses_json_string_filters(api): + await dm.create_dashboard("SOC", filters=json.dumps([{"id": "f1"}])) + _, _, _, body = call(api) + assert body["definition"] == {"filters": [{"id": "f1"}]} + assert "description" not in body + + +@pytest.mark.asyncio +async def test_update_dashboard_builds_mask(api): + await dm.update_dashboard( + "d1", + display_name="New", + filters=[], + dashboard={"definition": {"charts": []}}, + update_mask="definition.charts", + etag="e1", + ) + method, path, params, body = call(api) + assert (method, path) == ("PATCH", "/nativeDashboards/d1") + assert params == {"updateMask": "displayName,definition.filters,definition.charts"} + assert body == { + "displayName": "New", + "etag": "e1", + "definition": {"charts": []}, + } + + +@pytest.mark.asyncio +async def test_update_dashboard_requires_a_field(api): + result = await dm.update_dashboard("d1") + assert "error" in result + api.assert_not_called() + + +@pytest.mark.asyncio +async def test_duplicate_dashboard(api): + await dm.duplicate_dashboard("d1", "Copy", access_type="DASHBOARD_PRIVATE") + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards/d1:duplicate") + assert body == { + "nativeDashboard": { + "displayName": "Copy", + "access": "DASHBOARD_PRIVATE", + "type": "CUSTOM", + } + } + + +@pytest.mark.asyncio +async def test_delete_dashboard(api): + api.return_value = {} + result = await dm.delete_dashboard("d1") + method, path, _, _ = call(api) + assert (method, path) == ("DELETE", "/nativeDashboards/d1") + assert result["status"] == "deleted" + + +@pytest.mark.asyncio +async def test_add_dashboard_chart_defaults(api): + await dm.add_dashboard_chart( + "d1", + "Logins", + query="metadata.event_type = \"USER_LOGIN\"", + visualization={"series": [{"chartType": "TABLE"}]}, + ) + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards/d1:addChart") + assert body == { + "dashboardChart": { + "displayName": "Logins", + "visualization": {"series": [{"chartType": "TABLE"}]}, + "chartDatasource": {"dataSources": ["UDM"]}, + }, + "dashboardQuery": { + "query": "metadata.event_type = \"USER_LOGIN\"", + "input": {"relativeTime": {"timeUnit": "DAY", "startTimeVal": 1}}, + }, + "chartLayout": {"startX": 0, "startY": 0, "spanX": 48, "spanY": 26}, + } + + +@pytest.mark.asyncio +async def test_add_dashboard_chart_absolute_window_and_layout(api): + await dm.add_dashboard_chart( + "d1", + "Chart", + query="q", + start_time="2026-01-01T00:00:00Z", + end_time="2026-01-02T00:00:00Z", + start_x=6, + start_y=0, + span_x=3, + span_y=2, + chart_layout=json.dumps({"spanY": 8}), + ) + _, _, _, body = call(api) + assert body["dashboardQuery"]["input"] == { + "timeWindow": { + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-02T00:00:00Z", + } + } + assert body["chartLayout"] == {"startX": 6, "startY": 0, "spanX": 3, "spanY": 8} + + +@pytest.mark.asyncio +async def test_add_dashboard_chart_markdown_tile_has_no_query(api): + await dm.add_dashboard_chart( + "d1", "Notes", tile_type="TILE_TYPE_MARKDOWN", chart={"markdown": {"text": "x"}} + ) + _, _, _, body = call(api) + assert "dashboardQuery" not in body + assert "chartDatasource" not in body["dashboardChart"] + assert body["dashboardChart"]["tileType"] == "TILE_TYPE_MARKDOWN" + assert body["dashboardChart"]["markdown"] == {"text": "x"} + + +@pytest.mark.asyncio +async def test_edit_dashboard_chart_fetches_etag_and_query_name(api): + api.side_effect = [ + { + "name": f"{INSTANCE}/dashboardCharts/c1", + "etag": "etag-1", + "chartDatasource": {"dashboardQuery": f"{INSTANCE}/dashboardQueries/q1"}, + }, + {"name": f"{INSTANCE}/dashboardQueries/q1", "etag": "qetag-1"}, + {"ok": True}, + ] + await dm.edit_dashboard_chart( + "d1", + "c1", + edit_mask="dashboardQuery.input,dashboardChart.displayName", + display_name="Renamed", + time_unit="DAY", + start_time_val=7, + language_features=["STAGES"], + ) + assert api.call_count == 3 + get_args = api.call_args_list[0].args + assert (get_args[1], get_args[2]) == ("GET", "/dashboardCharts/c1") + get_q = api.call_args_list[1].args + assert (get_q[1], get_q[2]) == ("GET", "/dashboardQueries/q1") + args, kwargs = api.call_args_list[2] + assert (args[1], args[2]) == ("POST", "/nativeDashboards/d1:editChart") + assert kwargs["json_body"] == { + "dashboardChart": { + "name": f"{INSTANCE}/dashboardCharts/c1", + "displayName": "Renamed", + "etag": "etag-1", + }, + "dashboardQuery": { + "name": f"{INSTANCE}/dashboardQueries/q1", + "etag": "qetag-1", + "input": {"relativeTime": {"timeUnit": "DAY", "startTimeVal": 7}}, + }, + "editMask": "dashboardQuery.input,dashboardChart.displayName", + "languageFeatures": ["STAGES"], + } + + +@pytest.mark.asyncio +async def test_edit_dashboard_chart_with_explicit_etag_skips_fetch(api): + await dm.edit_dashboard_chart( + "d1", "c1", edit_mask="dashboardChart.displayName", display_name="X", etag="e9" + ) + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards/d1:editChart") + assert body["dashboardChart"]["etag"] == "e9" + assert "dashboardQuery" not in body + + +@pytest.mark.asyncio +async def test_remove_dashboard_chart_uses_full_chart_name(api): + await dm.remove_dashboard_chart("d1", "c1") + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards/d1:removeChart") + assert body == {"dashboardChart": f"{INSTANCE}/dashboardCharts/c1"} + + +@pytest.mark.asyncio +async def test_duplicate_dashboard_chart_accepts_full_name(api): + full = f"{INSTANCE}/dashboardCharts/c1" + await dm.duplicate_dashboard_chart(f"{INSTANCE}/nativeDashboards/d1", full) + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards/d1:duplicateChart") + assert body == {"dashboardChart": full} + + +@pytest.mark.asyncio +async def test_export_dashboards(api): + await dm.export_dashboards(["d1", f"{INSTANCE}/nativeDashboards/d2"]) + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards:export") + assert body == { + "names": [ + f"{INSTANCE}/nativeDashboards/d1", + f"{INSTANCE}/nativeDashboards/d2", + ] + } + + +@pytest.mark.asyncio +async def test_export_dashboards_single_string_id(api): + await dm.export_dashboards("d1") + _, _, _, body = call(api) + assert body == {"names": [f"{INSTANCE}/nativeDashboards/d1"]} + + +@pytest.mark.asyncio +async def test_import_dashboards_accepts_export_response(api): + item = {"dashboard": {"displayName": "X"}, "dashboardCharts": []} + await dm.import_dashboards({"inlineDestination": {"dashboards": [item]}}) + method, path, _, body = call(api) + assert (method, path) == ("POST", "/nativeDashboards:import") + assert body == {"source": {"dashboards": [item]}} + + +@pytest.mark.asyncio +async def test_import_dashboards_json_string_list(api): + item = {"dashboard": {"displayName": "X"}} + await dm.import_dashboards(json.dumps([item])) + _, _, _, body = call(api) + assert body == {"source": {"dashboards": [item]}} + + +@pytest.mark.asyncio +async def test_execute_dashboard_query_defaults_to_past_day(api): + await dm.execute_dashboard_query(query="q", clear_cache=True) + method, path, _, body = call(api) + assert (method, path) == ("POST", "/dashboardQueries:execute") + assert body == { + "query": { + "query": "q", + "input": {"relativeTime": {"timeUnit": "DAY", "startTimeVal": 1}}, + }, + "clearCache": True, + } + + +@pytest.mark.asyncio +async def test_execute_dashboard_query_saved_query_and_filters(api): + filters = [{"fieldPath": "principal.hostname"}] + await dm.execute_dashboard_query( + query_id="q1", + time_unit="HOUR", + start_time_val=6, + dialect="YL2", + filters=json.dumps(filters), + use_previous_time_range=True, + ) + _, _, _, body = call(api) + assert body == { + "query": { + "name": f"{INSTANCE}/dashboardQueries/q1", + "input": {"relativeTime": {"timeUnit": "HOUR", "startTimeVal": 6}}, + "dialect": "YL2", + }, + "filters": filters, + "usePreviousTimeRange": True, + } + + +@pytest.mark.asyncio +async def test_execute_dashboard_query_requires_query(api): + result = await dm.execute_dashboard_query() + assert "error" in result + api.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_dashboard_query(api): + await dm.get_dashboard_query(f"{INSTANCE}/dashboardQueries/q1") + method, path, params, _ = call(api) + assert (method, path) == ("GET", "/dashboardQueries/q1") + assert params is None + + +# --- DashboardChartService ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_dashboard_chart(api): + await dm.get_dashboard_chart("c1") + method, path, _, _ = call(api) + assert (method, path) == ("GET", "/dashboardCharts/c1") + + +@pytest.mark.asyncio +async def test_list_dashboard_charts(api): + api.return_value = {"dashboardCharts": [{"name": "c1"}], "totalSize": 1} + result = await dm.list_dashboard_charts(filter='display_name = "x"', page_size=5) + method, path, params, _ = call(api) + assert (method, path) == ("GET", "/dashboardCharts") + assert params == {"pageSize": 5, "pageToken": None, "filter": 'display_name = "x"'} + assert result["total_charts"] == 1 + + +@pytest.mark.asyncio +async def test_batch_get_dashboard_charts(api): + api.return_value = {"dashboardCharts": [{}, {}]} + result = await dm.batch_get_dashboard_charts(["c1", f"{INSTANCE}/dashboardCharts/c2"]) + method, path, params, body = call(api) + assert (method, path) == ("GET", "/dashboardCharts:batchGet") + assert params == { + "names": [f"{INSTANCE}/dashboardCharts/c1", f"{INSTANCE}/dashboardCharts/c2"] + } + assert body is None + assert result["total_charts"] == 2 + + +# --- Error paths -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_api_failure_returns_error_dict(api): + api.side_effect = RuntimeError("HTTP 404: not found") + result = await dm.get_dashboard("missing") + assert result == {"error": "Error getting dashboard: HTTP 404: not found"} + + +@pytest.mark.asyncio +async def test_invalid_json_string_returns_error(api): + result = await dm.create_dashboard("SOC", filters="{not json") + assert "error" in result + assert "filters must be valid JSON" in result["error"] + api.assert_not_called() diff --git a/server/secops/tests/test_dashboard_reports_unit.py b/server/secops/tests/test_dashboard_reports_unit.py new file mode 100644 index 00000000..2d0b7edf --- /dev/null +++ b/server/secops/tests/test_dashboard_reports_unit.py @@ -0,0 +1,273 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for dashboard scheduled report and featured content tools.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from secops_mcp.tools import dashboard_reports as dr + +INSTANCE = "projects/p/locations/us/instances/i" +REPORTS = "/dashboardScheduledReports" +FEATURED = "/contentHub/featuredContentNativeDashboards" + + +@pytest.fixture +def api(): + with patch( + "secops_mcp.tools.dashboard_reports.get_chronicle_client" + ) as get_client, patch( + "secops_mcp.tools.dashboard_reports.chronicle_api_request" + ) as request: + client = MagicMock() + client.instance_id = INSTANCE + get_client.return_value = client + request.return_value = {"ok": True} + request.client = client + yield request + + +def _call(api): + """Return (method, path, kwargs) of the single recorded API call.""" + assert api.call_count == 1 + args, kwargs = api.call_args + assert args[0] is api.client + return args[1], args[2], kwargs + + +# --- scheduled reports ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_dashboard_scheduled_reports(api): + api.return_value = { + "dashboardScheduledReports": [{"name": "a"}, {"name": "b"}], + "nextPageToken": "tok", + } + result = await dr.list_dashboard_scheduled_reports( + page_size=10, + page_token="p", + filter_query="weekly", + view="DASHBOARD_SCHEDULED_REPORT_VIEW_FULL", + ) + method, path, kwargs = _call(api) + assert (method, path) == ("GET", REPORTS) + assert kwargs["params"] == { + "pageSize": 10, + "pageToken": "p", + "filter": "weekly", + "view": "DASHBOARD_SCHEDULED_REPORT_VIEW_FULL", + } + assert result["total_reports"] == 2 + assert result["nextPageToken"] == "tok" + + +@pytest.mark.asyncio +async def test_get_dashboard_scheduled_report_accepts_full_name(api): + await dr.get_dashboard_scheduled_report( + report_id=f"{INSTANCE}/dashboardScheduledReports/r1" + ) + method, path, _ = _call(api) + assert (method, path) == ("GET", f"{REPORTS}/r1") + + +@pytest.mark.asyncio +async def test_create_dashboard_scheduled_report_builds_body(api): + await dr.create_dashboard_scheduled_report( + display_name="Weekly", + dashboard_id="dash1", + cron="0 8 * * 1", + recipients=["a@example.com"], + time_zone="UTC", + file_format="FILE_FORMAT_CSV", + email_subject="Subj", + scheduled_report={"userData": {"pinned": True}}, + ) + method, path, kwargs = _call(api) + assert (method, path) == ("POST", REPORTS) + assert kwargs["json_body"] == { + "displayName": "Weekly", + "dashboard": {"name": "dash1"}, + "cronDetails": {"cron": "0 8 * * 1", "timeZone": "UTC"}, + "deliveryDetails": { + "emailDelivery": {"recipients": ["a@example.com"], "subject": "Subj"} + }, + "format": {"fileFormat": "FILE_FORMAT_CSV"}, + "userData": {"pinned": True}, + } + + +@pytest.mark.asyncio +async def test_create_dashboard_scheduled_report_parses_json_strings(api): + await dr.create_dashboard_scheduled_report( + display_name="Daily", + dashboard_id=f"{INSTANCE}/nativeDashboards/full", + cron="0 6 * * *", + recipients='["x@example.com", "y@example.com"]', + scheduled_report='{"description": "from json", "etag": "e1"}', + ) + _, _, kwargs = _call(api) + body = kwargs["json_body"] + assert body["dashboard"] == {"name": "full"} + assert body["deliveryDetails"]["emailDelivery"]["recipients"] == [ + "x@example.com", + "y@example.com", + ] + assert body["description"] == "from json" + assert body["etag"] == "e1" + assert body["format"] == {"fileFormat": "FILE_FORMAT_PDF"} + + +@pytest.mark.asyncio +async def test_update_dashboard_scheduled_report_derives_mask(api): + await dr.update_dashboard_scheduled_report( + report_id="r1", + cron="0 7 * * 1-5", + recipients=["b@example.com"], + pinned=True, + ) + method, path, kwargs = _call(api) + assert (method, path) == ("PATCH", f"{REPORTS}/r1") + assert kwargs["params"] == { + "updateMask": "cron_details,delivery_details,user_data.pinned" + } + assert kwargs["json_body"] == { + "cronDetails": {"cron": "0 7 * * 1-5"}, + "deliveryDetails": {"emailDelivery": {"recipients": ["b@example.com"]}}, + "userData": {"pinned": True}, + } + + +@pytest.mark.asyncio +async def test_update_dashboard_scheduled_report_explicit_mask(api): + await dr.update_dashboard_scheduled_report( + report_id="r1", + display_name="New", + update_mask="display_name, description", + scheduled_report='{"description": "d"}', + ) + _, _, kwargs = _call(api) + assert kwargs["params"] == {"updateMask": "display_name,description"} + assert kwargs["json_body"] == {"displayName": "New", "description": "d"} + + +@pytest.mark.asyncio +async def test_delete_dashboard_scheduled_report(api): + api.return_value = {} + result = await dr.delete_dashboard_scheduled_report(report_id="r9") + method, path, _ = _call(api) + assert (method, path) == ("DELETE", f"{REPORTS}/r9") + assert result == {"deleted": True, "report_id": "r9"} + + +@pytest.mark.asyncio +async def test_fetch_dashboard_scheduled_report_history(api): + await dr.fetch_dashboard_scheduled_report_history( + report_id="r1", page_size=20, page_token="t" + ) + method, path, kwargs = _call(api) + assert (method, path) == ("GET", f"{REPORTS}/r1:fetchHistory") + assert kwargs["params"] == {"pageSize": 20, "pageToken": "t"} + + +@pytest.mark.asyncio +async def test_duplicate_dashboard_scheduled_report(api): + await dr.duplicate_dashboard_scheduled_report(report_id="r1") + method, path, kwargs = _call(api) + assert (method, path) == ("POST", f"{REPORTS}/r1:duplicate") + assert kwargs["json_body"] == {} + + +@pytest.mark.asyncio +async def test_trigger_instant_dashboard_report(api): + api.return_value = {"name": "operations/op1", "done": False} + result = await dr.trigger_instant_dashboard_report(report_id="r1") + method, path, kwargs = _call(api) + assert (method, path) == ("POST", f"{REPORTS}/r1:trigger") + assert kwargs["json_body"] == {} + assert result["done"] is False + + +# --- featured content ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_list_featured_content_dashboards(api): + api.return_value = {"featuredContentNativeDashboards": [{"name": "f1"}]} + result = await dr.list_featured_content_dashboards( + page_size=5, filter_query="okta" + ) + method, path, kwargs = _call(api) + assert (method, path) == ("GET", FEATURED) + assert kwargs["params"] == { + "pageSize": 5, + "pageToken": None, + "filter": "okta", + } + assert result["total_featured_dashboards"] == 1 + + +@pytest.mark.asyncio +async def test_get_featured_content_dashboard(api): + await dr.get_featured_content_dashboard( + featured_dashboard_id=f"{INSTANCE}{FEATURED}/okta" + ) + method, path, _ = _call(api) + assert (method, path) == ("GET", f"{FEATURED}/okta") + + +@pytest.mark.asyncio +async def test_install_featured_content_dashboard(api): + api.return_value = {"nativeDashboard": f"{INSTANCE}/nativeDashboards/new"} + result = await dr.install_featured_content_dashboard( + featured_dashboard_id="okta", + featured_dashboard='{"dashboardContent": {"dashboard": {"displayName": "X"}}}', + ) + method, path, kwargs = _call(api) + assert (method, path) == ("POST", f"{FEATURED}/okta:install") + assert kwargs["json_body"] == { + "featuredContentNativeDashboard": { + "dashboardContent": {"dashboard": {"displayName": "X"}} + } + } + assert result["nativeDashboard"].endswith("/nativeDashboards/new") + + +@pytest.mark.asyncio +async def test_install_featured_content_dashboard_empty_body(api): + await dr.install_featured_content_dashboard(featured_dashboard_id="okta") + _, _, kwargs = _call(api) + assert kwargs["json_body"] == {} + + +# --- error paths ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_api_error_returns_error_dict(api): + api.side_effect = RuntimeError("Chronicle API GET failed with status 404") + result = await dr.get_dashboard_scheduled_report(report_id="missing") + assert "error" in result + assert "404" in result["error"] + + +@pytest.mark.asyncio +async def test_invalid_json_arg_returns_error_dict(api): + result = await dr.create_dashboard_scheduled_report( + display_name="x", dashboard_id="d", cron="* * * * *", recipients="{bad" + ) + assert "error" in result + assert "recipients must be valid JSON" in result["error"] + api.assert_not_called() diff --git a/server/secops/tests/test_legacy_dashboards_unit.py b/server/secops/tests/test_legacy_dashboards_unit.py new file mode 100644 index 00000000..7122cd51 --- /dev/null +++ b/server/secops/tests/test_legacy_dashboards_unit.py @@ -0,0 +1,159 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for legacy (Looker) dashboard and migration MCP tools.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from secops_mcp.tools.legacy_dashboards import ( + copy_legacy_dashboard, + delete_legacy_dashboard, + get_legacy_dashboard, + list_legacy_dashboards, +) + +MODULE = "secops_mcp.tools.legacy_dashboards" +INSTANCE = "projects/p1/locations/us/instances/inst-1" + + +@pytest.fixture +def chronicle_client(): + with patch(f"{MODULE}.get_chronicle_client") as factory: + client = MagicMock() + client.instance_id = INSTANCE + factory.return_value = client + yield client + + +@pytest.fixture +def api_request(): + with patch(f"{MODULE}.chronicle_api_request") as request: + yield request + + +def call_of(api_request): + """Return (method, path, params, json_body) of the single API call.""" + api_request.assert_called_once() + args, kwargs = api_request.call_args + return args[1], args[2], kwargs.get("params"), kwargs.get("json_body") + + +@pytest.mark.asyncio +async def test_get_legacy_dashboard_accepts_full_name( + chronicle_client, api_request +): + api_request.return_value = {"name": f"{INSTANCE}/dashboards/d1"} + + result = await get_legacy_dashboard( + f"{INSTANCE}/dashboards/d1", view="DASHBOARD_VIEW_EXPORT" + ) + + assert result == {"name": f"{INSTANCE}/dashboards/d1"} + assert call_of(api_request) == ( + "GET", + "/dashboards/d1", + {"view": "DASHBOARD_VIEW_EXPORT"}, + None, + ) + + +@pytest.mark.asyncio +async def test_list_legacy_dashboards(chronicle_client, api_request): + api_request.return_value = { + "dashboards": [{"name": "a"}, {"name": "b"}], + "nextPageToken": "tok2", + } + + result = await list_legacy_dashboards(page_size=2, page_token="tok1") + + assert result["total_dashboards"] == 2 + assert result["nextPageToken"] == "tok2" + assert call_of(api_request) == ( + "GET", + "/dashboards", + {"pageSize": 2, "pageToken": "tok1", "view": None}, + None, + ) + + +@pytest.mark.asyncio +async def test_delete_legacy_dashboard(chronicle_client, api_request): + api_request.return_value = {} + + result = await delete_legacy_dashboard("d1") + + assert result == {"status": "deleted", "dashboard_id": "d1"} + assert call_of(api_request) == ("DELETE", "/dashboards/d1", None, None) + + +@pytest.mark.asyncio +async def test_copy_legacy_dashboard(chronicle_client, api_request): + api_request.return_value = {"name": f"{INSTANCE}/dashboards/d2"} + + result = await copy_legacy_dashboard( + "d1", dashboard_type="DASHBOARD_TYPE_USER", display_name="Copy" + ) + + assert result == {"name": f"{INSTANCE}/dashboards/d2"} + assert call_of(api_request) == ( + "POST", + "/dashboards/d1:copy", + None, + { + "destination": { + "parent": INSTANCE, + "dashboard": { + "displayName": "Copy", + "type": "DASHBOARD_TYPE_USER", + }, + } + }, + ) + + +@pytest.mark.asyncio +async def test_copy_legacy_dashboard_json_string_destination( + chronicle_client, api_request +): + api_request.return_value = {} + + await copy_legacy_dashboard( + "d1", destination='{"type": "DASHBOARD_TYPE_CUSTOMER", "displayName": "X"}' + ) + + _, _, _, body = call_of(api_request) + assert body["destination"]["dashboard"] == { + "type": "DASHBOARD_TYPE_CUSTOMER", + "displayName": "X", + } + + +@pytest.mark.asyncio +async def test_copy_legacy_dashboard_requires_type( + chronicle_client, api_request +): + result = await copy_legacy_dashboard("d1", display_name="no type") + + assert "error" in result + api_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_error_path_returns_error_dict(chronicle_client, api_request): + api_request.side_effect = RuntimeError("boom 403") + + result = await get_legacy_dashboard("d1") + + assert result == {"error": "Error getting legacy dashboard: boom 403"} diff --git a/server/secops/tests/test_legacy_soar_dashboard_unit.py b/server/secops/tests/test_legacy_soar_dashboard_unit.py new file mode 100644 index 00000000..b9db05a3 --- /dev/null +++ b/server/secops/tests/test_legacy_soar_dashboard_unit.py @@ -0,0 +1,440 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for the legacy SOAR dashboard MCP tools. + +Every tool is exercised against a mocked ``chronicle_api_request`` so the +assertions cover the HTTP method, the ``legacySoarDashboard:legacy*`` custom +verb, query params and JSON body that would reach the Chronicle API. +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from secops_mcp.tools import legacy_soar_dashboard as mod + +MODULE = "secops_mcp.tools.legacy_soar_dashboard" + + +@pytest.fixture +def chronicle_client(): + with patch(f"{MODULE}.get_chronicle_client") as factory: + client = MagicMock(name="chronicle") + factory.return_value = client + yield client + + +@pytest.fixture +def api(chronicle_client): + with patch(f"{MODULE}.chronicle_api_request") as request: + request.return_value = {} + yield request + + +def call_args(api): + """Return (method, path, kwargs) of the single API call made.""" + assert api.call_count == 1 + args, kwargs = api.call_args + _, method, path = args + return method, path, kwargs + + +WIDGET = { + "id": 7, + "dashboardId": 12, + "title": "Cases by priority", + "widgetType": "CUSTOM", + "widthSize": "ONE_BLOCK", + "subject": "CASES", + "dimensionA": "PRIORITY", + "chartType": "PIE", +} + + +# --------------------------------------------------------------------------- +# GET verbs +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_dashboard_cards(api): + api.return_value = {"payload": {"1": "SOC Overview", "2": "SLA"}} + + result = await mod.legacy_soar_get_dashboard_cards() + + method, path, kwargs = call_args(api) + assert (method, path) == ("GET", "legacySoarDashboard:legacyGetDashboardCards") + assert "params" not in kwargs and "json_body" not in kwargs + assert result["total_dashboards"] == 2 + assert result["payload"]["1"] == "SOC Overview" + + +@pytest.mark.asyncio +async def test_get_dashboard_widget_definitions(api): + api.return_value = {"payload": [{"id": 1}, {"id": 2}, {"id": 3}]} + + result = await mod.legacy_soar_get_dashboard_widget_definitions() + + method, path, _ = call_args(api) + assert (method, path) == ( + "GET", + "legacySoarDashboard:legacyGetDashboardWidgetDefinitions", + ) + assert result["total_definitions"] == 3 + + +@pytest.mark.asyncio +async def test_get_dashboard_playbooks(api): + api.return_value = {"payload": [{"identifier": "pb-1"}]} + + result = await mod.legacy_soar_get_dashboard_playbooks() + + method, path, _ = call_args(api) + assert (method, path) == ("GET", "legacySoarDashboard:legacyGetDashboardPlaybooks") + assert result["total_playbooks"] == 1 + + +@pytest.mark.asyncio +async def test_get_cases_time_to_respond(api): + api.return_value = {"timeToResponseValue": 42} + + result = await mod.legacy_soar_get_cases_time_to_respond( + time_range_filter="LAST_WEEK" + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "GET", + "legacySoarDashboard:legacyGetCasesTimeToRespond", + ) + assert kwargs["params"] == {"timeRangeFilter": "LAST_WEEK"} + assert result == {"timeToResponseValue": 42} + + +@pytest.mark.asyncio +async def test_get_opened_and_closed_cases_trends(api): + api.return_value = {"payload": [{"opened": 3, "closed": 2}]} + + result = await mod.legacy_soar_get_opened_and_closed_cases_trends( + time_range_type="LAST_MONTH" + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "GET", + "legacySoarDashboard:legacyGetOpenedAndClosedCasesTrends", + ) + assert kwargs["params"] == {"timeRangeType": "LAST_MONTH"} + assert result["total_results"] == 1 + + +# --------------------------------------------------------------------------- +# POST verbs with an ID payload +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_dashboard(api): + api.return_value = {"payload": {"id": 12, "widgets": [WIDGET]}} + + result = await mod.legacy_soar_get_dashboard(dashboard_id=12) + + method, path, kwargs = call_args(api) + assert (method, path) == ("POST", "legacySoarDashboard:legacyGetDashboard") + assert kwargs["json_body"] == {"legacyPayload": 12} + assert result["payload"]["id"] == 12 + + +@pytest.mark.asyncio +async def test_delete_dashboard(api): + result = await mod.legacy_soar_delete_dashboard(dashboard_id=12) + + method, path, kwargs = call_args(api) + assert (method, path) == ("POST", "legacySoarDashboard:legacyDeleteDashboard") + assert kwargs["json_body"] == {"legacyPayload": 12} + assert result == {"deleted": True, "dashboard_id": 12} + + +@pytest.mark.asyncio +async def test_delete_dashboard_widget(api): + result = await mod.legacy_soar_delete_dashboard_widget(widget_id=7) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyDeleteDashboardWidget", + ) + assert kwargs["json_body"] == {"legacyPayload": 7} + assert result == {"deleted": True, "widget_id": 7} + + +# --------------------------------------------------------------------------- +# Dashboard create / import / report template +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_or_update_dashboard_compacts_body(api): + api.return_value = {"id": 99, "name": "SOC Overview"} + + result = await mod.legacy_soar_add_or_update_dashboard( + dashboard_id=0, + name="SOC Overview", + default_time_range="LAST_WEEK", + environments=["Default Environment"], + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyAddOrUpdateDashboard", + ) + assert kwargs["json_body"] == { + "id": 0, + "name": "SOC Overview", + "defaultTimeRange": "LAST_WEEK", + "environments": ["Default Environment"], + } + assert result["id"] == 99 + + +@pytest.mark.asyncio +async def test_add_or_update_dashboard_parses_json_string_lists(api): + await mod.legacy_soar_add_or_update_dashboard( + dashboard_id=12, + allowed_users='["alice", "bob"]', + widgets=json.dumps([WIDGET]), + ) + + _, _, kwargs = call_args(api) + assert kwargs["json_body"]["allowedUsers"] == ["alice", "bob"] + assert kwargs["json_body"]["widgets"] == [WIDGET] + + +@pytest.mark.asyncio +async def test_import_dashboard_overrides_name(api): + exported = {"id": 5, "name": "Old", "widgets": [WIDGET], "environments": ["E"]} + + await mod.legacy_soar_import_dashboard(dashboard=json.dumps(exported), name="New") + + method, path, kwargs = call_args(api) + assert (method, path) == ("POST", "legacySoarDashboard:legacyImportDashboard") + assert kwargs["json_body"] == {**exported, "name": "New"} + + +@pytest.mark.asyncio +async def test_import_dashboard_requires_content(api): + result = await mod.legacy_soar_import_dashboard() + + assert "error" in result + api.assert_not_called() + + +@pytest.mark.asyncio +async def test_save_dashboard_as_report_template(api): + await mod.legacy_soar_save_dashboard_as_report_template( + name="Weekly SOC", + category="MANAGEMENT", + report_widgets=[WIDGET], + has_scheduler=False, + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacySaveDashboardAsReportTemplate", + ) + assert kwargs["json_body"] == { + "name": "Weekly SOC", + "category": "MANAGEMENT", + "reportWidgets": [WIDGET], + "hasScheduler": False, + } + + +# --------------------------------------------------------------------------- +# Widget definition driven verbs +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_or_update_dashboard_widget_explicit_args(api): + api.return_value = {"payload": {**WIDGET, "id": 101}} + + result = await mod.legacy_soar_add_or_update_dashboard_widget( + widget_id=0, + dashboard_id=12, + title="Cases by priority", + widget_type="CUSTOM", + width_size="ONE_BLOCK", + subject="CASES", + dimension_a="PRIORITY", + chart_type="PIE", + time_filter="LAST_WEEK", + extra_fields={"caseStatusesFilter": ["OPENED"]}, + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyAddOrUpdateDashboardWidget", + ) + assert kwargs["json_body"] == { + "id": 0, + "dashboardId": 12, + "title": "Cases by priority", + "widgetType": "CUSTOM", + "widthSize": "ONE_BLOCK", + "subject": "CASES", + "dimensionA": "PRIORITY", + "chartType": "PIE", + "timeFilter": "LAST_WEEK", + "caseStatusesFilter": ["OPENED"], + } + assert result["payload"]["id"] == 101 + + +@pytest.mark.asyncio +async def test_add_or_update_dashboard_widget_missing_required(api): + result = await mod.legacy_soar_add_or_update_dashboard_widget( + widget_id=0, dashboard_id=12, title="No type" + ) + + assert "widgetType" in result["error"] and "widthSize" in result["error"] + api.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_dashboard_widget_values_widget_passthrough_json_string(api): + api.return_value = {"payload": {"series": []}} + + result = await mod.legacy_soar_get_dashboard_widget_values( + widget=json.dumps(WIDGET), time_filter="LAST_MONTH" + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyGetDashboardWidgetValues", + ) + assert kwargs["json_body"] == {**WIDGET, "timeFilter": "LAST_MONTH"} + assert result == {"payload": {"series": []}} + + +@pytest.mark.asyncio +async def test_get_dashboard_widget_case_ids(api): + api.return_value = {"payload": [1, 2, 3]} + + result = await mod.legacy_soar_get_dashboard_widget_case_ids(widget=WIDGET) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyGetDashboardWidgetCaseIds", + ) + assert kwargs["json_body"] == WIDGET + assert result["total_results"] == 3 + + +@pytest.mark.asyncio +async def test_get_dashboard_custom_widget_case_ids(api): + api.return_value = {"payload": [9]} + + result = await mod.legacy_soar_get_dashboard_custom_widget_case_ids( + widget=WIDGET, extra_fields='{"analystFilters": ["alice"]}' + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyGetDashboardCustomWidgetCaseIds", + ) + assert kwargs["json_body"] == {**WIDGET, "analystFilters": ["alice"]} + assert result["total_results"] == 1 + + +@pytest.mark.asyncio +async def test_get_dashboard_playbook_runs(api): + api.return_value = {"payload": [{"status": "ERROR"}]} + + result = await mod.legacy_soar_get_dashboard_playbook_runs( + widget=WIDGET, + subject="PLAYBOOKS", + extra_fields={"playbookStatusFilter": ["ERROR"]}, + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyGetDashboardPlaybookRuns", + ) + assert kwargs["json_body"]["subject"] == "PLAYBOOKS" + assert kwargs["json_body"]["playbookStatusFilter"] == ["ERROR"] + assert result["total_results"] == 1 + + +@pytest.mark.asyncio +async def test_get_playbook_monitoring(api): + api.return_value = {"payload": {"name": "Playbook monitoring"}} + + result = await mod.legacy_soar_get_playbook_monitoring( + workflow_identifier="pb-1", time_filter="LAST_WEEK" + ) + + method, path, kwargs = call_args(api) + assert (method, path) == ( + "POST", + "legacySoarDashboard:legacyGetPlaybookMonitoring", + ) + assert kwargs["json_body"] == { + "workflowIdentifier": "pb-1", + "timeFilter": "LAST_WEEK", + } + assert result["payload"]["name"] == "Playbook monitoring" + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_api_failure_returns_error_dict(api): + api.side_effect = RuntimeError("Chronicle API GET ... failed with status 403") + + result = await mod.legacy_soar_get_dashboard_cards() + + assert result == { + "error": "Error listing legacy SOAR dashboard cards: Chronicle API GET " + "... failed with status 403" + } + + +@pytest.mark.asyncio +async def test_invalid_json_string_returns_error(api): + result = await mod.legacy_soar_get_dashboard_widget_values(widget="{not json") + + assert "widget must be valid JSON" in result["error"] + api.assert_not_called() + + +@pytest.mark.asyncio +async def test_client_factory_receives_tenant_args(api, chronicle_client): + with patch(f"{MODULE}.get_chronicle_client") as factory: + factory.return_value = chronicle_client + await mod.legacy_soar_get_dashboard_cards( + project_id="p", customer_id="c", region="europe" + ) + factory.assert_called_once_with("p", "c", "europe") diff --git a/server/secops/tests/test_utils_chronicle_request_unit.py b/server/secops/tests/test_utils_chronicle_request_unit.py new file mode 100644 index 00000000..603c987f --- /dev/null +++ b/server/secops/tests/test_utils_chronicle_request_unit.py @@ -0,0 +1,128 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for the shared Chronicle REST helpers in secops_mcp.utils.""" + +from unittest.mock import MagicMock + +import pytest + +from secops_mcp.utils import ( + chronicle_api_request, + instance_base_url, + parse_json_arg, +) + +INSTANCE = "projects/p/locations/us/instances/abc" + + +def _client(base_url): + client = MagicMock() + client.base_url = base_url + client.instance_id = INSTANCE + response = MagicMock() + response.status_code = 200 + response.content = b'{"ok": true}' + response.json.return_value = {"ok": True} + client.session.request.return_value = response + return client + + +def test_instance_base_url_with_string_base_url_swaps_version(): + client = _client("https://us-chronicle.googleapis.com/v1alpha") + assert ( + instance_base_url(client, "v1") + == f"https://us-chronicle.googleapis.com/v1/{INSTANCE}" + ) + + +def test_instance_base_url_with_callable_base_url_passes_version(): + def base_url(version): + return f"https://us-chronicle.googleapis.com/{version}" + + client = _client(base_url) + assert ( + instance_base_url(client, "v1beta") + == f"https://us-chronicle.googleapis.com/v1beta/{INSTANCE}" + ) + + +def test_chronicle_api_request_subresource_path_and_param_filtering(): + client = _client("https://us-chronicle.googleapis.com/v1alpha") + result = chronicle_api_request( + client, + "GET", + "/nativeDashboards", + params={"pageSize": 10, "pageToken": None}, + ) + assert result == {"ok": True} + client.session.request.assert_called_once_with( + "GET", + f"https://us-chronicle.googleapis.com/v1alpha/{INSTANCE}/nativeDashboards", + params={"pageSize": 10}, + json=None, + timeout=120, + ) + + +def test_chronicle_api_request_instance_custom_verb_path(): + client = _client("https://us-chronicle.googleapis.com/v1alpha") + chronicle_api_request( + client, "POST", "legacySoarDashboard:legacyGetDashboard", json_body={"a": 1} + ) + url = client.session.request.call_args.args[1] + assert url.endswith(f"{INSTANCE}/legacySoarDashboard:legacyGetDashboard") + assert client.session.request.call_args.kwargs["json"] == {"a": 1} + + +def test_chronicle_api_request_absolute_resource_name(): + client = _client("https://us-chronicle.googleapis.com/v1alpha") + chronicle_api_request(client, "GET", f"{INSTANCE}/dashboardCharts/c1") + url = client.session.request.call_args.args[1] + assert url == ( + f"https://us-chronicle.googleapis.com/v1alpha/{INSTANCE}/dashboardCharts/c1" + ) + + +def test_chronicle_api_request_raises_on_http_error(): + client = _client("https://us-chronicle.googleapis.com/v1alpha") + client.session.request.return_value.status_code = 404 + client.session.request.return_value.text = "not found" + with pytest.raises(RuntimeError, match="status 404: not found"): + chronicle_api_request(client, "GET", "/nativeDashboards/missing") + + +def test_chronicle_api_request_empty_body_returns_empty_dict(): + client = _client("https://us-chronicle.googleapis.com/v1alpha") + client.session.request.return_value.content = b"" + assert chronicle_api_request(client, "DELETE", "/nativeDashboards/d1") == {} + + +@pytest.mark.parametrize( + "value,expected", + [ + (None, None), + ("", None), + ({"a": 1}, {"a": 1}), + ([1, 2], [1, 2]), + ('{"a": 1}', {"a": 1}), + ("[1, 2]", [1, 2]), + ], +) +def test_parse_json_arg_accepts_native_and_string_forms(value, expected): + assert parse_json_arg(value, "x") == expected + + +def test_parse_json_arg_rejects_invalid_json(): + with pytest.raises(ValueError, match="x must be valid JSON"): + parse_json_arg("{not json", "x")