diff --git a/README.md b/README.md index 46bb918..b110713 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ See [docs/features.md](docs/features.md) for a full breakdown of each tool's cap > **Just want to try it?** Download [`example-mcp-app-security.mcpb`](https://github.com/elastic/example-mcp-app-security/releases/latest) and double-click it. No Node.js, no cloning, no config files. > > Claude Desktop handles the rest — during install, fill in your Elasticsearch URL, Kibana URL, and API key. See [Creating an API key](docs/setup-local.md#creating-an-api-key) if you need to generate one first. +> +> For the API key's permissions, see [Required permissions](docs/permissions.md). The recommended Quickstart there uses Kibana's built-in **editor** (full-featured) or **viewer** (read-only) role plus a small companion role for index access — fastest unless you need a fully scripted custom role. For other hosts (Cursor, VS Code, Claude Code) or building from source, see [Installation](#installation) below. diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..f0aecd6 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,523 @@ +# Minimum Required Permissions + +This guide defines the least-privilege roles for the Elastic Security MCP app on **stateful (self-managed and Elastic Cloud Hosted) deployments**. Two paths: + +- **[Quickstart](#quickstart--built-in-roles)** — assign a built-in Kibana role plus a small index-privileges add-on. Recommended for most users. +- **[Advanced — Custom roles](#advanced--custom-roles)** — fully scripted role JSON. Use when you need to provision via API/IaC, or want finer-grained control than the built-ins offer. + +> **Space ID:** Kibana index patterns include a `` segment (e.g., `.alerts-security.alerts-`). For most deployments this is `default`. Replace `` with your actual space ID throughout this guide. The app currently targets the `default` space. + +> **Serverless:** This guide targets stateful deployments. Serverless projects ship a different set of built-in roles (`t1_analyst`, `soc_manager`, etc.) and aren't covered here yet. + +--- + +## TL;DR — Paste this in Dev Tools + +If you just want a working role and aren't picky about going through the UI or using built-ins, paste these three commands into **Stack Management → Dev Tools** (as `elastic` or any user with `manage_security`). Replace `` with your space (typically `default`) and pick a strong password. + +``` +PUT /_security/role/mcp_app_full +{ + "cluster": ["monitor"], + "indices": [ + { + "names": [ + ".alerts-security.alerts-", + ".alerts-security.attack.discovery.alerts-", + ".adhoc.alerts-security.attack.discovery.alerts-", + ".internal.alerts-security.alerts--*", + ".internal.alerts-security.attack.discovery.alerts--*", + ".internal.adhoc.alerts-security.attack.discovery.alerts--*", + "logs-*", + "risk-score.risk-score-latest-*" + ], + "privileges": ["read", "write", "monitor", "view_index_metadata"] + } + ], + "applications": [ + { + "application": "kibana-.kibana", + "privileges": [ + "feature_siemV5.all", + "feature_securitySolutionCasesV3.all", + "feature_securitySolutionTimeline.all", + "feature_securitySolutionNotes.all", + "feature_securitySolutionRulesV4.all", + "feature_securitySolutionAlertsV1.all", + "feature_securitySolutionAssistant.all", + "feature_securitySolutionAttackDiscovery.all", + "feature_actions.all" + ], + "resources": ["space:"] + } + ] +} + +PUT /_security/user/mcp_app_user +{ + "password": "", + "roles": ["mcp_app_full"] +} + +POST /_security/api_key/grant +{ + "grant_type": "password", + "username": "mcp_app_user", + "password": "", + "api_key": { "name": "mcp-app-full" } +} +``` + +Use the `encoded` field from the last response as the `elasticsearchApiKey` for your cluster entry in `CLUSTERS_JSON` (or `CLUSTERS_FILE`) — see [Cluster configuration](setup-local.md#cluster-configuration). + +Targets **9.4+**. For 9.3 or 9.0–9.2, swap the Kibana feature names — see the [version-specific tables](#kibana-feature-privileges). For a strict read-only key, see the [Read-Only Role](#read-only-role). + +Prefer built-in Kibana roles (`editor`/`viewer`) instead? See the [Quickstart](#quickstart--built-in-roles) below. + +--- + +## Quickstart — Built-in roles + +Two pre-built Kibana roles cover the entire Kibana feature surface the MCP app needs (Security, Cases, Timeline, Notes, Rules, Alerts, AI Assistant, Attack Discovery, Actions and Connectors). You only need to add a small block of **Elasticsearch index privileges** on top — Kibana feature privileges require no toggling. + +### Full-featured access + +**1. Create a companion role for index privileges** + +Stack Management → Roles → **Create role** → name it `mcp_app_indexes_full`, grant **Cluster privilege** `monitor`, and grant the following **Index privileges**: + +| Index pattern | Privileges | +|---|---| +| `.alerts-security.alerts-` | `read`, `write`, `monitor`, `view_index_metadata` | +| `.alerts-security.attack.discovery.alerts-` | `read`, `write`, `monitor`, `view_index_metadata` | +| `.adhoc.alerts-security.attack.discovery.alerts-` | `read`, `write`, `monitor`, `view_index_metadata` | +| `.internal.alerts-security.alerts--*` | `read`, `write`, `monitor`, `view_index_metadata` | +| `.internal.alerts-security.attack.discovery.alerts--*` | `read`, `write`, `monitor`, `view_index_metadata` | +| `.internal.adhoc.alerts-security.attack.discovery.alerts--*` | `read`, `write`, `monitor`, `view_index_metadata` | +| `logs-*` | `read`, `write`, `monitor`, `view_index_metadata` | +| `risk-score.risk-score-latest-*` | `read`, `monitor`, `view_index_metadata` | + +> Why cluster `monitor`, index-level `monitor`, *and* `view_index_metadata`: `_cat/indices/` (Threat Hunt's index list) needs cluster `monitor` (to enumerate indices at all) plus index-level `monitor` (which covers `indices:monitor/stats` and `indices:monitor/settings/get`). `_mapping` (Threat Hunt's field-mapping picker) needs index-level `view_index_metadata` — that's a separate privilege from `monitor`, despite the overlap in name. Neither `editor` nor `viewer` grants cluster `monitor`, so it has to come from this companion role; `viewer` also doesn't grant `view_index_metadata` on the `.internal.alerts-security.*` backing indices, which is why the companion grants it explicitly. + +No Kibana-feature or application privileges in this companion role — those come from `editor`. + +**2. Assign roles to a user** + +Stack Management → Users → **Create user** (or edit an existing one) → assign **both** roles: + +- `editor` (built-in) — covers all Kibana features for read/write Security workflows. +- `mcp_app_indexes_full` (the companion role created above). + +**3. Create an API key for that user** + +As an admin (e.g. logged in as `elastic`), open **Dev Tools** and grant a key on the user's behalf: + +``` +POST /_security/api_key/grant +{ + "grant_type": "password", + "username": "", + "password": "", + "api_key": { "name": "mcp-app-full" } +} +``` + +The response includes an `encoded` field — use that as the `elasticsearchApiKey` for your cluster entry in `CLUSTERS_JSON` (or `CLUSTERS_FILE`). The key inherits the user's combined privileges from `editor` + the companion role. + +### Read-only access + +**1. Create a companion role for index privileges** + +Stack Management → Roles → **Create role** → name it `mcp_app_indexes_readonly`, grant **Cluster privilege** `monitor`, and grant the following **Index privileges**: + +| Index pattern | Privileges | +|---|---| +| `.alerts-security.alerts-` | `read`, `monitor`, `view_index_metadata` | +| `.alerts-security.attack.discovery.alerts-` | `read`, `monitor`, `view_index_metadata` | +| `.adhoc.alerts-security.attack.discovery.alerts-` | `read`, `monitor`, `view_index_metadata` | +| `.internal.alerts-security.alerts--*` | `read`, `monitor`, `view_index_metadata` | +| `.internal.alerts-security.attack.discovery.alerts--*` | `read`, `monitor`, `view_index_metadata` | +| `.internal.adhoc.alerts-security.attack.discovery.alerts--*` | `read`, `monitor`, `view_index_metadata` | +| `logs-*` | `read`, `monitor`, `view_index_metadata` | +| `risk-score.risk-score-latest-*` | `read`, `monitor`, `view_index_metadata` | + +**2. Assign roles to a user** + +Assign **both** `viewer` (built-in) and `mcp_app_indexes_readonly` to the user. + +**3. Create an API key** + +Same as above — as an admin, run `POST /_security/api_key/grant` with the read-only user's credentials and an `api_key.name` of `mcp-app-readonly`. + +### What the built-ins cover + +| Surface | `editor` | `viewer` | +|---|---|---| +| All Kibana features (SIEM, Cases, Timeline, Notes, Rules, Alerts, AI Assistant, Attack Discovery, Actions/Connectors) | All | Read | +| Alert acknowledgment, case CRUD, rule CRUD | Yes (via Kibana APIs) | No | +| Sample-data generation | Needs companion `write` on `logs-*` (covered above) | Disabled | +| Threat Hunt index listing | Needs companion `monitor` on target indices (covered above) | Same | + +The built-ins eliminate the need to specify per-feature privileges like `feature_siemV5.all` or `feature_securitySolutionRulesV4.all`. Those names change between minor versions; the built-ins absorb the churn. + +--- + +## Troubleshooting + +### 401 Unauthorized + +- API key is invalid, expired, or malformed +- Verify with: `curl -s -H "Authorization: ApiKey " /_security/_authenticate` + +### 403 Forbidden on Kibana APIs + +- Missing Kibana feature privileges — the role needs application privileges on `kibana-.kibana`. The Quickstart's `editor`/`viewer` covers these by default. +- Check which features are missing: the 403 response body usually names the required privilege. +- Common cause: forgetting to grant privileges in the correct Kibana space. + +### 403 on rules-read despite granting `feature_securitySolutionRulesV4.read` + +- If you've followed the documented privileges and still see 403 on rules-read endpoints (e.g. `GET /api/detection_engine/rules/_find`), add `feature_siemV4.read` to your role's read privileges (and `feature_siemV4.all` for the full-featured role) as a transitional workaround. +- The Quickstart path may already include this via the built-in `editor` / `viewer` roles. + +### 403 on Threat Hunt → "list indices" + +- The companion role is missing index-level `monitor` on the target index pattern. +- `_cat/indices/` requires index-level `monitor`; cluster-level `monitor` alone is not sufficient. + +### Attack Discovery returns 403 + +- Built-in `editor` covers the AI Assistant, Attack discovery, and Actions/Connectors feature privileges. If you scoped the key narrower than `editor`, restore those grants — Attack Discovery requires **all three** plus Rules and Alerts. + +### Sample-data generation returns 403 + +- Companion role is missing `write` on `logs-*` and the alert backing indices. The `editor` built-in does **not** grant raw index `write`; it must come from the companion role. + +### Space ID mismatch + +- Index patterns use the Kibana space ID (e.g., `.alerts-security.alerts-default`) +- If using a non-default space, update all index patterns in the companion role +- The app currently targets the `default` space + +### "No alerts found" but alerts exist in Kibana + +- The companion role's index privileges may not cover the alert index for your space +- Check the space ID in the index pattern matches your Kibana space + +--- + +## Advanced — Custom roles + +Use this path when: + +- You provision roles via API, Terraform, or other IaC and want a single self-contained role definition. +- You need finer-grained restriction than the built-ins offer (e.g. Cases-only, no Threat Hunt). +- Your users' built-in role assignments are managed elsewhere and you can't add `editor`/`viewer` to them. + +Each role below is a single self-contained definition — no companion role required. + +### Full-Featured Role + +#### Cluster privileges + +| Privilege | Why | +|---|---| +| `monitor` | `_cat/indices` for Threat Hunt, AI Assistant prerequisite | + +#### Index privileges + +**System / alert indices** (`read`, `write`, `monitor`, `view_index_metadata`): + +| Index pattern | Used by | +|---|---| +| `.alerts-security.alerts-` | Alert Triage, Attack Discovery, Detection Rules, Case Management, Sample Data cleanup | +| `.alerts-security.attack.discovery.alerts-` | Attack Discovery | +| `.adhoc.alerts-security.attack.discovery.alerts-` | Attack Discovery (ad-hoc generated) | +| `.internal.alerts-security.alerts--*` | Backing indices for the alert data stream | +| `.internal.alerts-security.attack.discovery.alerts--*` | Backing indices for the attack-discovery data stream | +| `.internal.adhoc.alerts-security.attack.discovery.alerts--*` | Backing indices for the ad-hoc attack-discovery data stream | + +**Data indices** (`read`, `write`, `monitor`, `view_index_metadata`): + +| Index pattern | Used by | +|---|---| +| `logs-*` | Threat Hunt, Alert Triage (enrichment), Sample Data (write + cleanup) | +| `risk-score.risk-score-latest-*` | Attack Discovery (entity risk scoring) | + +#### Kibana feature privileges + +
+9.4+ (recommended) + +| Feature | Privilege | Why | +|---|---|---| +| Security > Security | All | Base access gate | +| Security > Cases | All | Case CRUD | +| Security > Timeline | All | Investigation timelines | +| Security > Notes | All | Timeline notes | +| Security > Rules and Exceptions | All | Rule CRUD, Attack Discovery prerequisite | +| Security > Alerts | All | Alert triage, Attack Discovery prerequisite | +| Security > Elastic AI Assistant | All | Anonymization fields for Attack Discovery | +| Security > Attack discovery | All | Generate/view/acknowledge discoveries | +| Management > Actions and Connectors | All | AI connector execution for Attack Discovery | + +
+ +
+9.3 (diff from 9.4+) + +- "Rules and Exceptions" and "Alerts" are combined into a single **"Rules, Alerts, and Exceptions"** feature — grant **All** +- All other features remain the same + +
+ +
+9.0 - 9.2 (diff from 9.4+) + +- Rules, Alerts, and Exceptions are all part of the base **"Security"** feature — grant **All** +- No separate Rules or Alerts features exist +- All other features remain the same + +
+ +#### Dev Tools: Create the role + +``` +PUT /_security/role/mcp_app_full +{ + "cluster": ["monitor"], + "indices": [ + { + "names": [ + ".alerts-security.alerts-", + ".alerts-security.attack.discovery.alerts-", + ".adhoc.alerts-security.attack.discovery.alerts-", + ".internal.alerts-security.alerts--*", + ".internal.alerts-security.attack.discovery.alerts--*", + ".internal.adhoc.alerts-security.attack.discovery.alerts--*", + "logs-*", + "risk-score.risk-score-latest-*" + ], + "privileges": ["read", "write", "monitor", "view_index_metadata"] + } + ], + "applications": [ + { + "application": "kibana-.kibana", + "privileges": [ + "feature_siemV5.all", + "feature_securitySolutionCasesV3.all", + "feature_securitySolutionTimeline.all", + "feature_securitySolutionNotes.all", + "feature_securitySolutionRulesV4.all", + "feature_securitySolutionAlertsV1.all", + "feature_securitySolutionAssistant.all", + "feature_securitySolutionAttackDiscovery.all", + "feature_actions.all" + ], + "resources": ["space:"] + } + ] +} +``` + +> Replace `` with your Kibana space ID (typically `default`). + +#### Create an API key for this role + +Two-step recipe — assign the role to a user, then have an admin grant an API key that inherits the user's privileges: + +``` +PUT /_security/user/mcp_app_user +{ + "password": "", + "roles": ["mcp_app_full"] +} +``` + +Then, authenticated as an admin (e.g. `elastic`): + +``` +POST /_security/api_key/grant +{ + "grant_type": "password", + "username": "mcp_app_user", + "password": "", + "api_key": { "name": "mcp-app-full" } +} +``` + +The response includes an `encoded` field — use that as the `elasticsearchApiKey` for your cluster entry in `CLUSTERS_JSON` (or `CLUSTERS_FILE`). The key inherits the user's role privileges directly. + +### Read-Only Role + +A strict read-only role: view everything, change nothing. + +> Need to acknowledge alerts or create cases? Use the full-featured role, or build a custom role using the [per-tool privilege breakdown](#appendix-per-tool-privilege-breakdown). + +#### Cluster privileges + +| Privilege | Why | +|---|---| +| `monitor` | `_cat/indices` for Threat Hunt | + +#### Index privileges + +**All index patterns** (`read`, `monitor`, `view_index_metadata`): + +| Index pattern | Used by | +|---|---| +| `.alerts-security.alerts-` | Alert Triage, Detection Rules, Case viewing | +| `.alerts-security.attack.discovery.alerts-` | Attack Discovery viewing | +| `.adhoc.alerts-security.attack.discovery.alerts-` | Attack Discovery viewing | +| `.internal.alerts-security.alerts--*` | Backing indices for the alert data stream | +| `.internal.alerts-security.attack.discovery.alerts--*` | Backing indices for the attack-discovery data stream | +| `.internal.adhoc.alerts-security.attack.discovery.alerts--*` | Backing indices for the ad-hoc attack-discovery data stream | +| `logs-*` | Threat Hunt, Alert Triage (enrichment) | +| `risk-score.risk-score-latest-*` | Attack Discovery (entity risk scoring) | + +#### Kibana feature privileges (9.4+) + +| Feature | Privilege | Why | +|---|---|---| +| Security > Security | Read | Base access gate | +| Security > Cases | Read | View cases | +| Security > Timeline | Read | View timelines | +| Security > Notes | Read | View notes | +| Security > Rules and Exceptions | Read | View rules | +| Security > Alerts | Read | View alerts | +| Security > Elastic AI Assistant | None | Not available in read-only | +| Security > Attack discovery | None | Not available in read-only | +| Management > Actions and Connectors | Read | List configured AI connectors (no execute) | + +#### Dev Tools: Create the role + +``` +PUT /_security/role/mcp_app_readonly +{ + "cluster": ["monitor"], + "indices": [ + { + "names": [ + ".alerts-security.alerts-", + ".alerts-security.attack.discovery.alerts-", + ".adhoc.alerts-security.attack.discovery.alerts-", + ".internal.alerts-security.alerts--*", + ".internal.alerts-security.attack.discovery.alerts--*", + ".internal.adhoc.alerts-security.attack.discovery.alerts--*", + "logs-*", + "risk-score.risk-score-latest-*" + ], + "privileges": ["read", "monitor", "view_index_metadata"] + } + ], + "applications": [ + { + "application": "kibana-.kibana", + "privileges": [ + "feature_siemV5.read", + "feature_securitySolutionCasesV3.read", + "feature_securitySolutionTimeline.read", + "feature_securitySolutionNotes.read", + "feature_securitySolutionRulesV4.read", + "feature_securitySolutionAlertsV1.read", + "feature_actions.read" + ], + "resources": ["space:"] + } + ] +} +``` + +#### Create an API key for this role + +``` +PUT /_security/user/mcp_app_readonly_user +{ + "password": "", + "roles": ["mcp_app_readonly"] +} +``` + +Then, authenticated as an admin (e.g. `elastic`): + +``` +POST /_security/api_key/grant +{ + "grant_type": "password", + "username": "mcp_app_readonly_user", + "password": "", + "api_key": { "name": "mcp-app-readonly" } +} +``` + +--- + +## Appendix: Per-Tool Privilege Breakdown + +Use this appendix to build custom roles that enable only specific tools. + +### Alert Triage + +| Operation | Cluster | Index privileges | Kibana features | +|---|---|---|---| +| Search alerts | `monitor` | `read` on `.alerts-security.alerts-` | Security (Read), Alerts (Read) | +| View alert context | `monitor` | `read` on `.alerts-security.alerts-`, `logs-endpoint.events.process-*`, `logs-endpoint.events.network-*` | Security (Read), Alerts (Read) | +| Acknowledge alerts | `monitor` | `read`, `write` on `.alerts-security.alerts-` and `.internal.alerts-security.alerts--*` | Security (All), Alerts (All) | + +### Attack Discovery + +| Operation | Cluster | Index privileges | Kibana features | +|---|---|---|---| +| View discoveries | `monitor` | `read` on `.alerts-security.attack.discovery.alerts-`, `.adhoc.alerts-security.attack.discovery.alerts-` | Security (Read), Attack discovery (Read) | +| Assess confidence | `monitor` | `read` on `.alerts-security.alerts-`, `risk-score.risk-score-latest-*` | Security (Read), Attack discovery (Read) | +| Generate discoveries | `monitor` | `read` on `.alerts-security.alerts-` | Security (All), Rules and Exceptions (All), Alerts (All), Elastic AI Assistant (All), Attack discovery (All), Actions and Connectors (All) | +| Acknowledge discoveries | `monitor` | `read`, `write` on `.alerts-security.attack.discovery.alerts-`, `.adhoc.alerts-security.attack.discovery.alerts-` and the matching `.internal.*-*` backing-index patterns | Security (All), Attack discovery (All) | + +### Case Management + +| Operation | Cluster | Index privileges | Kibana features | +|---|---|---|---| +| List/view cases | — | — | Security (Read), Cases (Read) | +| View case alerts | — | `read` on `.alerts-security.alerts-` | Security (Read), Cases (Read), Alerts (Read) | +| Create/update cases | — | — | Security (All), Cases (All) | +| Add comments | — | — | Security (All), Cases (All) | +| Attach alerts | — | — | Security (All), Cases (All) | + +### Detection Rules + +| Operation | Cluster | Index privileges | Kibana features | +|---|---|---|---| +| List/view rules | — | — | Security (Read), Rules and Exceptions (Read) | +| Validate queries | — | `read` on `.alerts-security.alerts-` | Security (Read) | +| Noisy rules report | — | `read` on `.alerts-security.alerts-` | Security (Read), Rules and Exceptions (Read), Alerts (Read) | +| Create/patch/delete rules | — | — | Security (All), Rules and Exceptions (All) | +| Bulk action on rules (`_bulk_action`) | — | — | Security (All), Rules and Exceptions (All) | +| Toggle rules | — | — | Security (All), Rules and Exceptions (All) | +| List exceptions | — | — | Security (Read), Rules and Exceptions (Read) | +| Add exceptions | — | — | Security (All), Rules and Exceptions (All) | + +### Threat Hunt + +| Operation | Cluster | Index privileges | Kibana features | +|---|---|---|---| +| ES\|QL queries | `monitor` | `read` on target indices (e.g., `logs-*`) | Security (Read) | +| List indices | `monitor` | `read`, `monitor` on target indices | Security (Read) | +| Field mappings | `monitor` | `read`, `view_index_metadata` on target indices | Security (Read) | +| Entity detail | `monitor` | `read` on `logs-endpoint.events.process-*`, `logs-endpoint.events.network-*`, `.alerts-security.alerts-` | Security (Read), Alerts (Read) | + +### Sample Data + +| Operation | Cluster | Index privileges | Kibana features | +|---|---|---|---| +| Check existing data | `monitor` | `read` on `.alerts-security.alerts-`, `logs-*` | Security (Read) | +| Generate sample data | `monitor` | `read`, `write` on `logs-*`, `.alerts-security.alerts-` and `.internal.alerts-security.alerts--*` | Security (All), Rules and Exceptions (All), Alerts (All) | +| Cleanup sample data | `monitor` | `read`, `write` on `logs-*`, `.alerts-security.alerts-` and `.internal.alerts-security.alerts--*` | Security (All), Rules and Exceptions (All), Alerts (All) | + +--- + +## Notes + +### Legacy `.siem-signals-` index + +If your cluster was upgraded from Elastic Security 8.0 or earlier, detection alerts may still exist in `.siem-signals-` instead of `.alerts-security.alerts-`. Add `read` (and `write` for acknowledgment) privileges on `.siem-signals-` if you need to access these legacy alerts. diff --git a/docs/setup-claude-code.md b/docs/setup-claude-code.md index 3af23fe..61f81b5 100644 --- a/docs/setup-claude-code.md +++ b/docs/setup-claude-code.md @@ -15,6 +15,8 @@ claude mcp add elastic-security \ > **Pinning a version:** Replace `elastic-security-mcp-app.tgz` with `elastic-security-mcp-app-.tgz` (e.g., `elastic-security-mcp-app-0.2.0.tgz`). > > **Keeping secrets out of shell history:** swap `CLUSTERS_JSON` for `CLUSTERS_FILE=/absolute/path/to/clusters.json` pointing at a JSON file with the same array. See [Creating an API key](./setup-local.md#creating-an-api-key) and [Cluster configuration](./setup-local.md#cluster-configuration). +> +> **Permissions:** For production use, create a scoped role instead of using `superuser`. See [Minimum required permissions](permissions.md) for ready-to-paste role definitions. ## Option 2: Local server (stdio) diff --git a/docs/setup-claude-desktop.md b/docs/setup-claude-desktop.md index 5e3db8d..c29ee51 100644 --- a/docs/setup-claude-desktop.md +++ b/docs/setup-claude-desktop.md @@ -14,6 +14,8 @@ Claude Desktop installs the extension and then opens a configuration dialog. Fil - **Elasticsearch API Key** — see [Creating an API key](./setup-local.md#creating-an-api-key) - **Kibana URL** +> **Permissions:** For production use, create a scoped role instead of using `superuser`. See [Minimum required permissions](permissions.md) for ready-to-paste role definitions. + After install: - Claude Desktop may show the connector as disabled at first. Toggle it on to enable the server. diff --git a/docs/setup-cursor.md b/docs/setup-cursor.md index 09bb055..a8feb8c 100644 --- a/docs/setup-cursor.md +++ b/docs/setup-cursor.md @@ -13,6 +13,8 @@ Click to install: > After clicking, Cursor opens its MCP settings with `CLUSTERS_JSON` pre-filled. Replace the placeholder URLs and API key with your real values. See [Creating an API key](./setup-local.md#creating-an-api-key) for how to generate your credentials. +> +> **Permissions:** For production use, create a scoped role instead of using `superuser`. See [Minimum required permissions](permissions.md) for ready-to-paste role definitions. Or add manually to `.cursor/mcp.json`: diff --git a/docs/setup-local.md b/docs/setup-local.md index 2b34476..f944928 100644 --- a/docs/setup-local.md +++ b/docs/setup-local.md @@ -18,7 +18,7 @@ You need an Elasticsearch API key with sufficient privileges for the operations - **Kibana UI:** Go to **Stack Management > API Keys > Create API key** - **Elastic docs:** [Elasticsearch API keys](https://www.elastic.co/docs/deploy-manage/api-keys/elasticsearch-api-keys) -For a quick start, a key with the `superuser` role works for all tools. For production, scope the key to the minimum required privileges. +For a quick start, a key with the `superuser` role works for all tools. For production, scope the key to the minimum required privileges — see [Minimum required permissions](permissions.md) for ready-to-paste role definitions. Kibana API keys and Elasticsearch API keys are the same underlying credential type. This project uses the same `elasticsearchApiKey` value for both Elasticsearch and Kibana requests, so you only need to configure one API key. diff --git a/docs/setup-vscode.md b/docs/setup-vscode.md index 2276672..284ae3a 100644 --- a/docs/setup-vscode.md +++ b/docs/setup-vscode.md @@ -31,6 +31,8 @@ Add to `.vscode/mcp.json`: > **Pinning a version:** Replace `elastic-security-mcp-app.tgz` with `elastic-security-mcp-app-.tgz` (e.g., `elastic-security-mcp-app-0.2.0.tgz`). > > **Keeping secrets out of `mcp.json`:** replace `CLUSTERS_JSON` with `CLUSTERS_FILE` pointing at the absolute path of a JSON file containing the same array. See [Cluster configuration](./setup-local.md#cluster-configuration). +> +> **Permissions:** For production use, create a scoped role instead of using `superuser`. See [Minimum required permissions](permissions.md) for ready-to-paste role definitions. ## Option 2: Local server (stdio) diff --git a/package.json b/package.json index 91dfc76..983e3ca 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "start:stdio": "node dist/main.js --stdio", "dev": "concurrently --raw \"node scripts/build-views.js --watch\" \"tsx watch main.ts\"", "typecheck": "tsc --noEmit", + "test:permissions": "tsx scripts/test-permissions/runner.ts", "skills:zip": "bash scripts/build-skill-zips.sh", "mcpb:pack": "bash scripts/build-mcpb.sh", "lint": "eslint .", diff --git a/scripts/test-permissions/README.md b/scripts/test-permissions/README.md new file mode 100644 index 0000000..19e2101 --- /dev/null +++ b/scripts/test-permissions/README.md @@ -0,0 +1,99 @@ +# Permissions Test Tooling + +Verifies that the role definitions documented in [`docs/permissions.md`](../../docs/permissions.md) actually work end-to-end against a real Elasticsearch + Kibana cluster. Provisions both documented roles, creates scoped API keys, and exercises every documented operation through the existing `src/elastic/*` business-logic modules. + +## Quick Start + +```bash +# Make sure .env has ELASTICSEARCH_URL, KIBANA_URL, ELASTIC_PASSWORD +# (and ELASTIC_USERNAME if not the default "elastic"). +# The runner authenticates as that user via Basic auth to bootstrap an +# admin API key, so the user must have manage_security plus enough +# privileges to seed sample data — `superuser` works for local dev. + +npm run test:permissions +``` + +Exit code is `0` if every check passes (or is skipped); `1` otherwise. + +## Flags + +| Flag | Description | +|---|---| +| `--role full\|readonly\|both` | Which role(s) to test (default: `both`). | +| `--cleanup-stale` | Delete leftover `mcp-app-test-*` roles and API keys before running. Useful after a crashed run. | +| `--no-cleanup` | Skip cleanup at the end and print the provisioned API keys so you can re-use them for manual debugging. | +| `--verbose`, `-v` | Print fixtures, stale-cleanup actions, and other debug info. | +| `-h`, `--help` | Show help. | + +Pass flags via `--`, e.g. `npm run test:permissions -- --role readonly --verbose`. + +## What it does + +1. **Pre-flight.** Loads admin credentials from `.env`. Calls `checkExistingData()`; if the cluster has zero security alerts, calls `generateSampleData({ count: 50 })` to seed. +2. **Capture fixtures.** Picks one alert, one case (creates one if none exist), and the first detection rule. +3. **For each role (`full`, then `readonly`):** + - Creates a role `mcp-app-test--` and an API key scoped to it. + - **Layer A** — calls `POST /_security/user/_has_privileges` as the scoped key, asserting that every privilege listed in the role descriptor is granted. + - **Layer B** — exercises one or more operations per group (alerts, cases, rules, attack-discovery, threat-hunt, sample-data) using the actual `src/elastic/*` functions. Reads must succeed for both roles. Writes must succeed for `full` and 403 for `readonly`. +4. **Report** — prints per-role results grouped by operation group. Symbols: `✓` pass, `✗` fail, `→` skipped. +5. **Cleanup** — always (in a `finally` block, and on `SIGINT`): invalidates API keys and deletes roles created by the run. + +Created cases / rules / sample documents are tagged `mcp-app-test` and are **not** automatically deleted. If they accumulate, clean them up via Kibana or with a tag-scoped delete. + +## Output Interpretation + +Each line in the report looks like: + +``` +✓ acknowledgeAlert (expect 403) — denied (403/401) +✗ createCase (expect 403) — expected 403 but call succeeded +→ patchRule (expect 403) — no fixture available for this check +``` + +- `pass` (`✓`) — outcome matched the expectation (call succeeded when expected `ok`, or call returned 403/401 when expected `403`). +- `fail` (`✗`) — outcome diverged from the expectation. The detail explains what happened. +- `skipped` (`→`) — the cluster did not have a fixture for this op (e.g. no rule to patch). Skipped checks do **not** affect the exit code. + +The summary line lists totals: `Summary: N passed, M failed, K skipped`. + +## Troubleshooting + +**Read-only writes return `ok` instead of `403` (e.g. `expected 403 but call succeeded`).** +The read-only role is over-privileged. Compare `roles.ts` to the read-only role in [`docs/permissions.md`](../../docs/permissions.md) — usually a stale `"write"` snuck into `indices.privileges`, or an `*.all` Kibana feature privilege replaced an `*.read` one. + +**Full-featured writes return `403`.** +A privilege documented in the full role is missing from `roles.ts`. Diff against the spec. + +**Layer A reports `missing privileges: ...`.** +The role descriptor sent in the `PUT /_security/role` body doesn't include the listed privileges, or Elasticsearch rejected one of them (typo / removed feature). Check that the Kibana feature names match your stack version. The defaults target 9.4+ — see the version-specific tables in `docs/permissions.md`. + +**`Fatal error: ELASTICSEARCH_URL, KIBANA_URL, and ELASTIC_PASSWORD must be set...`** +`.env` isn't loading or is missing one of `ELASTICSEARCH_URL`, `KIBANA_URL`, `ELASTIC_PASSWORD`. `ELASTIC_USERNAME` is optional (defaults to `elastic`). The script reads them via `dotenv/config`. + +**`Seeding completed but no security alerts were created.`** +`generateSampleData` ran but didn't end up writing alerts. Usually means the admin key lacks `write` on `.alerts-security.alerts-default`. Use a key with at least the privileges in the full role. + +**Leftover `mcp-app-test-*` roles or API keys.** +A previous run was killed before cleanup. Re-run with `--cleanup-stale` to wipe them, or delete manually via Kibana > Stack Management. + +## Architecture + +``` +scripts/test-permissions/ +├── roles.ts # Role descriptors + operation check matrix +├── elastic-admin.ts # PUT/DELETE role, POST/DELETE api_key, _has_privileges probe +├── runner.ts # Orchestrator: provision → seed → test → report → cleanup +└── README.md # This file +``` + +The runner reuses business logic from `src/elastic/*` and swaps the API key on the singleton config (`setConfig`) between admin and each scoped role. This forces sequential per-role execution; that's fine at this scale. + +## Updating the Test Matrix + +When you add or remove an operation from `src/elastic/*`, also update `operationChecks` in [`roles.ts`](./roles.ts): + +- Add an entry naming the function and its group. +- Set `expect.full` to `"ok"` and `expect.readonly` to either `"ok"` (read) or `"403"` (write). +- If the call needs a fixture that may not exist, add `skipUnless` and gate it on the fixture. +- If the function silences errors internally (e.g. wraps each ES call in `try { ... } catch {}`), it's not testable here — either refactor the function to surface 403s or pick a different operation that hits the same privilege. diff --git a/scripts/test-permissions/elastic-admin.ts b/scripts/test-permissions/elastic-admin.ts new file mode 100644 index 0000000..4a33e91 --- /dev/null +++ b/scripts/test-permissions/elastic-admin.ts @@ -0,0 +1,347 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EsClient } from "../../src/elastic/es-client/index.js"; +import type { RoleDescriptor } from "./roles.js"; + +export interface CreatedApiKey { + id: string; + name: string; + /** Base64 string used as the `ApiKey` header value. */ + encoded: string; +} + +export interface BasicAuth { + elasticsearchUrl: string; + username: string; + password: string; +} + +/** + * Thin helper that issues a request through the supplied {@link EsClient}. + * + * Mirrors the legacy `esRequest(path, { method, body, params })` signature + * so the rest of this file reads identically; under the hood it dispatches + * to the per-call axios `request` API. + * + * Errors are already rewritten by the response interceptor on the client + * — they surface as `Error: Elasticsearch [] : `, + * which the runner's permission-classifier already handles. + */ +async function esRequest( + esClient: EsClient, + path: string, + options: { + method?: "GET" | "POST" | "PUT" | "DELETE"; + body?: unknown; + params?: Record; + } = {} +): Promise { + const response = await esClient.request({ + url: path, + method: options.method ?? "GET", + data: options.body, + params: options.params, + }); + return response.data; +} + +/** + * POST /_security/api_key authenticated with Basic auth — bypasses the + * "derived API key" restriction that prevents API-key-authenticated + * callers from creating new keys with explicit role_descriptors. + * + * Used both for the bootstrap admin key (with `role_descriptors: {}` = + * inherit owner privileges) and for the per-role scoped keys (with + * explicit `role_descriptors`). + */ +export async function basicAuthCreateApiKey( + auth: BasicAuth, + body: Record +): Promise<{ id: string; name: string; encoded: string }> { + const url = auth.elasticsearchUrl.replace(/\/$/, "") + "/_security/api_key"; + const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString("base64"); + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Elasticsearch ${res.status}: ${text}`); + } + return (await res.json()) as { id: string; name: string; encoded: string }; +} + +/** + * Bootstraps an admin API key by authenticating as the admin user + * (basic auth) and creating a key that inherits the user's privileges. + * Used as the `ELASTICSEARCH_API_KEY` for everything that goes through + * esRequest/kibanaRequest during the runner. + */ +export async function bootstrapAdminApiKey( + auth: BasicAuth, + name: string +): Promise { + return basicAuthCreateApiKey(auth, { name, role_descriptors: {} }); +} + +/** + * POST /_security/api_key/grant — admin (basic auth) grants an API key + * for another user using their password. The resulting key inherits the + * target user's effective privileges. + * + * Why this and not basicAuthCreateApiKey-as-user: many built-in roles + * (notably `viewer`) don't include `manage_own_api_key`, so the user + * can't mint their own key. Grant works because authentication and + * authorization are split — the admin caller has `grant_api_key`, and + * the resulting key carries only the target user's privileges. + */ +export async function grantApiKeyForUser( + admin: BasicAuth, + targetUsername: string, + targetPassword: string, + keyName: string +): Promise { + const url = admin.elasticsearchUrl.replace(/\/$/, "") + "/_security/api_key/grant"; + const credentials = Buffer.from(`${admin.username}:${admin.password}`).toString("base64"); + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "password", + username: targetUsername, + password: targetPassword, + api_key: { name: keyName }, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Elasticsearch ${res.status}: ${text}`); + } + return (await res.json()) as CreatedApiKey; +} + +export interface HasPrivilegesProbe { + cluster?: string[]; + index?: Array<{ names: string[]; privileges: string[] }>; + application?: Array<{ + application: string; + privileges: string[]; + resources: string[]; + }>; +} + +export interface HasPrivilegesResult { + has_all_requested: boolean; + cluster: Record; + index: Record>; + application: Record>>; +} + +export async function createRole( + esClient: EsClient, + name: string, + descriptor: RoleDescriptor +): Promise<{ created: boolean }> { + const result = await esRequest<{ role: { created: boolean } }>( + esClient, + `/_security/role/${encodeURIComponent(name)}`, + { + method: "PUT", + body: descriptor, + } + ); + return { created: result.role?.created ?? true }; +} + +/** + * Returns true if a role with this name exists in the cluster. + * Used to short-circuit built-in provisioning when a reserved role + * isn't loaded (Security Solution reserved roles are gated on Kibana + * feature flags / licensing — they're absent on plain stacks). + */ +export async function roleExists( + esClient: EsClient, + name: string +): Promise { + try { + await esRequest>( + esClient, + `/_security/role/${encodeURIComponent(name)}` + ); + return true; + } catch (err) { + if (err instanceof Error && /\b404\b/.test(err.message)) { + return false; + } + throw err; + } +} + +export async function deleteRole( + esClient: EsClient, + name: string +): Promise<{ found: boolean }> { + try { + return await esRequest<{ found: boolean }>( + esClient, + `/_security/role/${encodeURIComponent(name)}`, + { method: "DELETE" } + ); + } catch (err) { + if (err instanceof Error && /\b404\b/.test(err.message)) { + return { found: false }; + } + throw err; + } +} + +export async function createApiKey( + auth: BasicAuth, + name: string, + roleName: string, + descriptor: RoleDescriptor +): Promise { + // Must use Basic auth: derived API keys (created when authenticated + // with another API key) are not allowed to specify explicit + // role_descriptors per ES security policy. + return basicAuthCreateApiKey(auth, { + name, + role_descriptors: { [roleName]: descriptor }, + }); +} + +export async function deleteApiKey( + esClient: EsClient, + id: string +): Promise<{ invalidated_api_keys: string[] }> { + return esRequest<{ invalidated_api_keys: string[] }>( + esClient, + "/_security/api_key", + { + method: "DELETE", + body: { ids: [id] }, + } + ); +} + +export async function hasPrivileges( + esClient: EsClient, + probe: HasPrivilegesProbe +): Promise { + return esRequest( + esClient, + "/_security/user/_has_privileges", + { + method: "POST", + body: probe, + } + ); +} + +interface ApiKeyListEntry { + id: string; + name: string; + invalidated: boolean; +} + +/** + * List API keys whose name matches the given prefix. Used by --cleanup-stale. + * Only returns active (non-invalidated) keys. + */ +export async function listApiKeysByPrefix( + esClient: EsClient, + prefix: string +): Promise<{ id: string; name: string }[]> { + const result = await esRequest<{ + api_keys: ApiKeyListEntry[]; + }>(esClient, "/_security/api_key", { + method: "GET", + params: { name: `${prefix}*` }, + }); + return (result.api_keys || []) + .filter((k: ApiKeyListEntry) => !k.invalidated) + .map((k: ApiKeyListEntry) => ({ id: k.id, name: k.name })); +} + +/** + * List role names matching a prefix. Used by --cleanup-stale. + */ +export async function listRolesByPrefix( + esClient: EsClient, + prefix: string +): Promise { + const result = await esRequest>( + esClient, + "/_security/role" + ); + return Object.keys(result).filter((name) => name.startsWith(prefix)); +} + +/** + * PUT /_security/user/ — create or overwrite a native-realm user. + * Used to provision a test user that has a built-in role assigned, so + * an API key minted as that user (with empty role_descriptors) inherits + * exactly the built-in role's privileges. + */ +export async function createUser( + esClient: EsClient, + username: string, + password: string, + roles: string[] +): Promise<{ created: boolean }> { + const result = await esRequest<{ created: boolean }>( + esClient, + `/_security/user/${encodeURIComponent(username)}`, + { + method: "PUT", + body: { password, roles }, + } + ); + return { created: result.created ?? true }; +} + +export async function deleteUser( + esClient: EsClient, + username: string +): Promise<{ found: boolean }> { + try { + return await esRequest<{ found: boolean }>( + esClient, + `/_security/user/${encodeURIComponent(username)}`, + { method: "DELETE" } + ); + } catch (err) { + if (err instanceof Error && /\b404\b/.test(err.message)) { + return { found: false }; + } + throw err; + } +} + +/** + * List native-realm usernames matching a prefix. Used by --cleanup-stale. + */ +export async function listUsersByPrefix( + esClient: EsClient, + prefix: string +): Promise { + const result = await esRequest>( + esClient, + "/_security/user" + ); + return Object.keys(result).filter((name) => name.startsWith(prefix)); +} diff --git a/scripts/test-permissions/roles.ts b/scripts/test-permissions/roles.ts new file mode 100644 index 0000000..cc5432d --- /dev/null +++ b/scripts/test-permissions/roles.ts @@ -0,0 +1,715 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AttackDiscovery } from "../../src/elastic/client/index.js"; +import { hasPrivileges } from "./elastic-admin.js"; +import type { Services } from "./services.js"; + +// The app currently targets the "default" Kibana space, so role definitions +// hard-code the resolved index/resource names rather than the +// placeholders used in docs/permissions.md. +const SPACE = "default"; +const KIBANA_RESOURCE = `space:${SPACE}`; + +export interface RoleDescriptor { + cluster: string[]; + indices: Array<{ + names: string[]; + privileges: string[]; + }>; + applications: Array<{ + application: string; + privileges: string[]; + resources: string[]; + }>; +} + +const DATA_INDICES = [ + `.alerts-security.alerts-${SPACE}`, + `.alerts-security.attack.discovery.alerts-${SPACE}`, + `.adhoc.alerts-security.attack.discovery.alerts-${SPACE}`, + // Backing indices for the alert and attack-discovery data streams. + // `_update_by_query` and `_delete_by_query` dispatch writes directly + // to backing indices (used by acknowledgeAlerts, acknowledgeDiscoveries, + // and cleanupSampleData), so the role needs explicit privileges here + // — granting them on the data stream alone is not sufficient. + `.internal.alerts-security.alerts-${SPACE}-*`, + `.internal.alerts-security.attack.discovery.alerts-${SPACE}-*`, + `.internal.adhoc.alerts-security.attack.discovery.alerts-${SPACE}-*`, + "logs-*", + "risk-score.risk-score-latest-*", +]; + +export const fullRole: RoleDescriptor = { + cluster: ["monitor"], + indices: [ + { + names: DATA_INDICES, + // Index-level `monitor` is required by `_cat/indices/` + // (used by Threat Hunt's listIndices) — that endpoint dispatches + // both `indices:monitor/stats` and `indices:monitor/settings/get`, + // which only the index-level `monitor` privilege covers in full. + // The cluster-level `monitor` above is a separate thing and is + // not sufficient on its own. + // + // `view_index_metadata` is required by `_mapping` (used by Threat + // Hunt's getMapping). It is its OWN privilege — `monitor` does + // NOT include it. Without it, `_mapping` returns 403 even when + // `read` is granted. + privileges: ["read", "write", "monitor", "view_index_metadata"], + }, + ], + applications: [ + { + application: "kibana-.kibana", + privileges: [ + "feature_siemV5.all", + "feature_securitySolutionCasesV3.all", + "feature_securitySolutionTimeline.all", + "feature_securitySolutionNotes.all", + "feature_securitySolutionRulesV4.all", + "feature_securitySolutionAlertsV1.all", + "feature_securitySolutionAssistant.all", + "feature_securitySolutionAttackDiscovery.all", + "feature_actions.all", + ], + resources: [KIBANA_RESOURCE], + }, + ], +}; + +export const readonlyRole: RoleDescriptor = { + cluster: ["monitor"], + indices: [ + { + names: DATA_INDICES, + privileges: ["read", "monitor", "view_index_metadata"], + }, + ], + applications: [ + { + application: "kibana-.kibana", + privileges: [ + "feature_siemV5.read", + "feature_securitySolutionCasesV3.read", + "feature_securitySolutionTimeline.read", + "feature_securitySolutionNotes.read", + "feature_securitySolutionRulesV4.read", + "feature_securitySolutionAlertsV1.read", + "feature_actions.read", + ], + resources: [KIBANA_RESOURCE], + }, + ], +}; + +export type AssertedRoleName = + | "full" + | "readonly" + | "quickstart_full" + | "quickstart_readonly"; +export type RoleName = AssertedRoleName; + +/** + * Custom-role descriptors for the asserted "Advanced" path. These + * roles are self-contained — they include cluster, index, and Kibana + * feature privileges in a single role. + */ +export const ROLE_DESCRIPTORS: Record<"full" | "readonly", RoleDescriptor> = { + full: fullRole, + readonly: readonlyRole, +}; + +/** + * The assertion profile for each asserted role. Quickstart variants + * (`quickstart_full` / `quickstart_readonly`) are expected to exhibit + * the same per-op outcomes as their custom-role counterparts (`full` / + * `readonly`), so we look up the same `expect` map under one key. + */ +export type AssertedExpectationProfile = "full" | "readonly"; +export const ASSERTED_EXPECTATION_PROFILE: Record< + AssertedRoleName, + AssertedExpectationProfile +> = { + full: "full", + readonly: "readonly", + quickstart_full: "full", + quickstart_readonly: "readonly", +}; + +/** + * Quickstart path: built-in Kibana role paired with a small companion + * role that grants only the index privileges the built-in lacks. This + * matches what `docs/permissions.md` recommends to end users. + * + * The companion role intentionally has no cluster, Kibana-feature, or + * application privileges — those come from the built-in. If a future + * Kibana version stops shipping cluster `monitor` in `editor`/`viewer`, + * the matrix run will surface the regression as a `listIndices` (or + * similar) failure. + */ +export const QUICKSTART_BUILTINS: Record< + "quickstart_full" | "quickstart_readonly", + "editor" | "viewer" +> = { + quickstart_full: "editor", + quickstart_readonly: "viewer", +}; + +export const QUICKSTART_COMPANION_DESCRIPTORS: Record< + "quickstart_full" | "quickstart_readonly", + RoleDescriptor +> = { + // Cluster-level `monitor` is required by `_cat/indices/` + // (Threat Hunt's listIndices). Neither `editor` nor `viewer` grants + // it on a stateful 9.5 cluster, so it has to come from the companion. + quickstart_full: { + cluster: ["monitor"], + indices: [ + { + names: DATA_INDICES, + privileges: ["read", "write", "monitor", "view_index_metadata"], + }, + ], + applications: [], + }, + quickstart_readonly: { + cluster: ["monitor"], + indices: [ + { + names: DATA_INDICES, + privileges: ["read", "monitor", "view_index_metadata"], + }, + ], + applications: [], + }, +}; + +/** Any role identity the runner may exercise. */ +export type AnyRoleName = AssertedRoleName; + +export type OperationGroup = + | "alerts" + | "cases" + | "rules" + | "attack-discovery" + | "threat-hunt" + | "sample-data"; + +export type Expectation = "ok" | "403"; + +/** Outcome bucket for a single operation run. */ +export type RunOutcome = "pass" | "403" | "404" | "other" | "skipped"; + +export interface SeedFixtures { + alertId: string; + alertIndex: string; + alertRuleId: string; + alertRuleName: string; + caseId: string; + ruleId?: string; + discoveryId?: string; + /** + * `list_id` of a transient detection-type exception list seeded by + * preflight. Used by `listExceptions` (read) and `addException` + * (write). When undefined (creation failed), both checks skip. + */ + exceptionListId?: string; + /** Per-run unique suffix used for case titles, rule names, etc. */ + suffix: string; +} + +/** Arguments passed to each operation `run` implementation. */ +export interface OperationRunDeps { + readonly services: Services; + readonly fixtures: SeedFixtures; + readonly role: AnyRoleName; +} + +export interface OperationCheck { + /** Human-readable name for the report. */ + name: string; + /** Which `src/elastic/*` module/function group this belongs to. */ + group: OperationGroup; + /** + * Function that performs the operation. Receives the seed fixtures, + * the active services bundle (rebuilt on each role swap), and the + * role being tested. Throws on API failure. + */ + run: (deps: OperationRunDeps) => Promise; + /** + * Per-role expectation, keyed by assertion profile (`full` or + * `readonly`). "ok" = call must succeed. "403" = call must throw + * with a message containing "403" (Kibana sometimes returns 401 for + * forbidden actions; that's also accepted). Only consulted for + * asserted roles (`full`, `readonly`, `quickstart_full`, + * `quickstart_readonly`) via `ASSERTED_EXPECTATION_PROFILE`; + * built-in runs record observed outcomes only. + */ + expect: Record; + /** + * If set, returning a falsy value from the resolver marks the check as + * "skipped" instead of running it. Used for ops that need an optional + * fixture (e.g. attack-discovery write tests need a discoveryId). + */ + skipUnless?: (fixtures: SeedFixtures, role: AnyRoleName) => unknown; +} + +export interface CheckResult { + check: OperationCheck; + role: RoleName; + outcome: "pass" | "fail" | "skipped"; + detail: string; +} + +function synthDiscovery(f: SeedFixtures): AttackDiscovery { + return { + id: f.discoveryId!, + timestamp: new Date().toISOString(), + executionUuid: "", + title: "", + summaryMarkdown: "", + detailsMarkdown: "", + mitreTactics: [], + alertIds: [f.alertId], + alertsContextCount: 1, + riskScore: 50, + }; +} + +function ruleBody(name: string): Record { + return { + type: "query", + name, + description: "Permissions test rule (safe to delete)", + severity: "low", + risk_score: 1, + query: "*:*", + language: "kuery", + index: ["logs-*"], + enabled: false, + from: "now-1d", + to: "now", + interval: "1h", + tags: ["mcp-app-test"], + threat: [], + }; +} + +export const operationChecks: OperationCheck[] = [ + // ─── alerts ──────────────────────────────────────────────────────────── + { + name: "fetchAlerts", + group: "alerts", + run: async ({ services }) => + services.alertsService.getAlerts({ days: 30, limit: 1 }), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "acknowledgeAlert", + group: "alerts", + run: async ({ services, fixtures }) => + services.alertsService.acknowledgeAlert(fixtures.alertId), + expect: { full: "ok", readonly: "403" }, + }, + { + // Drives parallel reads against logs-endpoint.events.process-*, + // logs-endpoint.events.network-*, and .alerts-security.alerts-*. + // Picks an alert with host.name set, because getAlertContext + // short-circuits the endpoint-event queries otherwise. + // + // NOTE: this check on its own does not defend the endpoint-event + // index privileges — _search against a wildcard pattern (logs- + // endpoint.events.process-*) is lenient: when a role has no + // concrete index matching the pattern, ES silently returns 0 hits + // instead of 403. The companion probe `endpointEventsReadable` + // below uses _has_privileges to verify the role actually grants + // read on those indices. + name: "getAlertContext", + group: "alerts", + run: async ({ services, fixtures }) => { + const alerts = await services.alertsService.getAlerts({ + days: 365, + limit: 50, + status: "open", + }); + const alert = + alerts.alerts.find((a) => Boolean(a._source.host?.name)) ?? + alerts.alerts[0]; + return services.alertsService.getAlertContext(fixtures.alertId, alert); + }, + expect: { full: "ok", readonly: "ok" }, + }, + { + // Companion probe for getAlertContext. Verifies the role grants + // `read` on the endpoint-event indices that getAlertContext queries + // in parallel. Needed because the production code uses wildcard + // _search patterns (logs-endpoint.events.process-*, logs-endpoint. + // events.network-*) which ES handles leniently — a role with zero + // matching concrete indices gets an empty result, not a 403, so + // the getAlertContext check above can't catch a privilege gap on + // those indices on its own. + // + // Tamper test: narrow DATA_INDICES to drop logs-* (or replace with + // a non-matching glob like logs-foo-*) and this probe must fail + // for both full and readonly. + // + // Reusable pattern: any other op that queries data via wildcard + // _search (M3 getMapping, M4 getEntityDetail) can use the same + // shape to defend its index privileges. + name: "endpointEventsReadable", + group: "alerts", + run: async ({ services }) => { + const result = await hasPrivileges(services.esClient, { + index: [ + { + names: [ + "logs-endpoint.events.process-*", + "logs-endpoint.events.network-*", + ], + privileges: ["read"], + }, + ], + }); + if (!result.has_all_requested) { + throw new Error( + `role lacks read on endpoint event indices required by getAlertContext: ${JSON.stringify(result.index)}` + ); + } + return result; + }, + expect: { full: "ok", readonly: "ok" }, + }, + + // ─── cases ───────────────────────────────────────────────────────────── + { + name: "listCases", + group: "cases", + run: async ({ services }) => + services.casesService.listCases({ perPage: 1 }), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "getCase", + group: "cases", + run: async ({ services, fixtures }) => + services.casesService.getCase(fixtures.caseId), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "createCase", + group: "cases", + run: async ({ services, fixtures, role }) => + services.casesService.createCase({ + title: `mcp-app-test ${role} ${fixtures.suffix} ${Date.now()}`, + description: "Permissions test case (safe to delete)", + tags: ["mcp-app-test"], + }), + expect: { full: "ok", readonly: "403" }, + }, + { + // Toggles severity between "low" and "medium" so the PATCH always + // makes a real change (Kibana rejects no-op updates with 406). The + // toggle leaves the field in a deterministic state regardless of + // starting value, so re-runs don't drift. + name: "updateCase", + group: "cases", + run: async ({ services, fixtures }) => { + const current = await services.casesService.getCase(fixtures.caseId); + const next = current.severity === "low" ? "medium" : "low"; + return services.casesService.updateCase( + fixtures.caseId, + current.version, + { severity: next } + ); + }, + expect: { full: "ok", readonly: "403" }, + }, + { + name: "addComment", + group: "cases", + run: async ({ services, fixtures }) => + services.casesService.addComment(fixtures.caseId, "mcp-app-test comment"), + expect: { full: "ok", readonly: "403" }, + }, + { + name: "attachAlert", + group: "cases", + run: async ({ services, fixtures }) => + services.casesService.attachAlert( + fixtures.caseId, + fixtures.alertId, + fixtures.alertIndex, + fixtures.alertRuleId, + fixtures.alertRuleName + ), + expect: { full: "ok", readonly: "403" }, + }, + + // ─── rules ───────────────────────────────────────────────────────────── + { + name: "findRules", + group: "rules", + run: async ({ services }) => + services.rulesService.findRules({ perPage: 1 }), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "noisyRules", + group: "rules", + run: async ({ services }) => + services.rulesService.noisyRules({ days: 30, limit: 5 }), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "createRule", + group: "rules", + run: async ({ services, fixtures, role }) => + services.rulesService.createRule( + ruleBody(`mcp-app-test ${role} ${fixtures.suffix} ${Date.now()}`) + ), + expect: { full: "ok", readonly: "403" }, + }, + { + // Non-destructive: re-patches the rule with its current `enabled` + // value. The PATCH call still exercises the write privilege (succeeds + // for `full`, 403s for `readonly`) without mutating state. getRule + // succeeds for both roles, so the 403 in the readonly path comes + // strictly from patchRule. + name: "patchRule", + group: "rules", + skipUnless: (f) => f.ruleId, + run: async ({ services, fixtures }) => { + const rule = await services.rulesService.getRule(fixtures.ruleId!); + return services.rulesService.patchRule(fixtures.ruleId!, { + enabled: rule.enabled, + }); + }, + expect: { full: "ok", readonly: "403" }, + }, + { + // Uses "duplicate" rather than "enable"/"disable"/"delete" — the + // first three mutate the source rule (or destroy it), duplicate + // leaves the source untouched and surfaces the write privilege via + // the new-rule creation. The created duplicate is immediately + // deleted to keep runs idempotent: without this, every successful + // run leaves a "[Duplicate]" rule behind that countLeftoverTagged- + // Resources won't catch (the duplicate inherits the source's tags, + // and the source is rarely tagged "mcp-app-test"). + name: "bulkAction", + group: "rules", + skipUnless: (f) => f.ruleId, + run: async ({ services, fixtures }) => { + const result = (await services.rulesService.bulkAction("duplicate", [ + fixtures.ruleId!, + ])) as { + attributes?: { results?: { created?: Array<{ id: string }> } }; + }; + const created = result.attributes?.results?.created ?? []; + for (const rule of created) { + try { + await services.rulesService.deleteRule(rule.id); + } catch { + /* best-effort cleanup; surface in leftover count if it sticks */ + } + } + return result; + }, + expect: { full: "ok", readonly: "403" }, + }, + { + name: "listExceptions", + group: "rules", + skipUnless: (f) => f.exceptionListId, + run: async ({ services, fixtures }) => + services.rulesService.listExceptions(fixtures.exceptionListId!), + expect: { full: "ok", readonly: "ok" }, + }, + { + // Creates an exception item against the preflight-seeded list. The + // created item is immediately deleted to keep runs idempotent — + // exceptions don't have a `tags` field, so countLeftoverTagged- + // Resources() can't catch them. The seeded list itself is torn + // down by cleanupRoleArtifacts at end of run. + name: "addException", + group: "rules", + skipUnless: (f) => f.ruleId && f.exceptionListId, + run: async ({ services, fixtures }) => { + const result = (await services.rulesService.addException( + fixtures.ruleId!, + fixtures.exceptionListId!, + { + name: `mcp-app-test ${fixtures.suffix}`, + description: "Permissions test exception (safe to delete)", + entries: [ + { + field: "host.name", + operator: "included", + type: "match", + value: "test-host", + }, + ], + } + )) as Array<{ id: string }>; + // Inline cleanup of any created items. Best-effort: a 403 here + // shouldn't happen on `full` but if it does, the leftover persists + // until the seeded list is torn down at end of run. + for (const item of Array.isArray(result) ? result : []) { + try { + await services.kibanaClient.request({ + url: `/api/exception_lists/items`, + method: "DELETE", + params: { id: item.id, namespace_type: "single" }, + headers: { "elastic-api-version": "2023-10-31" }, + }); + } catch { + /* best-effort cleanup */ + } + } + return result; + }, + expect: { full: "ok", readonly: "403" }, + }, + + // ─── attack-discovery ────────────────────────────────────────────────── + { + name: "fetchDiscoveries", + group: "attack-discovery", + run: async ({ services }) => + services.attackDiscoveryService.getDiscoveries({ days: 30, limit: 5 }), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "listAIConnectors", + group: "attack-discovery", + run: async ({ services }) => + services.attackDiscoveryService.listAIConnectors(), + expect: { full: "ok", readonly: "ok" }, + }, + { + // Synthesizes an AttackDiscovery from the seeded discoveryId and a + // real alertId so the inner ES|QL queries actually run (the helper + // returns early when alertIds is empty). This drives reads against + // .alerts-security.alerts-* and risk-score.risk-score-latest-*. + name: "assessConfidence", + group: "attack-discovery", + skipUnless: (f) => f.discoveryId, + run: async ({ services, fixtures }) => + services.attackDiscoveryService.assessConfidence([ + synthDiscovery(fixtures), + ]), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "getDiscoveryDetail", + group: "attack-discovery", + skipUnless: (f) => f.discoveryId, + run: async ({ services, fixtures }) => + services.attackDiscoveryService.getDiscoveryDetail( + synthDiscovery(fixtures) + ), + expect: { full: "ok", readonly: "ok" }, + }, + { + // Bypasses the production helper (acknowledgeDiscoveries) which + // silently catches per-index errors — a 403 there returns + // `updated: 0` instead of throwing, so it can't drive a privilege + // assertion. Calling _update_by_query directly lets the 403 + // propagate, which is what we want here. + name: "acknowledgeDiscoveries", + group: "attack-discovery", + skipUnless: (f) => f.discoveryId, + run: async ({ services, fixtures }) => + services.esClient.request({ + url: `/.alerts-security.attack.discovery.alerts-${SPACE}/_update_by_query`, + method: "POST", + data: { + query: { ids: { values: [fixtures.discoveryId!] } }, + script: { + source: + 'ctx._source["kibana.alert.workflow_status"] = "acknowledged"', + lang: "painless", + }, + }, + }), + expect: { full: "ok", readonly: "403" }, + }, + + // ─── threat-hunt ─────────────────────────────────────────────────────── + { + name: "executeEsql", + group: "threat-hunt", + run: async ({ services }) => + services.esqlService.executeEsql("FROM logs-* | LIMIT 1"), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "listIndices", + group: "threat-hunt", + run: async ({ services }) => services.indicesService.listIndices("logs-*"), + expect: { full: "ok", readonly: "ok" }, + }, + { + // _mapping requires `view_index_metadata`, which is its own privilege + // — NOT included in `monitor` (despite a common misconception) and + // NOT in `read`. Dropping `"view_index_metadata"` from the privileges + // list flips this to 403 cleanly. Uses f.alertIndex (the seeded + // alert's concrete backing index) so the test doesn't depend on + // sample data being present on the cluster. + name: "getMapping", + group: "threat-hunt", + run: async ({ services, fixtures }) => + services.indicesService.getMapping(fixtures.alertIndex), + expect: { full: "ok", readonly: "ok" }, + }, + + // ─── sample-data ─────────────────────────────────────────────────────── + { + name: "checkExistingData", + group: "sample-data", + run: async ({ services }) => services.sampleDataService.checkExistingData(), + expect: { full: "ok", readonly: "ok" }, + }, + { + name: "generateSampleData", + group: "sample-data", + run: async ({ services }) => + services.sampleDataService.generateSampleData({ count: 1 }), + expect: { full: "ok", readonly: "403" }, + }, + { + // Bypasses cleanupSampleData() (silently catches per-index errors) + // by hitting one logs data stream directly. _delete_by_query on a + // data stream may require privileges on the underlying .ds-* backing + // indices, which the current `logs-*` glob in DATA_INDICES does NOT + // match. If full 403s here, DATA_INDICES needs `.ds-logs-*` too — + // the same backing-index pattern PR #18 fixed for alerts. + name: "cleanupSampleDataLogs", + group: "sample-data", + run: async ({ services }) => + services.esClient.request({ + url: `/logs-endpoint.events.process-${SPACE}/_delete_by_query`, + method: "POST", + data: { query: { term: { tags: "mcp-app-test" } } }, + }), + expect: { full: "ok", readonly: "403" }, + }, + { + // Companion to acknowledgeAlert (which uses _update_by_query on the + // alerts data stream). _delete_by_query exercises the delete arm of + // the same backing-index privilege PR #18 added. + name: "cleanupSampleDataAlerts", + group: "sample-data", + run: async ({ services }) => + services.esClient.request({ + url: `/.alerts-security.alerts-${SPACE}/_delete_by_query`, + method: "POST", + data: { query: { term: { tags: "mcp-app-test" } } }, + }), + expect: { full: "ok", readonly: "403" }, + }, +]; diff --git a/scripts/test-permissions/runner.ts b/scripts/test-permissions/runner.ts new file mode 100644 index 0000000..041edc5 --- /dev/null +++ b/scripts/test-permissions/runner.ts @@ -0,0 +1,1105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import "dotenv/config"; +import crypto from "node:crypto"; + +import type { ClusterCredentials } from "../../src/elastic/credential-client/index.js"; +import { + ASSERTED_EXPECTATION_PROFILE, + QUICKSTART_BUILTINS, + QUICKSTART_COMPANION_DESCRIPTORS, + ROLE_DESCRIPTORS, + operationChecks, + type AnyRoleName, + type AssertedRoleName, + type CheckResult, + type OperationCheck, + type OperationGroup, + type RoleName, + type RunOutcome, + type SeedFixtures, +} from "./roles.js"; +import { + bootstrapAdminApiKey, + grantApiKeyForUser, + createApiKey, + createRole, + createUser, + deleteApiKey, + deleteRole, + deleteUser, + hasPrivileges, + roleExists, + listApiKeysByPrefix, + listRolesByPrefix, + listUsersByPrefix, + type CreatedApiKey, +} from "./elastic-admin.js"; +import { buildServices, type Services } from "./services.js"; + +const TEST_TAG = "mcp-app-test"; +const TEST_RESOURCE_PREFIX = "mcp-app-test-"; +const ADMIN_CLUSTER_NAME = "test-permissions-admin"; +const SCOPED_CLUSTER_NAME = "test-permissions-scoped"; + +interface CliOptions { + roles: RoleName[]; + cleanupStale: boolean; + cleanup: boolean; + verbose: boolean; +} + +interface AdminConfig { + elasticsearchUrl: string; + /** + * Bootstrapped admin API key (encoded). Created on startup via Basic + * auth so we can authenticate ES + Kibana requests through the standard + * `createEsClient` / `createKibanaClient` factories (both expect an + * `ApiKey` header). + */ + elasticsearchApiKey: string; + /** Kept around for createApiKey calls (which require Basic auth). */ + basicAuth: { username: string; password: string }; + kibanaUrl: string; +} + +interface RoleArtifacts { + role: "full" | "readonly"; + roleName: string; + apiKey: CreatedApiKey; +} + +type QuickstartRoleName = "quickstart_full" | "quickstart_readonly"; + +interface QuickstartArtifacts { + role: QuickstartRoleName; + companionRoleName: string; + username: string; + apiKey: CreatedApiKey; +} + +interface UnavailableQuickstart { + role: QuickstartRoleName; + reason: string; +} + +const SYM_OK = "✓"; +const SYM_FAIL = "✗"; +const SYM_SKIP = "→"; + +const GROUP_ORDER: OperationGroup[] = [ + "alerts", + "cases", + "rules", + "attack-discovery", + "threat-hunt", + "sample-data", +]; + +const ALL_ASSERTED_ROLES: AssertedRoleName[] = [ + "full", + "readonly", + "quickstart_full", + "quickstart_readonly", +]; + +function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { + roles: ["full", "readonly"], + cleanupStale: false, + cleanup: true, + verbose: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--role") { + const value = argv[++i]; + if (value === "both") opts.roles = ["full", "readonly"]; + else if (value === "all") opts.roles = [...ALL_ASSERTED_ROLES]; + else if (value === "quickstart") + opts.roles = ["quickstart_full", "quickstart_readonly"]; + else if (value === "none") opts.roles = []; + else if ( + value === "full" || + value === "readonly" || + value === "quickstart_full" || + value === "quickstart_readonly" + ) + opts.roles = [value]; + else + die( + `Unknown --role value: ${value} (expected full|readonly|quickstart_full|quickstart_readonly|both|quickstart|all|none)` + ); + } else if (arg === "--cleanup-stale") { + opts.cleanupStale = true; + } else if (arg === "--no-cleanup") { + opts.cleanup = false; + } else if (arg === "--verbose" || arg === "-v") { + opts.verbose = true; + } else if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } else { + die(`Unknown argument: ${arg}`); + } + } + return opts; +} + +function printHelp() { + console.log(`Usage: npm run test:permissions -- [options] + +Options: + --role + Role(s) to test (default: both). + Names: full, readonly, quickstart_full, quickstart_readonly. + "both" = full,readonly. "quickstart" = quickstart_full,quickstart_readonly. + "all" = all four. "none" = no roles (cleanup-stale only). + --cleanup-stale Delete leftover ${TEST_RESOURCE_PREFIX}* roles/users/keys before running + --no-cleanup Skip cleanup at end (prints API keys for reuse) + --verbose Verbose output + -h, --help Show this help +`); +} + +function die(message: string): never { + console.error(`Error: ${message}`); + process.exit(1); +} + +interface AdminBasics { + elasticsearchUrl: string; + kibanaUrl: string; + basicAuth: { username: string; password: string }; +} + +function loadAdminBasics(): AdminBasics { + const elasticsearchUrl = process.env.ELASTICSEARCH_URL; + const kibanaUrl = process.env.KIBANA_URL; + // Default to "elastic" — by far the most common admin user for local + // dev clusters. Override via env if needed. + const username = process.env.ELASTIC_USERNAME || "elastic"; + const password = process.env.ELASTIC_PASSWORD; + if (!elasticsearchUrl || !kibanaUrl || !password) { + die( + "ELASTICSEARCH_URL, KIBANA_URL, and ELASTIC_PASSWORD must be set in .env or the environment. " + + "ELASTIC_USERNAME defaults to 'elastic'." + ); + } + return { + elasticsearchUrl, + kibanaUrl, + basicAuth: { username, password }, + }; +} + +function adminServices(admin: AdminConfig): Services { + const creds: ClusterCredentials = { + name: ADMIN_CLUSTER_NAME, + elasticsearchUrl: admin.elasticsearchUrl, + kibanaUrl: admin.kibanaUrl, + elasticsearchApiKey: admin.elasticsearchApiKey, + }; + return buildServices(creds); +} + +function scopedServices(admin: AdminConfig, scopedKey: string): Services { + const creds: ClusterCredentials = { + name: SCOPED_CLUSTER_NAME, + elasticsearchUrl: admin.elasticsearchUrl, + kibanaUrl: admin.kibanaUrl, + elasticsearchApiKey: scopedKey, + }; + return buildServices(creds); +} + +function isPermissionDenied(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + // Direct ES/Kibana 403/401 throws. The client interceptor formats these + // as e.g. `Elasticsearch [] 403: ...` / `Kibana [] 403: ...`. + if (/Elasticsearch\s+(?:\[[^\]]+\]\s+)?403:|Kibana\s+(?:\[[^\]]+\]\s+)?(?:403|401):/i.test(msg)) + return true; + // Bulk-API path: HTTP 200 with per-doc errors. The first error JSON + // contains `"status":403` or `security_exception`. + if (/security_exception/i.test(msg)) return true; + if (/"status"\s*:\s*403/.test(msg)) return true; + // Kibana bulk-endpoint path (e.g. /api/detection_engine/rules/_bulk_action): + // top-level HTTP is 500, but the per-rule denial is reported as + // `"status_code":403` inside `attributes.errors[]`. Without this any + // op that goes through one of those endpoints would be classified + // "other" instead of "403" by the runner. + if (/"status_code"\s*:\s*403/.test(msg)) return true; + return false; +} + +function isNotFound(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /(?:Elasticsearch|Kibana)\s+(?:\[[^\]]+\]\s+)?404:|not_found/i.test(msg); +} + +interface ObservedRun { + check: OperationCheck; + outcome: RunOutcome; + detail: string; +} + +/** + * Runs every operation check against the supplied services bundle and + * returns the *observed* outcome for each — without comparing to any + * expectation. Used both for built-in discovery (where there's no + * expectation) and as the raw layer underneath asserted runs. + */ +async function runOpsObserve( + services: Services, + role: AnyRoleName, + fixtures: SeedFixtures +): Promise { + const out: ObservedRun[] = []; + for (const check of operationChecks) { + if (check.skipUnless && !check.skipUnless(fixtures, role)) { + out.push({ + check, + outcome: "skipped", + detail: "no fixture available for this check", + }); + continue; + } + try { + const value = await check.run({ services, fixtures, role }); + out.push({ check, outcome: "pass", detail: summarize(value) }); + } catch (err) { + const msg = formatError(err); + if (isPermissionDenied(err)) { + out.push({ check, outcome: "403", detail: "denied (403/401)" }); + } else if (isNotFound(err)) { + out.push({ check, outcome: "404", detail: msg }); + } else { + out.push({ check, outcome: "other", detail: msg }); + } + } + } + return out; +} + +const DISCOVERY_INDEX = ".alerts-security.attack.discovery.alerts-default"; + +/** + * Returns a discovery `_id` to use as fixture for `acknowledgeDiscoveries`. + * Reuses an existing discovery if one is present; otherwise seeds a + * minimal one via `_bulk` (silent on failure — the check skips when no + * discoveryId is captured). + */ +async function ensureDiscoveryFixture( + services: Services, + opts: CliOptions +): Promise { + try { + const existing = await services.esClient.request<{ + hits: { hits: Array<{ _id: string }> }; + }>({ + url: `/${DISCOVERY_INDEX}/_search`, + method: "POST", + data: { size: 1, _source: false, query: { match_all: {} } }, + }); + const hit = existing.data.hits?.hits?.[0]; + if (hit) { + if (opts.verbose) console.log(`→ Reusing existing discovery ${hit._id}`); + return hit._id; + } + } catch { + /* index may not exist yet — fall through to seed */ + } + + if (opts.verbose) console.log("→ No discoveries found, seeding one…"); + const now = new Date().toISOString(); + const doc = { + "@timestamp": now, + "kibana.alert.uuid": crypto.randomUUID(), + "kibana.alert.workflow_status": "open", + "kibana.alert.rule.execution.uuid": crypto.randomUUID(), + "kibana.alert.attack_discovery.title": "mcp-app-test seed discovery", + "kibana.alert.attack_discovery.summary_markdown": "Permissions test seed (safe to delete)", + "kibana.alert.attack_discovery.details_markdown": "Permissions test seed (safe to delete)", + "kibana.alert.attack_discovery.alert_ids": [], + "kibana.alert.attack_discovery.alerts_context_count": 0, + "kibana.alert.risk_score": 0, + "kibana.space_ids": ["default"], + "kibana.alert.rule.tags": [TEST_TAG], + }; + const body = + JSON.stringify({ create: { _index: DISCOVERY_INDEX } }) + "\n" + + JSON.stringify(doc) + "\n"; + try { + const response = await services.esClient.request<{ + items: Array<{ create: { _id: string; status: number; error?: unknown } }>; + errors: boolean; + }>({ + url: "/_bulk", + method: "POST", + data: body, + params: { refresh: "true" }, + headers: { "Content-Type": "application/x-ndjson" }, + }); + const result = response.data; + if (result.errors) { + const err = result.items[0]?.create?.error; + console.warn( + ` warning: failed to seed discovery, acknowledgeDiscoveries will be skipped: ${JSON.stringify(err)}` + ); + return undefined; + } + return result.items[0].create._id; + } catch (err) { + console.warn( + ` warning: failed to seed discovery, acknowledgeDiscoveries will be skipped: ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } +} + +/** + * Creates a transient detection-type exception list under a unique + * `list_id` so the `listExceptions` / `addException` checks have a + * fixture they can read against and write to. Returns the `list_id` + * (the value `_find` filters on — not the Saved Object id). + * + * Why we don't use `endpoint_list`: it's only present after the Endpoint + * Security integration is enabled. On a vanilla cluster `_find?list_id= + * endpoint_list` returns 404, which the runner classifies as "no fixture" + * and the privilege check skips. Seeding our own list keeps the check + * deterministic across environments. Mirrors `ensureDiscoveryFixture`. + */ +async function ensureExceptionListFixture( + services: Services, + opts: CliOptions +): Promise { + const listId = `mcp-app-test-list-${crypto.randomBytes(4).toString("hex")}`; + if (opts.verbose) console.log(`→ Seeding exception list ${listId}…`); + try { + await services.kibanaClient.request({ + url: "/api/exception_lists", + method: "POST", + headers: { "elastic-api-version": "2023-10-31" }, + data: { + list_id: listId, + name: listId, + description: "Permissions test exception list (safe to delete)", + type: "detection", + namespace_type: "single", + }, + }); + return listId; + } catch (err) { + console.warn( + ` warning: failed to seed exception list, listExceptions/addException will be skipped: ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } +} + +async function deleteExceptionListFixture( + services: Services, + listId: string +): Promise { + try { + await services.kibanaClient.request({ + url: "/api/exception_lists", + method: "DELETE", + headers: { "elastic-api-version": "2023-10-31" }, + params: { list_id: listId, namespace_type: "single" }, + }); + } catch (err) { + console.warn( + ` warning: failed to delete exception list ${listId}: ${formatError(err)}` + ); + } +} + +async function preflight( + services: Services, + opts: CliOptions +): Promise { + if (opts.verbose) console.log("→ Verifying admin connectivity…"); + // A trivial call confirms ES + key are reachable. + let existing = await services.sampleDataService.checkExistingData(); + + if (existing.totalAlerts === 0) { + console.log("→ No security alerts found, seeding sample data (count=50)…"); + await services.sampleDataService.generateSampleData({ count: 50 }); + existing = await services.sampleDataService.checkExistingData(); + if (existing.totalAlerts === 0) { + die( + "Seeding completed but no security alerts were created. Aborting — Layer B checks need at least one alert." + ); + } + } else if (opts.verbose) { + console.log( + `→ Cluster has ${existing.totalAlerts} alert(s); skipping sample-data seed.` + ); + } + + // Capture one alert. + const alerts = await services.alertsService.getAlerts({ + days: 365, + limit: 1, + status: "open", + }); + let alertHit = alerts.alerts[0]; + if (!alertHit) { + // Fall back to acknowledged alerts if all alerts have been triaged. + const triaged = await services.alertsService.getAlerts({ + days: 365, + limit: 1, + status: "acknowledged", + }); + alertHit = triaged.alerts[0]; + } + if (!alertHit) { + die("No alerts available even after seeding. Cannot run alert-related Layer B checks."); + } + + const alertSrc = alertHit._source; + const alertRuleId = String( + (alertSrc["kibana.alert.rule.uuid"] as string) || + (alertSrc["kibana.alert.rule.rule_id"] as string) || + "" + ); + const alertRuleName = String((alertSrc["kibana.alert.rule.name"] as string) || ""); + + // Capture or create one case. Operations that need a version refetch it + // themselves at call time, so the version isn't stored in fixtures. + const caseList = await services.casesService.listCases({ perPage: 1 }); + let caseId: string; + if (caseList.cases[0]) { + caseId = caseList.cases[0].id; + } else { + if (opts.verbose) console.log("→ No cases found, creating a seed case…"); + const newCase = await services.casesService.createCase({ + title: `mcp-app-test seed case ${new Date().toISOString()}`, + description: "Seed case created by test-permissions runner", + tags: ["mcp-app-test"], + }); + caseId = newCase.id; + } + + // Capture an existing rule (best-effort; missing rule just causes patchRule to skip). + let ruleId: string | undefined; + try { + const rules = await services.rulesService.findRules({ perPage: 1 }); + ruleId = rules.data[0]?.id; + } catch { + /* rules feature might not be available */ + } + + const discoveryId = await ensureDiscoveryFixture(services, opts); + const exceptionListId = await ensureExceptionListFixture(services, opts); + + return { + alertId: alertHit._id, + alertIndex: alertHit._index, + alertRuleId, + alertRuleName, + caseId, + ruleId, + discoveryId, + exceptionListId, + suffix: crypto.randomBytes(4).toString("hex"), + }; +} + +async function cleanupStaleResources( + services: Services, + opts: CliOptions +): Promise { + const keys = await listApiKeysByPrefix(services.esClient, TEST_RESOURCE_PREFIX); + for (const k of keys) { + if (opts.verbose) console.log(`→ Invalidating stale API key: ${k.name} (${k.id})`); + try { + await deleteApiKey(services.esClient, k.id); + } catch (err) { + console.warn(` warning: failed to invalidate ${k.id}: ${formatError(err)}`); + } + } + const roles = await listRolesByPrefix(services.esClient, TEST_RESOURCE_PREFIX); + for (const r of roles) { + if (opts.verbose) console.log(`→ Deleting stale role: ${r}`); + try { + await deleteRole(services.esClient, r); + } catch (err) { + console.warn(` warning: failed to delete role ${r}: ${formatError(err)}`); + } + } + const users = await listUsersByPrefix(services.esClient, TEST_RESOURCE_PREFIX); + for (const u of users) { + if (opts.verbose) console.log(`→ Deleting stale user: ${u}`); + try { + await deleteUser(services.esClient, u); + } catch (err) { + console.warn(` warning: failed to delete user ${u}: ${formatError(err)}`); + } + } +} + +async function provisionQuickstart( + services: Services, + admin: AdminConfig, + role: QuickstartRoleName, + suffix: string +): Promise { + const builtin = QUICKSTART_BUILTINS[role]; + if (!(await roleExists(services.esClient, builtin))) { + return { role, reason: `built-in '${builtin}' not present in cluster` }; + } + const descriptor = QUICKSTART_COMPANION_DESCRIPTORS[role]; + const companionRoleName = `${TEST_RESOURCE_PREFIX}${role}-companion-${suffix}`; + const username = `${TEST_RESOURCE_PREFIX}${role}-${suffix}`; + const password = crypto.randomBytes(18).toString("base64"); + await createRole(services.esClient, companionRoleName, descriptor); + let apiKey: CreatedApiKey; + try { + await createUser(services.esClient, username, password, [builtin, companionRoleName]); + apiKey = await grantApiKeyForUser( + { + elasticsearchUrl: admin.elasticsearchUrl, + username: admin.basicAuth.username, + password: admin.basicAuth.password, + }, + username, + password, + username + ); + } catch (err) { + // Best-effort cleanup so a failed provisioning leaves no orphans. + try { + await deleteUser(services.esClient, username); + } catch { + /* swallow */ + } + try { + await deleteRole(services.esClient, companionRoleName); + } catch { + /* swallow */ + } + throw err; + } + return { role, companionRoleName, username, apiKey }; +} + +async function provisionRole( + services: Services, + admin: AdminConfig, + role: "full" | "readonly", + suffix: string +): Promise { + const descriptor = ROLE_DESCRIPTORS[role]; + const roleName = `${TEST_RESOURCE_PREFIX}${role}-${suffix}`; + await createRole(services.esClient, roleName, descriptor); + const apiKey = await createApiKey( + { + elasticsearchUrl: admin.elasticsearchUrl, + username: admin.basicAuth.username, + password: admin.basicAuth.password, + }, + roleName, + roleName, + descriptor + ); + return { role, roleName, apiKey }; +} + +interface LayerAResult { + role: "full" | "readonly"; + outcome: "pass" | "fail"; + detail: string; +} + +async function runLayerA( + role: "full" | "readonly", + services: Services +): Promise { + const descriptor = ROLE_DESCRIPTORS[role]; + + const probe = { + cluster: descriptor.cluster, + index: descriptor.indices.map((i) => ({ + names: i.names, + privileges: i.privileges, + })), + application: descriptor.applications.map( + (a: RoleDescriptorApplication) => ({ + application: a.application, + privileges: a.privileges, + resources: a.resources, + }) + ), + }; + + try { + const result = await hasPrivileges(services.esClient, probe); + if (result.has_all_requested) { + return { + role, + outcome: "pass", + detail: "all requested privileges granted", + }; + } + const missing = collectMissing(result); + return { + role, + outcome: "fail", + detail: `missing privileges: ${missing.join(", ") || "(unknown)"}`, + }; + } catch (err) { + return { + role, + outcome: "fail", + detail: `_has_privileges call failed: ${formatError(err)}`, + }; + } +} + +interface RoleDescriptorApplication { + application: string; + privileges: string[]; + resources: string[]; +} + +function collectMissing(result: { + cluster: Record; + index: Record>; + application: Record>>; +}): string[] { + const missing: string[] = []; + for (const [priv, granted] of Object.entries(result.cluster || {})) { + if (!granted) missing.push(`cluster:${priv}`); + } + for (const [name, privs] of Object.entries(result.index || {})) { + for (const [priv, granted] of Object.entries(privs || {})) { + if (!granted) missing.push(`index:${name}:${priv}`); + } + } + for (const [app, resources] of Object.entries(result.application || {})) { + for (const [resource, privs] of Object.entries(resources || {})) { + for (const [priv, granted] of Object.entries(privs || {})) { + if (!granted) missing.push(`${app}:${resource}:${priv}`); + } + } + } + return missing; +} + +async function runLayerB( + role: AssertedRoleName, + services: Services, + fixtures: SeedFixtures +): Promise<{ checkResults: CheckResult[]; observed: ObservedRun[] }> { + const observed = await runOpsObserve(services, role, fixtures); + const checkResults: CheckResult[] = observed.map((o) => + deriveCheckResult(role, o) + ); + return { checkResults, observed }; +} + +function deriveCheckResult( + role: AssertedRoleName, + o: ObservedRun +): CheckResult { + if (o.outcome === "skipped") { + return { check: o.check, role, outcome: "skipped", detail: o.detail }; + } + const expectation = o.check.expect[ASSERTED_EXPECTATION_PROFILE[role]]; + if (expectation === "ok") { + if (o.outcome === "pass") { + return { check: o.check, role, outcome: "pass", detail: o.detail }; + } + if (o.outcome === "404") { + return { + check: o.check, + role, + outcome: "skipped", + detail: `no fixture in cluster: ${o.detail}`, + }; + } + if (o.outcome === "403") { + return { + check: o.check, + role, + outcome: "fail", + detail: `expected ok but got 403/401`, + }; + } + return { check: o.check, role, outcome: "fail", detail: o.detail }; + } + // expectation === "403" + if (o.outcome === "403") { + return { check: o.check, role, outcome: "pass", detail: o.detail }; + } + if (o.outcome === "pass") { + return { + check: o.check, + role, + outcome: "fail", + detail: `expected 403 but call succeeded`, + }; + } + return { + check: o.check, + role, + outcome: "fail", + detail: `expected 403 but got non-permission error: ${o.detail}`, + }; +} + +interface LeftoverCounts { + cases: number; + rules: number; +} + +/** + * Counts cases and detection rules currently tagged `mcp-app-test`. Run + * under the admin key. Used at end of run to surface what tag-scoped + * cleanup the user may want to do via Kibana. + */ +async function countLeftoverTaggedResources( + services: Services +): Promise { + let cases = 0; + let rules = 0; + try { + const result = await services.casesService.listCases({ + tags: [TEST_TAG], + perPage: 1, + }); + cases = result.total; + } catch { + /* cases API unavailable — leave at 0 */ + } + try { + const result = await services.rulesService.findRules({ + filter: `alert.attributes.tags:"${TEST_TAG}"`, + perPage: 1, + }); + rules = result.total; + } catch { + /* rules API unavailable — leave at 0 */ + } + return { cases, rules }; +} + +function summarize(value: unknown): string { + if (value === undefined || value === null) return "ok"; + if (typeof value === "string") return value.slice(0, 80); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return `array(${value.length})`; + try { + const json = JSON.stringify(value); + return json.length > 80 ? `${json.slice(0, 77)}…` : json; + } catch { + return "ok"; + } +} + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +function symbolFor(outcome: "pass" | "fail" | "skipped"): string { + if (outcome === "pass") return SYM_OK; + if (outcome === "fail") return SYM_FAIL; + return SYM_SKIP; +} + +function printRoleReport( + role: RoleName, + layerA: LayerAResult | null, + layerB: CheckResult[] +) { + console.log(`\n── ${role.toUpperCase()} ──`); + + if (layerA) { + console.log(" Layer A (_has_privileges):"); + console.log( + ` ${symbolFor(layerA.outcome)} all role privileges granted — ${layerA.detail}` + ); + } else { + console.log( + " Layer A: skipped (built-in privileges aren't enumerable from the role descriptor)" + ); + } + + console.log(" Layer B (operations):"); + for (const group of GROUP_ORDER) { + const inGroup = layerB.filter((r) => r.check.group === group); + if (inGroup.length === 0) continue; + console.log(` [${group}]`); + for (const r of inGroup) { + const expected = r.check.expect[ASSERTED_EXPECTATION_PROFILE[role]]; + console.log( + ` ${symbolFor(r.outcome)} ${r.check.name} (expect ${expected}) — ${r.detail}` + ); + } + } +} + +async function cleanupRoleArtifacts( + adminSvc: Services, + artifacts: RoleArtifacts[], + quickstartArtifacts: QuickstartArtifacts[], + exceptionListId: string | undefined, + opts: CliOptions +) { + if (!opts.cleanup) { + console.log("\n→ Skipping cleanup (--no-cleanup). Provisioned resources:"); + for (const a of artifacts) { + console.log(` role: ${a.roleName}`); + console.log(` api key: ${a.apiKey.name} (id=${a.apiKey.id})`); + console.log(` encoded: ${a.apiKey.encoded}`); + } + for (const q of quickstartArtifacts) { + console.log(` user: ${q.username} (quickstart: ${q.role})`); + console.log(` role: ${q.companionRoleName}`); + console.log(` api key: ${q.apiKey.name} (id=${q.apiKey.id})`); + console.log(` encoded: ${q.apiKey.encoded}`); + } + if (exceptionListId) { + console.log(` exception list: ${exceptionListId} (namespace_type=single)`); + } + return; + } + if (exceptionListId) { + await deleteExceptionListFixture(adminSvc, exceptionListId); + } + for (const a of artifacts) { + try { + await deleteApiKey(adminSvc.esClient, a.apiKey.id); + } catch (err) { + console.warn( + ` warning: failed to invalidate API key ${a.apiKey.id}: ${formatError(err)}` + ); + } + try { + await deleteRole(adminSvc.esClient, a.roleName); + } catch (err) { + console.warn( + ` warning: failed to delete role ${a.roleName}: ${formatError(err)}` + ); + } + } + for (const q of quickstartArtifacts) { + try { + await deleteApiKey(adminSvc.esClient, q.apiKey.id); + } catch (err) { + console.warn( + ` warning: failed to invalidate API key ${q.apiKey.id}: ${formatError(err)}` + ); + } + try { + await deleteUser(adminSvc.esClient, q.username); + } catch (err) { + console.warn( + ` warning: failed to delete user ${q.username}: ${formatError(err)}` + ); + } + try { + await deleteRole(adminSvc.esClient, q.companionRoleName); + } catch (err) { + console.warn( + ` warning: failed to delete role ${q.companionRoleName}: ${formatError(err)}` + ); + } + } +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const basics = loadAdminBasics(); + + // Bootstrap an admin API key via Basic auth. Two reasons: + // 1. The standard ES + Kibana client factories only support + // `Authorization: ApiKey ...` and we need an admin-privilege key for + // seed-fixture queries (fetchAlerts, listCases, createCase, etc.). + // 2. The user may not have a usable API key in .env (we don't want to + // require them to mint one manually). Basic auth is the + // local-dev-friendly path. + const bootstrapKeyName = `mcp-runner-bootstrap-${crypto.randomBytes(4).toString("hex")}`; + if (opts.verbose) console.log(`→ Bootstrapping admin API key "${bootstrapKeyName}"…`); + const bootstrapKey = await bootstrapAdminApiKey( + { + elasticsearchUrl: basics.elasticsearchUrl, + username: basics.basicAuth.username, + password: basics.basicAuth.password, + }, + bootstrapKeyName + ); + const admin: AdminConfig = { + elasticsearchUrl: basics.elasticsearchUrl, + elasticsearchApiKey: bootstrapKey.encoded, + basicAuth: basics.basicAuth, + kibanaUrl: basics.kibanaUrl, + }; + + // The admin services bundle is the long-lived "control plane" — used + // for provisioning, cleanup, and fixture seeding. A short-lived "data + // plane" bundle is built per role swap inside the run loop below. + const adminSvc = adminServices(admin); + + const provisioned: RoleArtifacts[] = []; + const provisionedQuickstarts: QuickstartArtifacts[] = []; + let seededExceptionListId: string | undefined; + let interrupted = false; + + const cleanupBootstrap = async () => { + try { + await deleteApiKey(adminSvc.esClient, bootstrapKey.id); + } catch (err) { + console.warn( + ` warning: failed to invalidate bootstrap admin key ${bootstrapKey.id}: ${formatError(err)}` + ); + } + }; + + const onSignal = () => { + interrupted = true; + console.log("\n→ Caught SIGINT, cleaning up before exit…"); + cleanupRoleArtifacts( + adminSvc, + provisioned, + provisionedQuickstarts, + seededExceptionListId, + opts + ) + .then(() => cleanupBootstrap()) + .catch((err) => console.error(`Cleanup error: ${formatError(err)}`)) + .finally(() => process.exit(130)); + }; + process.on("SIGINT", onSignal); + + let exitCode = 0; + try { + if (opts.cleanupStale) { + console.log(`→ --cleanup-stale: removing leftover ${TEST_RESOURCE_PREFIX}* resources…`); + await cleanupStaleResources(adminSvc, opts); + } + + console.log("→ Pre-flight: checking connectivity and seed data…"); + const fixtures = await preflight(adminSvc, opts); + seededExceptionListId = fixtures.exceptionListId; + if (opts.verbose) { + console.log(` alertId: ${fixtures.alertId}`); + console.log(` alertIndex: ${fixtures.alertIndex}`); + console.log(` alertRuleId: ${fixtures.alertRuleId}`); + console.log(` caseId: ${fixtures.caseId}`); + console.log(` ruleId: ${fixtures.ruleId ?? "(none — patchRule will skip)"}`); + console.log(` exceptionListId: ${fixtures.exceptionListId ?? "(none — listExceptions/addException will skip)"}`); + console.log(` suffix: ${fixtures.suffix}`); + } + + interface AssertedRun { + role: AssertedRoleName; + layerA: LayerAResult | null; + layerB: CheckResult[]; + } + const assertedRuns: AssertedRun[] = []; + const unavailableQuickstarts: UnavailableQuickstart[] = []; + + for (const role of opts.roles) { + console.log(`\n→ Provisioning role "${role}"…`); + let apiKey: CreatedApiKey; + let layerA: LayerAResult | null = null; + if (role === "full" || role === "readonly") { + const artifacts = await provisionRole(adminSvc, admin, role, fixtures.suffix); + provisioned.push(artifacts); + apiKey = artifacts.apiKey; + // Layer A runs as the scoped role (the privilege check answers + // "does this caller have these privileges?", which only makes + // sense for the scoped key, not the admin). + const layerASvc = scopedServices(admin, apiKey.encoded); + layerA = await runLayerA(role, layerASvc); + } else { + const result = await provisionQuickstart(adminSvc, admin, role, fixtures.suffix); + if ("reason" in result) { + console.warn(` ! ${role} unavailable: ${result.reason}`); + unavailableQuickstarts.push(result); + continue; + } + provisionedQuickstarts.push(result); + apiKey = result.apiKey; + } + const scopedSvc = scopedServices(admin, apiKey.encoded); + const { checkResults } = await runLayerB(role, scopedSvc, fixtures); + assertedRuns.push({ role, layerA, layerB: checkResults }); + } + + let passed = 0; + let failed = 0; + let skipped = 0; + for (const { role, layerA, layerB } of assertedRuns) { + printRoleReport(role, layerA, layerB); + if (layerA) { + if (layerA.outcome === "pass") passed++; + else failed++; + } + for (const r of layerB) { + if (r.outcome === "pass") passed++; + else if (r.outcome === "fail") failed++; + else skipped++; + } + } + + if (assertedRuns.length > 0) { + console.log( + `\nSummary: ${passed} passed, ${failed} failed, ${skipped} skipped` + ); + } + + // Surface leftover test resources so the user can clean them up. We + // query through the admin services bundle to make sure we see + // everything regardless of which scoped role most recently ran. + const leftover = await countLeftoverTaggedResources(adminSvc); + if (leftover.cases > 0 || leftover.rules > 0) { + const parts: string[] = []; + if (leftover.cases > 0) parts.push(`${leftover.cases} case(s)`); + if (leftover.rules > 0) parts.push(`${leftover.rules} rule(s)`); + console.log( + `Note: ${parts.join(", ")} tagged "${TEST_TAG}" remain in the cluster — clean up via Kibana > Stack Management or with a tag-scoped delete.` + ); + } + + exitCode = failed === 0 ? 0 : 1; + } catch (err) { + console.error(`\nFatal error: ${formatError(err)}`); + exitCode = 1; + } finally { + if (!interrupted) { + try { + await cleanupRoleArtifacts( + adminSvc, + provisioned, + provisionedQuickstarts, + seededExceptionListId, + opts + ); + } catch (err) { + console.error(`Cleanup error: ${formatError(err)}`); + if (exitCode === 0) exitCode = 1; + } + // The bootstrap admin API key is always invalidated, even when the + // user asked --no-cleanup (which only preserves the per-role + // scoped keys for debugging). Leaving the bootstrap key around + // would be a real footgun: it carries admin privileges. + await cleanupBootstrap(); + } + } + process.exit(exitCode); +} + +main().catch((err) => { + console.error(formatError(err)); + process.exit(1); +}); diff --git a/scripts/test-permissions/services.ts b/scripts/test-permissions/services.ts new file mode 100644 index 0000000..1100370 --- /dev/null +++ b/scripts/test-permissions/services.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + AlertsClient, + AttackDiscoveryClient, + CasesClient, + EsqlClient, + IndicesClient, + RulesClient, + SampleDataClient, +} from "../../src/elastic/client/index.js"; +import type { ClusterCredentials } from "../../src/elastic/credential-client/index.js"; +import { + createEsClient, + type EsClient, +} from "../../src/elastic/es-client/index.js"; +import { + createKibanaClient, + type KibanaClient, +} from "../../src/elastic/kibana-client/index.js"; +import { + AlertsService, + AttackDiscoveryService, + CasesService, + EsqlService, + IndicesService, + RulesService, + SampleDataService, +} from "../../src/elastic/service/index.js"; + +/** + * Bundle of services + low-level clients wired against a single set of + * cluster credentials. The runner rebuilds this on each role swap rather + * than mutating any singleton. + * + * Mirrors the wiring in `src/server.ts` — keep this in sync when new + * services are added there. + */ +export interface Services { + readonly esClient: EsClient; + readonly kibanaClient: KibanaClient; + readonly alertsService: AlertsService; + readonly casesService: CasesService; + readonly rulesService: RulesService; + readonly attackDiscoveryService: AttackDiscoveryService; + readonly esqlService: EsqlService; + readonly indicesService: IndicesService; + readonly sampleDataService: SampleDataService; +} + +export function buildServices(creds: ClusterCredentials): Services { + const esClient = createEsClient(creds); + const kibanaClient = createKibanaClient(creds); + + const alertsService = new AlertsService({ + alertsClient: new AlertsClient({ esClient }), + }); + const attackDiscoveryService = new AttackDiscoveryService({ + attackDiscoveryClient: new AttackDiscoveryClient({ esClient, kibanaClient }), + }); + const casesService = new CasesService({ + casesClient: new CasesClient({ esClient, kibanaClient }), + }); + const esqlService = new EsqlService({ + esqlClient: new EsqlClient({ esClient }), + }); + const indicesService = new IndicesService({ + indicesClient: new IndicesClient({ esClient }), + }); + const rulesService = new RulesService({ + rulesClient: new RulesClient({ esClient, kibanaClient }), + }); + const sampleDataService = new SampleDataService({ + sampleDataClient: new SampleDataClient({ esClient }), + rulesService, + }); + + return { + esClient, + kibanaClient, + alertsService, + casesService, + rulesService, + attackDiscoveryService, + esqlService, + indicesService, + sampleDataService, + }; +}