From 9686f9cb0c0e6c8fdd2fa086051ec1f022d143ab Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Mon, 2 Nov 2026 16:15:04 +0000 Subject: [PATCH 1/3] feat(secops): add SIEM dashboard MCP tools covering all Chronicle dashboard APIs Adds 54 tools to google-secops-mcp spanning every public Chronicle dashboard service, implemented as direct REST calls through a new shared `chronicle_api_request` helper in `secops_mcp/utils.py` (works across `secops` SDK releases regardless of how `base_url` is exposed). New tool modules under `secops_mcp/tools/`: - `dashboard_management.py` (17): NativeDashboardService (list, get, create, update, duplicate, delete, add/edit/remove/duplicate chart, export, import), DashboardQueryService (execute, get) and DashboardChartService (get, list, batchGet). - `dashboard_reports.py` (11): DashboardScheduledReportService (list, get, create, update, delete, fetchHistory, duplicate, trigger) and FeaturedContentNativeDashboardService (list, get, install). - `legacy_dashboard_migration.py` (9): legacy Looker DashboardService (create, get, list, delete, copy) and the Looker-to-native migration workflow (fetchMigratable, batchMigrate, fetchMigrationMetrics, fetchMigrationJobs). - `legacy_soar_dashboard.py` (17): every LegacySoarDashboardService verb (dashboards, widgets, widget values/case IDs, playbook monitoring, case trends, time-to-respond, import, save as report template). Behaviour learned from live verification against a Chronicle tenant and encoded in the tools: - `create_dashboard` / `duplicate_dashboard` must send `type: CUSTOM` and an `access` value; both default to `DASHBOARD_PRIVATE`. - `add_dashboard_chart` must send `startX`/`startY` explicitly (proto3 presence) and `chartDatasource.dataSources` (defaults to `["UDM"]`). - `edit_dashboard_chart` enforces optimistic concurrency, so the tool fetches the chart (and saved query) first and sends their `etag`s and the query `name`; `editMask` paths are camelCase. - `import_dashboards` requires the export payload unmodified (`name` and `etag` present) and returns the new resource name in `results[]`. - `ManageDashboardLease` is not visible to the public API surface and is intentionally not exposed. Also adds `parse_json_arg` so structured arguments are accepted either as JSON values or JSON-encoded strings, unit tests for every tool (142 passing in `server/secops/tests`), and README / docs sections with a usage example. --- docs/servers/secops_mcp.md | 638 +++++++ server/secops/README.md | 172 ++ server/secops/secops_mcp/tools/__init__.py | 4 + .../secops_mcp/tools/dashboard_management.py | 1292 ++++++++++++++ .../secops_mcp/tools/dashboard_reports.py | 807 +++++++++ .../tools/legacy_dashboard_migration.py | 694 ++++++++ .../secops_mcp/tools/legacy_soar_dashboard.py | 1582 +++++++++++++++++ server/secops/secops_mcp/utils.py | 115 +- .../tests/test_dashboard_management_unit.py | 427 +++++ .../tests/test_dashboard_reports_unit.py | 273 +++ .../test_legacy_dashboard_migration_unit.py | 305 ++++ .../tests/test_legacy_soar_dashboard_unit.py | 440 +++++ .../test_utils_chronicle_request_unit.py | 128 ++ 13 files changed, 6876 insertions(+), 1 deletion(-) create mode 100644 server/secops/secops_mcp/tools/dashboard_management.py create mode 100644 server/secops/secops_mcp/tools/dashboard_reports.py create mode 100644 server/secops/secops_mcp/tools/legacy_dashboard_migration.py create mode 100644 server/secops/secops_mcp/tools/legacy_soar_dashboard.py create mode 100644 server/secops/tests/test_dashboard_management_unit.py create mode 100644 server/secops/tests/test_dashboard_reports_unit.py create mode 100644 server/secops/tests/test_legacy_dashboard_migration_unit.py create mode 100644 server/secops/tests/test_legacy_soar_dashboard_unit.py create mode 100644 server/secops/tests/test_utils_chronicle_request_unit.py diff --git a/docs/servers/secops_mcp.md b/docs/servers/secops_mcp.md index 432d4e9c..375593aa 100644 --- a/docs/servers/secops_mcp.md +++ b/docs/servers/secops_mcp.md @@ -839,6 +839,584 @@ 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 and Migration Tools + +- **`create_legacy_dashboard(display_name=None, dashboard_type=None, lookml=None, default_dashboard=None, dashboard=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Create a legacy (Looker/LookML) Chronicle dashboard. v1alpha-only. + - **Parameters:** + - `display_name` (optional): User-visible dashboard name. + - `dashboard_type` (optional): ``DASHBOARD_TYPE_USER`` (private to the caller) or ``DASHBOARD_TYPE_CUSTOMER`` (visible to all users). ``DASHBOARD_TYPE_PREDEFINED`` dashboards cannot be created. + - `lookml` (optional): LookML YAML defining the dashboard. + - `default_dashboard` (optional): Show this dashboard by default (only one dashboard may be the default). + - `dashboard` (optional): Full Dashboard resource as a dict or JSON string, e.g. ``{"displayName": "SOC Overview", "type": "DASHBOARD_TYPE_USER", "lookml": "..."}``. Explicit args above override matching keys. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** The created Dashboard resource (``name``, ``displayName``, ``type``, ``uri``, ...), or an error message. + +- **`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. + +- **`fetch_migratable_dashboards(page_size=None, page_token=None, view=None, project_id=None, customer_id=None, region=None)`** + - **Description:** List legacy (Looker) dashboards eligible for migration to native. 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. + +- **`batch_migrate_dashboards(dashboards, project_id=None, customer_id=None, region=None)`** + - **Description:** Migrate legacy (Looker) dashboards to native dashboards. v1alpha-only. + - **Parameters:** + - `dashboards` (required): Legacy dashboard IDs or full resource names, as a list or JSON array string. Bare IDs are expanded to ``projects/.../instances/.../dashboards/{id}``. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"migrationJobId": "...", "jobMigrationStatus": "JOB_MIGRATION_STATE_IN_PROGRESS" | "..._SUCCESS" | "..._PARTIAL_SUCCESS" | "..._FAILURE", "endTime": "..."}`` plus ``dashboards_requested``, or an error message. + +- **`fetch_dashboard_migration_metrics(project_id=None, customer_id=None, region=None)`** + - **Description:** Get instance-wide legacy-to-native dashboard migration metrics. v1alpha-only. + - **Returns:** ``{"totalMigrations", "successRate", "hitlCount", "lookerDashboardCount", "migratedDashboardsCount", "pendingDashboardsCount", "processingDashboardsCount", "failedDashboardsCount"}`` or an error message. + +- **`fetch_dashboard_migration_jobs(migration_job_id=None, dashboard_id=None, project_id=None, customer_id=None, region=None)`** + - **Description:** Get legacy-to-native dashboard migration jobs and results. v1alpha-only. + - **Parameters:** + - `migration_job_id` (optional): Migration job ID to fetch. + - `dashboard_id` (optional): Legacy dashboard ID or full resource name; restricts results to that dashboard. + - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). + - **Returns:** ``{"migrationJobs": [{"migrationJobId", "jobMigrationStatus", "startedBy", "startTime", "endTime", "results": [{"dashboard", "title", "status", "errorMessage", "nativeDashboard"}]}], "total_jobs": N}`` or an error message. Result ``status`` values: DASHBOARD_MIGRATION_STATE_PENDING, _IN_PROGRESS, _SUCCESS, _NEEDS_REVIEW, _FAILED. + +### 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 +1711,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..31d63380 100644 --- a/server/secops/README.md +++ b/server/secops/README.md @@ -212,6 +212,176 @@ 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 and Migration Tools + +- **`create_legacy_dashboard(display_name=None, dashboard_type=None, lookml=None, default_dashboard=None, dashboard=None, project_id=None, customer_id=None, region=None)`** + - Create a legacy (Looker/LookML) Chronicle dashboard. v1alpha-only. + +- **`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. + +- **`fetch_migratable_dashboards(page_size=None, page_token=None, view=None, project_id=None, customer_id=None, region=None)`** + - List legacy (Looker) dashboards eligible for migration to native. v1alpha-only. + +- **`batch_migrate_dashboards(dashboards, project_id=None, customer_id=None, region=None)`** + - Migrate legacy (Looker) dashboards to native dashboards. v1alpha-only. + +- **`fetch_dashboard_migration_metrics(project_id=None, customer_id=None, region=None)`** + - Get instance-wide legacy-to-native dashboard migration metrics. v1alpha-only. + +- **`fetch_dashboard_migration_jobs(migration_job_id=None, dashboard_id=None, project_id=None, customer_id=None, region=None)`** + - Get legacy-to-native dashboard migration jobs and results. v1alpha-only. + +### 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 +403,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, migrate legacy (Looker) dashboards, and query legacy SOAR dashboards 18. **UDM Search & Export**: Direct UDM querying, field value autocomplete, and CSV export ### Example @@ -254,6 +425,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`, `batch_migrate_dashboards`, and `legacy_soar_get_dashboard_cards` to build, query, report on, migrate, 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..798e52bb 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_dashboard_migration 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..2ba76f5f --- /dev/null +++ b/server/secops/secops_mcp/tools/dashboard_reports.py @@ -0,0 +1,807 @@ +# 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: + """Expand a bare native dashboard ID to its full resource name.""" + if "/" in dashboard: + return dashboard + return f"{chronicle.instance_id}/nativeDashboards/{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}`); becomes `dashboard.name`. + 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_dashboard_migration.py b/server/secops/secops_mcp/tools/legacy_dashboard_migration.py new file mode 100644 index 00000000..7afc8735 --- /dev/null +++ b/server/secops/secops_mcp/tools/legacy_dashboard_migration.py @@ -0,0 +1,694 @@ +# 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 and migration. + +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, 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_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 create_legacy_dashboard( + display_name: Optional[str] = None, + dashboard_type: Optional[str] = None, + lookml: Optional[str] = None, + default_dashboard: Optional[bool] = 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 legacy (Looker/LookML) Chronicle dashboard. v1alpha-only. + + Creates a dashboard under ``/dashboards``, the legacy Looker-backed + dashboard family. The dashboard is defined by a LookML model; the + recommended source is the ``lookml`` returned by ``get_legacy_dashboard`` + with ``view="DASHBOARD_VIEW_EXPORT"``. For new work prefer native + dashboards (``create_dashboard``); legacy dashboards exist mainly so they + can be migrated. + + **Workflow Integration:** + - Re-create or import a Looker dashboard from exported LookML. + - Pair with ``batch_migrate_dashboards`` to convert it to a native + dashboard afterwards. + + **Use Cases:** + - Restore a legacy dashboard from a LookML backup + - Stage a legacy dashboard for migration testing + + Args: + display_name (Optional[str]): User-visible dashboard name. + dashboard_type (Optional[str]): ``DASHBOARD_TYPE_USER`` (private to + the caller) or ``DASHBOARD_TYPE_CUSTOMER`` (visible to all users). + ``DASHBOARD_TYPE_PREDEFINED`` dashboards cannot be created. + lookml (Optional[str]): LookML YAML defining the dashboard. + default_dashboard (Optional[bool]): Show this dashboard by default + (only one dashboard may be the default). + dashboard (Optional[Union[Dict[str, Any], str]]): Full Dashboard + resource as a dict or JSON string, e.g. + ``{"displayName": "SOC Overview", "type": "DASHBOARD_TYPE_USER", + "lookml": "..."}``. Explicit args above override matching keys. + 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 created Dashboard resource (``name``, + ``displayName``, ``type``, ``uri``, ...), or an error message. + + Example Usage: + create_legacy_dashboard(display_name="SOC Overview", + dashboard_type="DASHBOARD_TYPE_USER", lookml=lookml_text) + + Next Steps (using MCP-enabled tools): + - ``get_legacy_dashboard`` to confirm the result + - ``batch_migrate_dashboards`` to convert it to a native dashboard + """ + try: + logger.info("Creating legacy dashboard %s", display_name) + chronicle = get_chronicle_client(project_id, customer_id, region) + body = _build_dashboard_resource( + dashboard, display_name, dashboard_type, lookml, default_dashboard + ) + if not body: + return { + "error": "Provide display_name/lookml or a dashboard resource" + } + return chronicle_api_request( + chronicle, "POST", _DASHBOARDS_PATH, json_body=body + ) + except Exception as e: + logger.error("Error creating legacy dashboard: %s", e, exc_info=True) + return {"error": f"Error creating legacy dashboard: {e}"} + + +@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 include the LookML definition, + which is the input format for ``create_legacy_dashboard``. 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`` + - ``batch_migrate_dashboards`` if not yet migrated + """ + 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. + - Compare with ``fetch_migratable_dashboards`` to see which are eligible. + + **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): + - ``fetch_migratable_dashboards`` to see migration eligibility + - ``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; to produce a + native dashboard use ``batch_migrate_dashboards`` instead. + + **Workflow Integration:** + - Copy a predefined dashboard, then migrate the copy to native. + - 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): + - ``batch_migrate_dashboards`` with 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}"} + + +@server.tool() +async def fetch_migratable_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) dashboards eligible for migration to native. v1alpha-only. + + Calls ``GET /dashboards:fetchMigratable``. Returns the subset of legacy + dashboards that Chronicle can convert into native dashboards, each with + a ``migrationInfo`` block (``state``, ``migrationJobId``, + ``nativeDashboard``). This is the first step of the Looker-to-native + migration workflow. The call may take several minutes on large instances. + + **Workflow Integration:** + - Run before ``batch_migrate_dashboards`` to pick candidates. + - Re-run afterwards; ``migrationInfo.state`` reflects progress. + + **Use Cases:** + - Plan a dashboard migration + - Find dashboards still in ``DASHBOARD_MIGRATION_STATE_PENDING`` + + 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: + fetch_migratable_dashboards(page_size=100) + + Next Steps (using MCP-enabled tools): + - ``batch_migrate_dashboards(dashboards=[...names...])`` + - ``fetch_dashboard_migration_metrics`` for an instance-wide summary + """ + try: + logger.info("Fetching migratable legacy dashboards") + chronicle = get_chronicle_client(project_id, customer_id, region) + response = chronicle_api_request( + chronicle, + "GET", + f"{_DASHBOARDS_PATH}:fetchMigratable", + 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 fetching migratable dashboards: %s", e, exc_info=True + ) + return {"error": f"Error fetching migratable dashboards: {e}"} + + +@server.tool() +async def batch_migrate_dashboards( + dashboards: Union[List[str], str], + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Migrate legacy (Looker) dashboards to native dashboards. v1alpha-only. + + Calls ``POST /dashboards:batchMigrate`` with the legacy dashboard resource + names. Chronicle starts an asynchronous migration job that builds a native + dashboard (``/nativeDashboards``) for each input. The response carries + ``migrationJobId`` and ``jobMigrationStatus``; poll + ``fetch_dashboard_migration_jobs`` for per-dashboard results. Some + dashboards may end in ``DASHBOARD_MIGRATION_STATE_NEEDS_REVIEW`` and + require manual checking in the Chronicle UI. + + **Workflow Integration:** + - Feed it the ``name`` values from ``fetch_migratable_dashboards``. + - Follow up with ``fetch_dashboard_migration_jobs(migration_job_id=...)``. + + **Use Cases:** + - Bulk-convert Looker dashboards to native dashboards + - Re-run migration for dashboards that previously failed + + Args: + dashboards (Union[List[str], str]): Legacy dashboard IDs or full + resource names, as a list or JSON array string. Bare IDs are + expanded to ``projects/.../instances/.../dashboards/{id}``. + 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]: ``{"migrationJobId": "...", "jobMigrationStatus": + "JOB_MIGRATION_STATE_IN_PROGRESS" | "..._SUCCESS" | + "..._PARTIAL_SUCCESS" | "..._FAILURE", "endTime": "..."}`` + plus ``dashboards_requested``, or an error message. + + Example Usage: + batch_migrate_dashboards(dashboards=["1234abcd", "5678efgh"]) + + Next Steps (using MCP-enabled tools): + - ``fetch_dashboard_migration_jobs(migration_job_id=)`` + - ``get_dashboard`` (native) on each result's ``nativeDashboard`` + """ + try: + chronicle = get_chronicle_client(project_id, customer_id, region) + parsed = parse_json_arg(dashboards, "dashboards") + if isinstance(dashboards, str) and parsed is None: + parsed = [dashboards] + if not isinstance(parsed, list) or not parsed: + return {"error": "dashboards must be a non-empty list of names"} + names = [_dashboard_name(chronicle, str(d)) for d in parsed] + logger.info("Batch migrating %d legacy dashboard(s)", len(names)) + response = chronicle_api_request( + chronicle, + "POST", + f"{_DASHBOARDS_PATH}:batchMigrate", + json_body={"dashboards": names}, + ) + response["dashboards_requested"] = names + return response + except Exception as e: + logger.error("Error batch migrating dashboards: %s", e, exc_info=True) + return {"error": f"Error batch migrating dashboards: {e}"} + + +@server.tool() +async def fetch_dashboard_migration_metrics( + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get instance-wide legacy-to-native dashboard migration metrics. v1alpha-only. + + Calls ``GET /dashboards:fetchMigrationMetrics``. Summarizes the Looker + dashboard migration for the whole instance: how many Looker dashboards + exist, how many have been migrated, are pending, processing or failed, + the overall success rate, and how many needed human-in-the-loop review. + + **Workflow Integration:** + - Report migration progress without enumerating dashboards. + - Decide whether another ``batch_migrate_dashboards`` round is needed. + + **Use Cases:** + - Track migration completion for a tenant + - Spot a high ``failedDashboardsCount`` or ``hitlCount`` + + 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]: ``{"totalMigrations", "successRate", "hitlCount", + "lookerDashboardCount", "migratedDashboardsCount", + "pendingDashboardsCount", "processingDashboardsCount", + "failedDashboardsCount"}`` or an error message. + + Example Usage: + fetch_dashboard_migration_metrics() + + Next Steps (using MCP-enabled tools): + - ``fetch_dashboard_migration_jobs`` to find the failing dashboards + - ``fetch_migratable_dashboards`` to list what is still pending + """ + try: + logger.info("Fetching dashboard migration metrics") + chronicle = get_chronicle_client(project_id, customer_id, region) + return chronicle_api_request( + chronicle, "GET", f"{_DASHBOARDS_PATH}:fetchMigrationMetrics" + ) + except Exception as e: + logger.error( + "Error fetching dashboard migration metrics: %s", e, exc_info=True + ) + return {"error": f"Error fetching dashboard migration metrics: {e}"} + + +@server.tool() +async def fetch_dashboard_migration_jobs( + migration_job_id: Optional[str] = None, + dashboard_id: Optional[str] = None, + project_id: Optional[str] = None, + customer_id: Optional[str] = None, + region: Optional[str] = None, +) -> Dict[str, Any]: + """Get legacy-to-native dashboard migration jobs and results. v1alpha-only. + + Calls ``GET /dashboards:fetchMigrationJobs``. With no filters it returns + every migration job for the instance; pass ``migration_job_id`` (from + ``batch_migrate_dashboards``) to inspect one job, or ``dashboard_id`` to + get only that legacy dashboard's migration result. Each job lists + ``results`` with per-dashboard ``status``, ``errorMessage`` and the + created ``nativeDashboard`` resource name. + + **Workflow Integration:** + - Poll after ``batch_migrate_dashboards`` until ``jobMigrationStatus`` + leaves ``JOB_MIGRATION_STATE_IN_PROGRESS``. + - Use ``results[].nativeDashboard`` to jump to the native dashboard. + + **Use Cases:** + - Check whether a migration finished and which dashboards failed + - Find the native dashboard created from a specific Looker dashboard + + Args: + migration_job_id (Optional[str]): Migration job ID to fetch. + dashboard_id (Optional[str]): Legacy dashboard ID or full resource + name; restricts results to that 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]: ``{"migrationJobs": [{"migrationJobId", + "jobMigrationStatus", "startedBy", "startTime", "endTime", + "results": [{"dashboard", "title", "status", "errorMessage", + "nativeDashboard"}]}], "total_jobs": N}`` or an error message. + Result ``status`` values: DASHBOARD_MIGRATION_STATE_PENDING, + _IN_PROGRESS, _SUCCESS, _NEEDS_REVIEW, _FAILED. + + Example Usage: + fetch_dashboard_migration_jobs(migration_job_id="job-123") + + Next Steps (using MCP-enabled tools): + - ``get_dashboard`` (native) on ``results[].nativeDashboard`` + - ``list_dashboards`` to browse all native dashboards + """ + try: + logger.info( + "Fetching dashboard migration jobs (job=%s, dashboard=%s)", + migration_job_id, + dashboard_id, + ) + chronicle = get_chronicle_client(project_id, customer_id, region) + params = { + "migrationJobId": migration_job_id, + "dashboard": ( + _dashboard_name(chronicle, dashboard_id) + if dashboard_id + else None + ), + } + response = chronicle_api_request( + chronicle, + "GET", + f"{_DASHBOARDS_PATH}:fetchMigrationJobs", + params=params, + ) + response["total_jobs"] = len(response.get("migrationJobs", [])) + return response + except Exception as e: + logger.error( + "Error fetching dashboard migration jobs: %s", e, exc_info=True + ) + return {"error": f"Error fetching dashboard migration jobs: {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..62ad8610 --- /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": f"{INSTANCE}/nativeDashboards/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": f"{INSTANCE}/nativeDashboards/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_dashboard_migration_unit.py b/server/secops/tests/test_legacy_dashboard_migration_unit.py new file mode 100644 index 00000000..6fa2bbb2 --- /dev/null +++ b/server/secops/tests/test_legacy_dashboard_migration_unit.py @@ -0,0 +1,305 @@ +# 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_dashboard_migration import ( + batch_migrate_dashboards, + copy_legacy_dashboard, + create_legacy_dashboard, + delete_legacy_dashboard, + fetch_dashboard_migration_jobs, + fetch_dashboard_migration_metrics, + fetch_migratable_dashboards, + get_legacy_dashboard, + list_legacy_dashboards, +) + +MODULE = "secops_mcp.tools.legacy_dashboard_migration" +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_create_legacy_dashboard(chronicle_client, api_request): + api_request.return_value = {"name": f"{INSTANCE}/dashboards/d1"} + + result = await create_legacy_dashboard( + display_name="SOC", + dashboard_type="DASHBOARD_TYPE_USER", + lookml="- dashboard: soc", + ) + + assert result == {"name": f"{INSTANCE}/dashboards/d1"} + assert call_of(api_request) == ( + "POST", + "/dashboards", + None, + { + "displayName": "SOC", + "type": "DASHBOARD_TYPE_USER", + "lookml": "- dashboard: soc", + }, + ) + + +@pytest.mark.asyncio +async def test_create_legacy_dashboard_json_string_resource( + chronicle_client, api_request +): + api_request.return_value = {} + + await create_legacy_dashboard( + dashboard='{"displayName": "From JSON", "type": "DASHBOARD_TYPE_CUSTOMER"}', + default_dashboard=True, + ) + + _, _, _, body = call_of(api_request) + assert body == { + "displayName": "From JSON", + "type": "DASHBOARD_TYPE_CUSTOMER", + "defaultDashboard": True, + } + + +@pytest.mark.asyncio +async def test_create_legacy_dashboard_requires_body( + chronicle_client, api_request +): + result = await create_legacy_dashboard() + + assert "error" in result + api_request.assert_not_called() + + +@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_fetch_migratable_dashboards(chronicle_client, api_request): + api_request.return_value = {"dashboards": [{"name": "a"}]} + + result = await fetch_migratable_dashboards(page_size=10) + + assert result["total_dashboards"] == 1 + assert call_of(api_request) == ( + "GET", + "/dashboards:fetchMigratable", + {"pageSize": 10, "pageToken": None, "view": None}, + None, + ) + + +@pytest.mark.asyncio +async def test_batch_migrate_dashboards_expands_ids( + chronicle_client, api_request +): + api_request.return_value = { + "migrationJobId": "job-1", + "jobMigrationStatus": "JOB_MIGRATION_STATE_IN_PROGRESS", + } + + result = await batch_migrate_dashboards( + dashboards=["d1", f"{INSTANCE}/dashboards/d2"] + ) + + expected_names = [ + f"{INSTANCE}/dashboards/d1", + f"{INSTANCE}/dashboards/d2", + ] + assert result["migrationJobId"] == "job-1" + assert result["dashboards_requested"] == expected_names + assert call_of(api_request) == ( + "POST", + "/dashboards:batchMigrate", + None, + {"dashboards": expected_names}, + ) + + +@pytest.mark.asyncio +async def test_batch_migrate_dashboards_json_string( + chronicle_client, api_request +): + api_request.return_value = {} + + await batch_migrate_dashboards(dashboards='["d1"]') + + _, _, _, body = call_of(api_request) + assert body == {"dashboards": [f"{INSTANCE}/dashboards/d1"]} + + +@pytest.mark.asyncio +async def test_fetch_dashboard_migration_metrics(chronicle_client, api_request): + api_request.return_value = {"totalMigrations": 3, "successRate": 0.66} + + result = await fetch_dashboard_migration_metrics() + + assert result == {"totalMigrations": 3, "successRate": 0.66} + assert call_of(api_request) == ( + "GET", + "/dashboards:fetchMigrationMetrics", + None, + None, + ) + + +@pytest.mark.asyncio +async def test_fetch_dashboard_migration_jobs(chronicle_client, api_request): + api_request.return_value = {"migrationJobs": [{"migrationJobId": "job-1"}]} + + result = await fetch_dashboard_migration_jobs( + migration_job_id="job-1", dashboard_id="d1" + ) + + assert result["total_jobs"] == 1 + assert call_of(api_request) == ( + "GET", + "/dashboards:fetchMigrationJobs", + { + "migrationJobId": "job-1", + "dashboard": f"{INSTANCE}/dashboards/d1", + }, + None, + ) + + +@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") From a7c2d7ec6110400b043c0925ca03cc5c32f1a72b Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Mon, 2 Nov 2026 17:44:18 +0000 Subject: [PATCH 2/3] fix(secops): send bare dashboard ID in scheduled report dashboard.name Live verification on a tenant with scheduled reports enabled showed that DashboardScheduledReportService stores and resolves `dashboard.name` as the bare native dashboard ID. Sending the full `projects/.../nativeDashboards/{id}` resource name fails with `INTERNAL: failed to fetch native dashboard details`. `create_dashboard_scheduled_report` and `update_dashboard_scheduled_report` now reduce full names to the bare ID. Verified live: create (bare ID and full name inputs), get, update (dashboard + cron), fetchHistory, duplicate, trigger, and delete all return 200. --- .../secops_mcp/tools/dashboard_reports.py | 17 ++++++++++++----- .../secops/tests/test_dashboard_reports_unit.py | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/server/secops/secops_mcp/tools/dashboard_reports.py b/server/secops/secops_mcp/tools/dashboard_reports.py index 2ba76f5f..1ba2c0f2 100644 --- a/server/secops/secops_mcp/tools/dashboard_reports.py +++ b/server/secops/secops_mcp/tools/dashboard_reports.py @@ -46,10 +46,16 @@ def _resource_id(value: str) -> str: def _dashboard_name(chronicle: Any, dashboard: str) -> str: - """Expand a bare native dashboard ID to its full resource name.""" - if "/" in dashboard: - return dashboard - return f"{chronicle.instance_id}/nativeDashboards/{dashboard}" + """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]: @@ -227,7 +233,8 @@ async def create_dashboard_scheduled_report( Args: display_name (str): Report name shown in the UI (required). dashboard_id (str): Native dashboard ID or full resource name - (`projects/.../nativeDashboards/{id}`); becomes `dashboard.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 diff --git a/server/secops/tests/test_dashboard_reports_unit.py b/server/secops/tests/test_dashboard_reports_unit.py index 62ad8610..2d0b7edf 100644 --- a/server/secops/tests/test_dashboard_reports_unit.py +++ b/server/secops/tests/test_dashboard_reports_unit.py @@ -99,7 +99,7 @@ async def test_create_dashboard_scheduled_report_builds_body(api): assert (method, path) == ("POST", REPORTS) assert kwargs["json_body"] == { "displayName": "Weekly", - "dashboard": {"name": f"{INSTANCE}/nativeDashboards/dash1"}, + "dashboard": {"name": "dash1"}, "cronDetails": {"cron": "0 8 * * 1", "timeZone": "UTC"}, "deliveryDetails": { "emailDelivery": {"recipients": ["a@example.com"], "subject": "Subj"} @@ -120,7 +120,7 @@ async def test_create_dashboard_scheduled_report_parses_json_strings(api): ) _, _, kwargs = _call(api) body = kwargs["json_body"] - assert body["dashboard"] == {"name": f"{INSTANCE}/nativeDashboards/full"} + assert body["dashboard"] == {"name": "full"} assert body["deliveryDetails"]["emailDelivery"]["recipients"] == [ "x@example.com", "y@example.com", From 56917d5033c0b547c99e10cfc3a4fb2f66f7106c Mon Sep 17 00:00:00 2001 From: Dan Dye Date: Mon, 2 Nov 2026 17:52:51 +0000 Subject: [PATCH 3/3] refactor(secops): drop untestable Looker migration and legacy create tools Removes five tools that could not be verified against any available tenant and notes them as future work in the README and docs: - fetch_migratable_dashboards, batch_migrate_dashboards, fetch_dashboard_migration_metrics, fetch_dashboard_migration_jobs: gated by the under-development `enable_looker_migration_wizard` feature (`403 Looker migration wizard is not enabled for the customer`). - create_legacy_dashboard: `POST /dashboards` fails server-side (`something went wrong, please try again later`) with or without LookML. The remaining legacy Looker tools (get, list, delete, copy) were verified live, so the module is renamed from legacy_dashboard_migration.py to legacy_dashboards.py. Tool count drops from 123 to 118 (49 new dashboard tools); 134 unit tests pass. --- docs/servers/secops_mcp.md | 41 +- server/secops/README.md | 21 +- server/secops/secops_mcp/tools/__init__.py | 2 +- .../tools/legacy_dashboard_migration.py | 694 ------------------ .../secops_mcp/tools/legacy_dashboards.py | 336 +++++++++ ...unit.py => test_legacy_dashboards_unit.py} | 150 +--- 6 files changed, 345 insertions(+), 899 deletions(-) delete mode 100644 server/secops/secops_mcp/tools/legacy_dashboard_migration.py create mode 100644 server/secops/secops_mcp/tools/legacy_dashboards.py rename server/secops/tests/{test_legacy_dashboard_migration_unit.py => test_legacy_dashboards_unit.py} (52%) diff --git a/docs/servers/secops_mcp.md b/docs/servers/secops_mcp.md index 375593aa..ca3b1732 100644 --- a/docs/servers/secops_mcp.md +++ b/docs/servers/secops_mcp.md @@ -1129,18 +1129,7 @@ The service account or user credentials need the following Chronicle roles: - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). - **Returns:** `{"nativeDashboard": "projects/.../nativeDashboards/{id}"}` or an error. -### Legacy (Looker) Dashboard and Migration Tools - -- **`create_legacy_dashboard(display_name=None, dashboard_type=None, lookml=None, default_dashboard=None, dashboard=None, project_id=None, customer_id=None, region=None)`** - - **Description:** Create a legacy (Looker/LookML) Chronicle dashboard. v1alpha-only. - - **Parameters:** - - `display_name` (optional): User-visible dashboard name. - - `dashboard_type` (optional): ``DASHBOARD_TYPE_USER`` (private to the caller) or ``DASHBOARD_TYPE_CUSTOMER`` (visible to all users). ``DASHBOARD_TYPE_PREDEFINED`` dashboards cannot be created. - - `lookml` (optional): LookML YAML defining the dashboard. - - `default_dashboard` (optional): Show this dashboard by default (only one dashboard may be the default). - - `dashboard` (optional): Full Dashboard resource as a dict or JSON string, e.g. ``{"displayName": "SOC Overview", "type": "DASHBOARD_TYPE_USER", "lookml": "..."}``. Explicit args above override matching keys. - - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). - - **Returns:** The created Dashboard resource (``name``, ``displayName``, ``type``, ``uri``, ...), or an error message. +### 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. @@ -1177,33 +1166,7 @@ The service account or user credentials need the following Chronicle roles: - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). - **Returns:** The newly created Dashboard resource, or an error. -- **`fetch_migratable_dashboards(page_size=None, page_token=None, view=None, project_id=None, customer_id=None, region=None)`** - - **Description:** List legacy (Looker) dashboards eligible for migration to native. 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. - -- **`batch_migrate_dashboards(dashboards, project_id=None, customer_id=None, region=None)`** - - **Description:** Migrate legacy (Looker) dashboards to native dashboards. v1alpha-only. - - **Parameters:** - - `dashboards` (required): Legacy dashboard IDs or full resource names, as a list or JSON array string. Bare IDs are expanded to ``projects/.../instances/.../dashboards/{id}``. - - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). - - **Returns:** ``{"migrationJobId": "...", "jobMigrationStatus": "JOB_MIGRATION_STATE_IN_PROGRESS" | "..._SUCCESS" | "..._PARTIAL_SUCCESS" | "..._FAILURE", "endTime": "..."}`` plus ``dashboards_requested``, or an error message. - -- **`fetch_dashboard_migration_metrics(project_id=None, customer_id=None, region=None)`** - - **Description:** Get instance-wide legacy-to-native dashboard migration metrics. v1alpha-only. - - **Returns:** ``{"totalMigrations", "successRate", "hitlCount", "lookerDashboardCount", "migratedDashboardsCount", "pendingDashboardsCount", "processingDashboardsCount", "failedDashboardsCount"}`` or an error message. - -- **`fetch_dashboard_migration_jobs(migration_job_id=None, dashboard_id=None, project_id=None, customer_id=None, region=None)`** - - **Description:** Get legacy-to-native dashboard migration jobs and results. v1alpha-only. - - **Parameters:** - - `migration_job_id` (optional): Migration job ID to fetch. - - `dashboard_id` (optional): Legacy dashboard ID or full resource name; restricts results to that dashboard. - - `project_id`, `customer_id`, `region` (optional): Chronicle instance override (defaults to environment config). - - **Returns:** ``{"migrationJobs": [{"migrationJobId", "jobMigrationStatus", "startedBy", "startTime", "endTime", "results": [{"dashboard", "title", "status", "errorMessage", "nativeDashboard"}]}], "total_jobs": N}`` or an error message. Result ``status`` values: DASHBOARD_MIGRATION_STATE_PENDING, _IN_PROGRESS, _SUCCESS, _NEEDS_REVIEW, _FAILED. +> **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 diff --git a/server/secops/README.md b/server/secops/README.md index 31d63380..9936e399 100644 --- a/server/secops/README.md +++ b/server/secops/README.md @@ -300,10 +300,7 @@ Chronicle Security Operations suite. - **`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 and Migration Tools - -- **`create_legacy_dashboard(display_name=None, dashboard_type=None, lookml=None, default_dashboard=None, dashboard=None, project_id=None, customer_id=None, region=None)`** - - Create a legacy (Looker/LookML) Chronicle dashboard. v1alpha-only. +### 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. @@ -317,17 +314,7 @@ Chronicle Security Operations suite. - **`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. -- **`fetch_migratable_dashboards(page_size=None, page_token=None, view=None, project_id=None, customer_id=None, region=None)`** - - List legacy (Looker) dashboards eligible for migration to native. v1alpha-only. - -- **`batch_migrate_dashboards(dashboards, project_id=None, customer_id=None, region=None)`** - - Migrate legacy (Looker) dashboards to native dashboards. v1alpha-only. - -- **`fetch_dashboard_migration_metrics(project_id=None, customer_id=None, region=None)`** - - Get instance-wide legacy-to-native dashboard migration metrics. v1alpha-only. - -- **`fetch_dashboard_migration_jobs(migration_job_id=None, dashboard_id=None, project_id=None, customer_id=None, region=None)`** - - Get legacy-to-native dashboard migration jobs and results. 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 @@ -403,7 +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, migrate legacy (Looker) dashboards, and query legacy SOAR dashboards +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 @@ -425,7 +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`, `batch_migrate_dashboards`, and `legacy_soar_get_dashboard_cards` to build, query, report on, migrate, and audit SIEM and SOAR dashboards +- **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 798e52bb..39e75714 100644 --- a/server/secops/secops_mcp/tools/__init__.py +++ b/server/secops/secops_mcp/tools/__init__.py @@ -21,7 +21,7 @@ from .feed_management import * from .investigation_management import * from .ioc_matches import * -from .legacy_dashboard_migration import * +from .legacy_dashboards import * from .legacy_soar_dashboard import * from .log_ingestion import * from .parser_management import * diff --git a/server/secops/secops_mcp/tools/legacy_dashboard_migration.py b/server/secops/secops_mcp/tools/legacy_dashboard_migration.py deleted file mode 100644 index 7afc8735..00000000 --- a/server/secops/secops_mcp/tools/legacy_dashboard_migration.py +++ /dev/null @@ -1,694 +0,0 @@ -# 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 and migration. - -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, 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_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 create_legacy_dashboard( - display_name: Optional[str] = None, - dashboard_type: Optional[str] = None, - lookml: Optional[str] = None, - default_dashboard: Optional[bool] = 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 legacy (Looker/LookML) Chronicle dashboard. v1alpha-only. - - Creates a dashboard under ``/dashboards``, the legacy Looker-backed - dashboard family. The dashboard is defined by a LookML model; the - recommended source is the ``lookml`` returned by ``get_legacy_dashboard`` - with ``view="DASHBOARD_VIEW_EXPORT"``. For new work prefer native - dashboards (``create_dashboard``); legacy dashboards exist mainly so they - can be migrated. - - **Workflow Integration:** - - Re-create or import a Looker dashboard from exported LookML. - - Pair with ``batch_migrate_dashboards`` to convert it to a native - dashboard afterwards. - - **Use Cases:** - - Restore a legacy dashboard from a LookML backup - - Stage a legacy dashboard for migration testing - - Args: - display_name (Optional[str]): User-visible dashboard name. - dashboard_type (Optional[str]): ``DASHBOARD_TYPE_USER`` (private to - the caller) or ``DASHBOARD_TYPE_CUSTOMER`` (visible to all users). - ``DASHBOARD_TYPE_PREDEFINED`` dashboards cannot be created. - lookml (Optional[str]): LookML YAML defining the dashboard. - default_dashboard (Optional[bool]): Show this dashboard by default - (only one dashboard may be the default). - dashboard (Optional[Union[Dict[str, Any], str]]): Full Dashboard - resource as a dict or JSON string, e.g. - ``{"displayName": "SOC Overview", "type": "DASHBOARD_TYPE_USER", - "lookml": "..."}``. Explicit args above override matching keys. - 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 created Dashboard resource (``name``, - ``displayName``, ``type``, ``uri``, ...), or an error message. - - Example Usage: - create_legacy_dashboard(display_name="SOC Overview", - dashboard_type="DASHBOARD_TYPE_USER", lookml=lookml_text) - - Next Steps (using MCP-enabled tools): - - ``get_legacy_dashboard`` to confirm the result - - ``batch_migrate_dashboards`` to convert it to a native dashboard - """ - try: - logger.info("Creating legacy dashboard %s", display_name) - chronicle = get_chronicle_client(project_id, customer_id, region) - body = _build_dashboard_resource( - dashboard, display_name, dashboard_type, lookml, default_dashboard - ) - if not body: - return { - "error": "Provide display_name/lookml or a dashboard resource" - } - return chronicle_api_request( - chronicle, "POST", _DASHBOARDS_PATH, json_body=body - ) - except Exception as e: - logger.error("Error creating legacy dashboard: %s", e, exc_info=True) - return {"error": f"Error creating legacy dashboard: {e}"} - - -@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 include the LookML definition, - which is the input format for ``create_legacy_dashboard``. 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`` - - ``batch_migrate_dashboards`` if not yet migrated - """ - 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. - - Compare with ``fetch_migratable_dashboards`` to see which are eligible. - - **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): - - ``fetch_migratable_dashboards`` to see migration eligibility - - ``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; to produce a - native dashboard use ``batch_migrate_dashboards`` instead. - - **Workflow Integration:** - - Copy a predefined dashboard, then migrate the copy to native. - - 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): - - ``batch_migrate_dashboards`` with 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}"} - - -@server.tool() -async def fetch_migratable_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) dashboards eligible for migration to native. v1alpha-only. - - Calls ``GET /dashboards:fetchMigratable``. Returns the subset of legacy - dashboards that Chronicle can convert into native dashboards, each with - a ``migrationInfo`` block (``state``, ``migrationJobId``, - ``nativeDashboard``). This is the first step of the Looker-to-native - migration workflow. The call may take several minutes on large instances. - - **Workflow Integration:** - - Run before ``batch_migrate_dashboards`` to pick candidates. - - Re-run afterwards; ``migrationInfo.state`` reflects progress. - - **Use Cases:** - - Plan a dashboard migration - - Find dashboards still in ``DASHBOARD_MIGRATION_STATE_PENDING`` - - 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: - fetch_migratable_dashboards(page_size=100) - - Next Steps (using MCP-enabled tools): - - ``batch_migrate_dashboards(dashboards=[...names...])`` - - ``fetch_dashboard_migration_metrics`` for an instance-wide summary - """ - try: - logger.info("Fetching migratable legacy dashboards") - chronicle = get_chronicle_client(project_id, customer_id, region) - response = chronicle_api_request( - chronicle, - "GET", - f"{_DASHBOARDS_PATH}:fetchMigratable", - 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 fetching migratable dashboards: %s", e, exc_info=True - ) - return {"error": f"Error fetching migratable dashboards: {e}"} - - -@server.tool() -async def batch_migrate_dashboards( - dashboards: Union[List[str], str], - project_id: Optional[str] = None, - customer_id: Optional[str] = None, - region: Optional[str] = None, -) -> Dict[str, Any]: - """Migrate legacy (Looker) dashboards to native dashboards. v1alpha-only. - - Calls ``POST /dashboards:batchMigrate`` with the legacy dashboard resource - names. Chronicle starts an asynchronous migration job that builds a native - dashboard (``/nativeDashboards``) for each input. The response carries - ``migrationJobId`` and ``jobMigrationStatus``; poll - ``fetch_dashboard_migration_jobs`` for per-dashboard results. Some - dashboards may end in ``DASHBOARD_MIGRATION_STATE_NEEDS_REVIEW`` and - require manual checking in the Chronicle UI. - - **Workflow Integration:** - - Feed it the ``name`` values from ``fetch_migratable_dashboards``. - - Follow up with ``fetch_dashboard_migration_jobs(migration_job_id=...)``. - - **Use Cases:** - - Bulk-convert Looker dashboards to native dashboards - - Re-run migration for dashboards that previously failed - - Args: - dashboards (Union[List[str], str]): Legacy dashboard IDs or full - resource names, as a list or JSON array string. Bare IDs are - expanded to ``projects/.../instances/.../dashboards/{id}``. - 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]: ``{"migrationJobId": "...", "jobMigrationStatus": - "JOB_MIGRATION_STATE_IN_PROGRESS" | "..._SUCCESS" | - "..._PARTIAL_SUCCESS" | "..._FAILURE", "endTime": "..."}`` - plus ``dashboards_requested``, or an error message. - - Example Usage: - batch_migrate_dashboards(dashboards=["1234abcd", "5678efgh"]) - - Next Steps (using MCP-enabled tools): - - ``fetch_dashboard_migration_jobs(migration_job_id=)`` - - ``get_dashboard`` (native) on each result's ``nativeDashboard`` - """ - try: - chronicle = get_chronicle_client(project_id, customer_id, region) - parsed = parse_json_arg(dashboards, "dashboards") - if isinstance(dashboards, str) and parsed is None: - parsed = [dashboards] - if not isinstance(parsed, list) or not parsed: - return {"error": "dashboards must be a non-empty list of names"} - names = [_dashboard_name(chronicle, str(d)) for d in parsed] - logger.info("Batch migrating %d legacy dashboard(s)", len(names)) - response = chronicle_api_request( - chronicle, - "POST", - f"{_DASHBOARDS_PATH}:batchMigrate", - json_body={"dashboards": names}, - ) - response["dashboards_requested"] = names - return response - except Exception as e: - logger.error("Error batch migrating dashboards: %s", e, exc_info=True) - return {"error": f"Error batch migrating dashboards: {e}"} - - -@server.tool() -async def fetch_dashboard_migration_metrics( - project_id: Optional[str] = None, - customer_id: Optional[str] = None, - region: Optional[str] = None, -) -> Dict[str, Any]: - """Get instance-wide legacy-to-native dashboard migration metrics. v1alpha-only. - - Calls ``GET /dashboards:fetchMigrationMetrics``. Summarizes the Looker - dashboard migration for the whole instance: how many Looker dashboards - exist, how many have been migrated, are pending, processing or failed, - the overall success rate, and how many needed human-in-the-loop review. - - **Workflow Integration:** - - Report migration progress without enumerating dashboards. - - Decide whether another ``batch_migrate_dashboards`` round is needed. - - **Use Cases:** - - Track migration completion for a tenant - - Spot a high ``failedDashboardsCount`` or ``hitlCount`` - - 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]: ``{"totalMigrations", "successRate", "hitlCount", - "lookerDashboardCount", "migratedDashboardsCount", - "pendingDashboardsCount", "processingDashboardsCount", - "failedDashboardsCount"}`` or an error message. - - Example Usage: - fetch_dashboard_migration_metrics() - - Next Steps (using MCP-enabled tools): - - ``fetch_dashboard_migration_jobs`` to find the failing dashboards - - ``fetch_migratable_dashboards`` to list what is still pending - """ - try: - logger.info("Fetching dashboard migration metrics") - chronicle = get_chronicle_client(project_id, customer_id, region) - return chronicle_api_request( - chronicle, "GET", f"{_DASHBOARDS_PATH}:fetchMigrationMetrics" - ) - except Exception as e: - logger.error( - "Error fetching dashboard migration metrics: %s", e, exc_info=True - ) - return {"error": f"Error fetching dashboard migration metrics: {e}"} - - -@server.tool() -async def fetch_dashboard_migration_jobs( - migration_job_id: Optional[str] = None, - dashboard_id: Optional[str] = None, - project_id: Optional[str] = None, - customer_id: Optional[str] = None, - region: Optional[str] = None, -) -> Dict[str, Any]: - """Get legacy-to-native dashboard migration jobs and results. v1alpha-only. - - Calls ``GET /dashboards:fetchMigrationJobs``. With no filters it returns - every migration job for the instance; pass ``migration_job_id`` (from - ``batch_migrate_dashboards``) to inspect one job, or ``dashboard_id`` to - get only that legacy dashboard's migration result. Each job lists - ``results`` with per-dashboard ``status``, ``errorMessage`` and the - created ``nativeDashboard`` resource name. - - **Workflow Integration:** - - Poll after ``batch_migrate_dashboards`` until ``jobMigrationStatus`` - leaves ``JOB_MIGRATION_STATE_IN_PROGRESS``. - - Use ``results[].nativeDashboard`` to jump to the native dashboard. - - **Use Cases:** - - Check whether a migration finished and which dashboards failed - - Find the native dashboard created from a specific Looker dashboard - - Args: - migration_job_id (Optional[str]): Migration job ID to fetch. - dashboard_id (Optional[str]): Legacy dashboard ID or full resource - name; restricts results to that 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]: ``{"migrationJobs": [{"migrationJobId", - "jobMigrationStatus", "startedBy", "startTime", "endTime", - "results": [{"dashboard", "title", "status", "errorMessage", - "nativeDashboard"}]}], "total_jobs": N}`` or an error message. - Result ``status`` values: DASHBOARD_MIGRATION_STATE_PENDING, - _IN_PROGRESS, _SUCCESS, _NEEDS_REVIEW, _FAILED. - - Example Usage: - fetch_dashboard_migration_jobs(migration_job_id="job-123") - - Next Steps (using MCP-enabled tools): - - ``get_dashboard`` (native) on ``results[].nativeDashboard`` - - ``list_dashboards`` to browse all native dashboards - """ - try: - logger.info( - "Fetching dashboard migration jobs (job=%s, dashboard=%s)", - migration_job_id, - dashboard_id, - ) - chronicle = get_chronicle_client(project_id, customer_id, region) - params = { - "migrationJobId": migration_job_id, - "dashboard": ( - _dashboard_name(chronicle, dashboard_id) - if dashboard_id - else None - ), - } - response = chronicle_api_request( - chronicle, - "GET", - f"{_DASHBOARDS_PATH}:fetchMigrationJobs", - params=params, - ) - response["total_jobs"] = len(response.get("migrationJobs", [])) - return response - except Exception as e: - logger.error( - "Error fetching dashboard migration jobs: %s", e, exc_info=True - ) - return {"error": f"Error fetching dashboard migration jobs: {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/tests/test_legacy_dashboard_migration_unit.py b/server/secops/tests/test_legacy_dashboards_unit.py similarity index 52% rename from server/secops/tests/test_legacy_dashboard_migration_unit.py rename to server/secops/tests/test_legacy_dashboards_unit.py index 6fa2bbb2..7122cd51 100644 --- a/server/secops/tests/test_legacy_dashboard_migration_unit.py +++ b/server/secops/tests/test_legacy_dashboards_unit.py @@ -17,19 +17,14 @@ import pytest -from secops_mcp.tools.legacy_dashboard_migration import ( - batch_migrate_dashboards, +from secops_mcp.tools.legacy_dashboards import ( copy_legacy_dashboard, - create_legacy_dashboard, delete_legacy_dashboard, - fetch_dashboard_migration_jobs, - fetch_dashboard_migration_metrics, - fetch_migratable_dashboards, get_legacy_dashboard, list_legacy_dashboards, ) -MODULE = "secops_mcp.tools.legacy_dashboard_migration" +MODULE = "secops_mcp.tools.legacy_dashboards" INSTANCE = "projects/p1/locations/us/instances/inst-1" @@ -55,58 +50,6 @@ def call_of(api_request): return args[1], args[2], kwargs.get("params"), kwargs.get("json_body") -@pytest.mark.asyncio -async def test_create_legacy_dashboard(chronicle_client, api_request): - api_request.return_value = {"name": f"{INSTANCE}/dashboards/d1"} - - result = await create_legacy_dashboard( - display_name="SOC", - dashboard_type="DASHBOARD_TYPE_USER", - lookml="- dashboard: soc", - ) - - assert result == {"name": f"{INSTANCE}/dashboards/d1"} - assert call_of(api_request) == ( - "POST", - "/dashboards", - None, - { - "displayName": "SOC", - "type": "DASHBOARD_TYPE_USER", - "lookml": "- dashboard: soc", - }, - ) - - -@pytest.mark.asyncio -async def test_create_legacy_dashboard_json_string_resource( - chronicle_client, api_request -): - api_request.return_value = {} - - await create_legacy_dashboard( - dashboard='{"displayName": "From JSON", "type": "DASHBOARD_TYPE_CUSTOMER"}', - default_dashboard=True, - ) - - _, _, _, body = call_of(api_request) - assert body == { - "displayName": "From JSON", - "type": "DASHBOARD_TYPE_CUSTOMER", - "defaultDashboard": True, - } - - -@pytest.mark.asyncio -async def test_create_legacy_dashboard_requires_body( - chronicle_client, api_request -): - result = await create_legacy_dashboard() - - assert "error" in result - api_request.assert_not_called() - - @pytest.mark.asyncio async def test_get_legacy_dashboard_accepts_full_name( chronicle_client, api_request @@ -207,95 +150,6 @@ async def test_copy_legacy_dashboard_requires_type( api_request.assert_not_called() -@pytest.mark.asyncio -async def test_fetch_migratable_dashboards(chronicle_client, api_request): - api_request.return_value = {"dashboards": [{"name": "a"}]} - - result = await fetch_migratable_dashboards(page_size=10) - - assert result["total_dashboards"] == 1 - assert call_of(api_request) == ( - "GET", - "/dashboards:fetchMigratable", - {"pageSize": 10, "pageToken": None, "view": None}, - None, - ) - - -@pytest.mark.asyncio -async def test_batch_migrate_dashboards_expands_ids( - chronicle_client, api_request -): - api_request.return_value = { - "migrationJobId": "job-1", - "jobMigrationStatus": "JOB_MIGRATION_STATE_IN_PROGRESS", - } - - result = await batch_migrate_dashboards( - dashboards=["d1", f"{INSTANCE}/dashboards/d2"] - ) - - expected_names = [ - f"{INSTANCE}/dashboards/d1", - f"{INSTANCE}/dashboards/d2", - ] - assert result["migrationJobId"] == "job-1" - assert result["dashboards_requested"] == expected_names - assert call_of(api_request) == ( - "POST", - "/dashboards:batchMigrate", - None, - {"dashboards": expected_names}, - ) - - -@pytest.mark.asyncio -async def test_batch_migrate_dashboards_json_string( - chronicle_client, api_request -): - api_request.return_value = {} - - await batch_migrate_dashboards(dashboards='["d1"]') - - _, _, _, body = call_of(api_request) - assert body == {"dashboards": [f"{INSTANCE}/dashboards/d1"]} - - -@pytest.mark.asyncio -async def test_fetch_dashboard_migration_metrics(chronicle_client, api_request): - api_request.return_value = {"totalMigrations": 3, "successRate": 0.66} - - result = await fetch_dashboard_migration_metrics() - - assert result == {"totalMigrations": 3, "successRate": 0.66} - assert call_of(api_request) == ( - "GET", - "/dashboards:fetchMigrationMetrics", - None, - None, - ) - - -@pytest.mark.asyncio -async def test_fetch_dashboard_migration_jobs(chronicle_client, api_request): - api_request.return_value = {"migrationJobs": [{"migrationJobId": "job-1"}]} - - result = await fetch_dashboard_migration_jobs( - migration_job_id="job-1", dashboard_id="d1" - ) - - assert result["total_jobs"] == 1 - assert call_of(api_request) == ( - "GET", - "/dashboards:fetchMigrationJobs", - { - "migrationJobId": "job-1", - "dashboard": f"{INSTANCE}/dashboards/d1", - }, - None, - ) - - @pytest.mark.asyncio async def test_error_path_returns_error_dict(chronicle_client, api_request): api_request.side_effect = RuntimeError("boom 403")