diff --git a/README.md b/README.md index f292930..0c0e5c0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ See [docs/features.md](docs/features.md) for a full breakdown of each tool's cap > > 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 the API key's permissions, see [Required permissions](docs/permissions.md) (stateful) or [Serverless permissions](docs/permissions-serverless.md) (Elastic Cloud Serverless Security projects). The stateful Quickstart 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-serverless.md b/docs/permissions-serverless.md new file mode 100644 index 0000000..2de2da5 --- /dev/null +++ b/docs/permissions-serverless.md @@ -0,0 +1,175 @@ +# Permissions — Elastic Cloud Serverless (Security project) + +This guide covers the MCP app on **Elastic Cloud Serverless Security projects**. Serverless ships a curated set of built-in role identities; you can also create custom roles if the built-ins don't fit. + +> **Stateful deployments:** See [permissions.md](./permissions.md) for self-managed and Elastic Cloud Hosted. + +--- + +## Built-in role identities + +Serverless Security projects pre-provision role-specific users in the file realm. You don't create or configure them — they're ready to use. Below is the observed behavior of three representative tiers against all MCP app operations. + +### Role tier summary + +| Operation | `t1_analyst` | `t2_analyst` | `soc_manager` | +|---|:---:|:---:|:---:| +| **Alerts** | | | | +| Fetch alerts | ✓ | ✓ | ✓ | +| Acknowledge alert | ✓ | ✓ | ✓ | +| Get alert context | ✓ | ✓ | ✓ | +| Endpoint events readable | ✓ | ✓ | ✓ | +| **Cases** | | | | +| List cases | ✓ | ✓ | ✓ | +| Get case | ✓ | ✓ | ✓ | +| Create case | ✗ | ✓ | ✓ | +| Update case | ✗ | ✓ | ✓ | +| Add comment | ✗ | ✓ | ✓ | +| Attach alert to case | ✗ | ✓ | ✓ | +| **Rules** | | | | +| Find rules | ✓ | ✓ | ✓ | +| Noisy rules | ✓ | ✓ | ✓ | +| Create rule | ✗ | ✗ | ✓ | +| Patch rule | ✗ | ✗ | ✓ | +| Bulk rule action | ✗ | ✗ | ✓ | +| List exceptions | ✓ | ✓ | ✓ | +| Add exception | ✗ | ✗ | ✓ | +| **Attack Discovery** | | | | +| Fetch discoveries | ✓ | ✓ | ✓ | +| List AI connectors | ✓ | ✓ | ✓ | +| Assess confidence | ✓ | ✓ | ✓ | +| Get discovery detail | ✓ | ✓ | ✓ | +| Acknowledge discoveries | ✓ | ✓ | ✓ | +| **Threat Hunt** | | | | +| Execute ES\|QL | ✓ | ✓ | ✓ | +| List indices (`_cat/indices`) | ✗ | ✗ | ✗ | +| Get field mapping | ✗ | ✗ | ✓ | +| **Sample Data** | | | | +| Check existing data | ✓ | ✓ | ✓ | +| Generate sample data | ✗ | ✗ | ✓ | +| Cleanup sample data logs | ✗ | ✗ | ✓ | +| Cleanup sample data alerts | ✓ | ✓ | ✓ | + +### Role capability profiles + +**`t1_analyst`** — read-only across all Security surfaces. Can read and acknowledge alerts, view cases and rules, run ES|QL queries, and access Attack Discovery. Cannot write cases, create or modify rules, list raw index names, or generate sample data. Closest stateful equivalent: `viewer` + alert-write index privileges. + +**`t2_analyst`** — adds full case management on top of `t1_analyst`. Can create, update, and comment on cases and attach alerts to cases. Still cannot manage rules or list indices. Closest stateful equivalent: `editor` on Cases only, `viewer` on everything else. + +**`soc_manager`** — full operational access. Can manage rules (create, patch, bulk actions), add exceptions, generate and clean up sample data, and get field mappings. The one gap shared with all tiers is `listIndices` — `_cat/indices` is restricted by the cluster privilege `read_project_routing` that Serverless built-ins hold (not `monitor`). + +### `listIndices` limitation + +All built-in Serverless Security roles use `cluster: read_project_routing` rather than the `cluster: monitor` privilege that stateful deployments use. `_cat/indices/` requires `monitor`, so the index-picker in the Threat Hunt tool cannot enumerate available indices for any built-in role. Workaround: use a [custom role](#custom-roles) that includes `cluster: monitor`. + +--- + +## Connecting with a built-in role identity + +The MCP app requires an API key. Create one in Kibana under **Stack Management → API Keys** while logged in as the user whose role you want to use. The key inherits that user's role permissions. + +Use the `encoded` value from the created key as `elasticsearchApiKey` in your cluster config. + +--- + +## Custom roles + +Custom roles are supported on Serverless Security projects (GA since October 2024). Create them with `PUT /_security/role/` in Kibana Dev Tools. The Kibana feature privilege names on serverless differ slightly from stateful 9.4+: + +| Feature | Serverless privilege (all/read) | +|---|---| +| SIEM | `feature_siemV5.all` / `feature_siemV5.read` | +| Cases | `feature_securitySolutionCasesV3.all` / `feature_securitySolutionCasesV3.read` | +| Rules | `feature_securitySolutionRulesV2.all` / `feature_securitySolutionRulesV2.read` | +| Alerts | `feature_securitySolutionAlertsV1.all` / `feature_securitySolutionAlertsV1.read` | +| AI Assistant | `feature_securitySolutionAssistant.all` / `feature_securitySolutionAssistant.read` | +| Attack Discovery | `feature_securitySolutionAttackDiscovery.all` / `feature_securitySolutionAttackDiscovery.read` | +| Timeline | `feature_securitySolutionTimeline.all` / `feature_securitySolutionTimeline.read` | +| Notes | `feature_securitySolutionNotes.all` / `feature_securitySolutionNotes.read` | +| Actions/Connectors | `feature_actions.all` / `feature_actions.read` | + +Note: `feature_securitySolutionRulesV2` on serverless vs `feature_securitySolutionRulesV4` on stateful 9.4+. + +### Full-access custom role (serverless) + +``` +PUT /_security/role/mcp_app_full_serverless +{ + "cluster": ["monitor"], + "indices": [ + { + "names": [ + ".alerts-security.alerts-default", + ".alerts-security.attack.discovery.alerts-default", + ".adhoc.alerts-security.attack.discovery.alerts-default", + ".internal.alerts-security.alerts-default-*", + ".internal.alerts-security.attack.discovery.alerts-default-*", + ".internal.adhoc.alerts-security.attack.discovery.alerts-default-*", + "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_securitySolutionRulesV2.all", + "feature_securitySolutionAlertsV1.all", + "feature_securitySolutionAssistant.all", + "feature_securitySolutionAttackDiscovery.all", + "feature_actions.all" + ], + "resources": ["space:default"] + } + ] +} +``` + +Adding `cluster: monitor` fixes the `listIndices` gap that all built-in roles share. + +### Read-only custom role (serverless) + +``` +PUT /_security/role/mcp_app_readonly_serverless +{ + "cluster": [], + "indices": [ + { + "names": [ + ".alerts-security.alerts-default", + ".alerts-security.attack.discovery.alerts-default", + ".adhoc.alerts-security.attack.discovery.alerts-default", + ".internal.alerts-security.alerts-default-*", + ".internal.alerts-security.attack.discovery.alerts-default-*", + ".internal.adhoc.alerts-security.attack.discovery.alerts-default-*", + "logs-*", + "risk-score.risk-score-latest-*" + ], + "privileges": ["read", "view_index_metadata"] + } + ], + "applications": [ + { + "application": "kibana-.kibana", + "privileges": [ + "feature_siemV5.read", + "feature_securitySolutionCasesV3.read", + "feature_securitySolutionTimeline.read", + "feature_securitySolutionNotes.read", + "feature_securitySolutionRulesV2.read", + "feature_securitySolutionAlertsV1.read", + "feature_securitySolutionAssistant.read", + "feature_securitySolutionAttackDiscovery.read", + "feature_actions.read" + ], + "resources": ["space:default"] + } + ] +} +``` diff --git a/docs/permissions.md b/docs/permissions.md index f0aecd6..25db571 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -7,7 +7,7 @@ This guide defines the least-privilege roles for the Elastic Security MCP app on > **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. +> **Serverless:** This guide targets stateful deployments. For Elastic Cloud Serverless Security projects, see [permissions-serverless.md](./permissions-serverless.md). --- diff --git a/package-lock.json b/package-lock.json index ca3cfcb..8dc8c24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5810,14 +5810,6 @@ "node": ">= 18" } }, - "node_modules/monaco-promql": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/monaco-promql/-/monaco-promql-1.8.0.tgz", - "integrity": "sha512-XdgRojBzEe/rKtrJaHbSfoMFOMD5TXymDHIitTngmBT6XEjtAirnA7Rb2YJAO1SZrJfgvAo4LFCzJ71fH7+WOw==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 11991b3..4f7645f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "dev": "concurrently --raw \"node scripts/build-views.js --watch\" \"tsx watch main.ts\"", "typecheck": "tsc --noEmit", "test:permissions": "tsx scripts/test-permissions/runner.ts", + "test:permissions:serverless": "tsx scripts/test-permissions/runner.ts --mode serverless --role serverless", + "test:permissions:serverless:all": "tsx scripts/test-permissions/runner.ts --mode serverless --role serverless_all", "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 index 19e2101..f27d1d6 100644 --- a/scripts/test-permissions/README.md +++ b/scripts/test-permissions/README.md @@ -2,6 +2,8 @@ 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. +Supports both **stateful** (default) and **serverless** deployment modes — see [Serverless mode](#serverless-mode) below. + ## Quick Start ```bash @@ -20,14 +22,85 @@ Exit code is `0` if every check passes (or is skipped); `1` otherwise. | Flag | Description | |---|---| -| `--role full\|readonly\|both` | Which role(s) to test (default: `both`). | +| `--mode stateful\|serverless` | Deployment mode (default: `stateful`). Changes the default admin username and enables serverless role aliases. | +| `--role ` | Which role(s) to test (default: `both`). See below for valid names and aliases. | | `--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. | +**`--role` values:** + +| Value | Expands to | +|---|---| +| `full` | Custom full-access role | +| `readonly` | Custom read-only role | +| `both` (default) | `full` + `readonly` | +| `all` | `full` + `readonly` + `quickstart_full` + `quickstart_readonly` | +| `quickstart_full` | Quickstart built-in `editor` + companion role | +| `quickstart_readonly` | Quickstart built-in `viewer` + companion role | +| `quickstart` | `quickstart_full` + `quickstart_readonly` | +| `serverless_t1_analyst` | Serverless built-in `t1_analyst` user (observe-only) | +| `serverless_t2_analyst` | Serverless built-in `t2_analyst` user (observe-only) | +| `serverless_soc_manager` | Serverless built-in `soc_manager` user (observe-only) | +| `serverless` | All 3 serverless built-in roles | +| `serverless_all` | All stateful asserted + all serverless observe-only | +| `none` | No roles (cleanup-stale only) | + Pass flags via `--`, e.g. `npm run test:permissions -- --role readonly --verbose`. +## Serverless mode + +For Elastic Cloud Serverless (Security project type), start a local serverless cluster and then run: + +```bash +# Start serverless ES (port 9200) +yarn es serverless --projectType=security + +# Start serverless Kibana (port 5601) +yarn start --serverless=security + +# In example-mcp-app-security, configure .env: +# ELASTICSEARCH_URL=http://localhost:9200 +# KIBANA_URL=http://localhost:5601/kbn +# ELASTIC_PASSWORD=changeme +# (ELASTIC_USERNAME defaults to "elastic_serverless" in serverless mode) + +npm run test:permissions:serverless +``` + +The serverless runner uses `--mode serverless`, which: +- Defaults admin username to `elastic_serverless` (instead of `elastic`) +- Authenticates the three built-in role users (`t1_analyst`, `t2_analyst`, `soc_manager`) via `grant_api_key` — they exist as file-realm users with password `changeme` +- Runs each built-in role in **observe-only mode**: all operations are exercised and results are printed, but no assertions are made. The exit code is only affected by the asserted custom roles (`full`, `readonly`) if those are also included + +Custom roles (`full`, `readonly`) work on serverless too — `PUT /_security/role` is supported since the GA of custom roles in Serverless Security (Oct 2024). + +To run both built-in observed roles and custom asserted roles against serverless in one pass: + +```bash +npm run test:permissions:serverless:all +``` + +### Output for serverless built-in roles + +Observed reports look like: + +``` +── SERVERLESS_T1_ANALYST (observe-only) ── + Layer A: skipped (built-in role — privileges not enumerable from a role descriptor) + Layer B (operations, observed — no pass/fail assertions): + [alerts] + ✓ fetchAlerts — pass: array(1) + ✓ acknowledgeAlert — pass: ok + ... + [rules] + ✗ createRule — 403: denied (403/401) + ... +``` + +Symbols show what actually happened: `✓` = call succeeded, `✗` = 403/401, `→` = skipped or other. These results inform [`docs/permissions-serverless.md`](../../docs/permissions-serverless.md). + ## 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. @@ -69,7 +142,7 @@ A privilege documented in the full role is missing from `roles.ts`. Diff against 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`. +`.env` isn't loading or is missing one of `ELASTICSEARCH_URL`, `KIBANA_URL`, `ELASTIC_PASSWORD`. `ELASTIC_USERNAME` is optional (defaults to `elastic` in stateful mode, `elastic_serverless` in serverless mode). 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. diff --git a/scripts/test-permissions/roles.ts b/scripts/test-permissions/roles.ts index cc5432d..e7ec04c 100644 --- a/scripts/test-permissions/roles.ts +++ b/scripts/test-permissions/roles.ts @@ -112,7 +112,35 @@ export type AssertedRoleName = | "readonly" | "quickstart_full" | "quickstart_readonly"; -export type RoleName = AssertedRoleName; + +export type ServerlessRoleName = + | "serverless_t1_analyst" + | "serverless_t2_analyst" + | "serverless_soc_manager"; + +/** + * Maps each serverless built-in role identity to the Elastic Cloud + * Serverless Security project role name and the file-realm password + * used by the local dev stack. + * + * All local serverless file-realm users share password `changeme` + * (see kibana-main serverless_resources/users). The runner mints + * per-run API keys via `grant_type: "password"` — no role creation + * or native user creation is needed. + * + * Note: `viewer` is a platform-level role with no pre-provisioned file-realm + * user in the Security project. Security-specific tiers start at `t1_analyst`. + */ +export const SERVERLESS_BUILTINS: Record< + ServerlessRoleName, + { roleName: string; password: string } +> = { + serverless_t1_analyst: { roleName: "t1_analyst", password: "changeme" }, + serverless_t2_analyst: { roleName: "t2_analyst", password: "changeme" }, + serverless_soc_manager: { roleName: "soc_manager", password: "changeme" }, +}; + +export type RoleName = AssertedRoleName | ServerlessRoleName; /** * Custom-role descriptors for the asserted "Advanced" path. These @@ -190,7 +218,7 @@ export const QUICKSTART_COMPANION_DESCRIPTORS: Record< }; /** Any role identity the runner may exercise. */ -export type AnyRoleName = AssertedRoleName; +export type AnyRoleName = RoleName; export type OperationGroup = | "alerts" diff --git a/scripts/test-permissions/runner.ts b/scripts/test-permissions/runner.ts index 041edc5..c877e82 100644 --- a/scripts/test-permissions/runner.ts +++ b/scripts/test-permissions/runner.ts @@ -14,6 +14,7 @@ import { QUICKSTART_BUILTINS, QUICKSTART_COMPANION_DESCRIPTORS, ROLE_DESCRIPTORS, + SERVERLESS_BUILTINS, operationChecks, type AnyRoleName, type AssertedRoleName, @@ -23,6 +24,7 @@ import { type RoleName, type RunOutcome, type SeedFixtures, + type ServerlessRoleName, } from "./roles.js"; import { bootstrapAdminApiKey, @@ -47,8 +49,11 @@ const TEST_RESOURCE_PREFIX = "mcp-app-test-"; const ADMIN_CLUSTER_NAME = "test-permissions-admin"; const SCOPED_CLUSTER_NAME = "test-permissions-scoped"; +type DeploymentMode = "stateful" | "serverless"; + interface CliOptions { roles: RoleName[]; + mode: DeploymentMode; cleanupStale: boolean; cleanup: boolean; verbose: boolean; @@ -108,32 +113,49 @@ const ALL_ASSERTED_ROLES: AssertedRoleName[] = [ "quickstart_readonly", ]; +const ALL_SERVERLESS_ROLES: ServerlessRoleName[] = [ + "serverless_t1_analyst", + "serverless_t2_analyst", + "serverless_soc_manager", +]; + function parseArgs(argv: string[]): CliOptions { const opts: CliOptions = { roles: ["full", "readonly"], + mode: "stateful", cleanupStale: false, cleanup: true, verbose: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--role") { + if (arg === "--mode") { + const value = argv[++i]; + if (value === "stateful" || value === "serverless") opts.mode = value; + else die(`Unknown --mode value: ${value} (expected stateful|serverless)`); + } else 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 === "serverless") opts.roles = [...ALL_SERVERLESS_ROLES]; + else if (value === "serverless_all") + opts.roles = [...ALL_ASSERTED_ROLES, ...ALL_SERVERLESS_ROLES]; else if (value === "none") opts.roles = []; else if ( value === "full" || value === "readonly" || value === "quickstart_full" || - value === "quickstart_readonly" + value === "quickstart_readonly" || + value === "serverless_t1_analyst" || + value === "serverless_t2_analyst" || + value === "serverless_soc_manager" ) opts.roles = [value]; else die( - `Unknown --role value: ${value} (expected full|readonly|quickstart_full|quickstart_readonly|both|quickstart|all|none)` + `Unknown --role value: ${value} (expected full|readonly|quickstart_full|quickstart_readonly|both|quickstart|all|none|serverless|serverless_all|serverless_t1_analyst|serverless_t2_analyst|serverless_soc_manager)` ); } else if (arg === "--cleanup-stale") { opts.cleanupStale = true; @@ -155,11 +177,15 @@ 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). + --mode Deployment mode (default: stateful). + --role Role(s) to test (default: both). + Stateful names: full, readonly, quickstart_full, quickstart_readonly. + Stateful aliases: "both" = full,readonly. "quickstart" = quickstart_*. + "all" = all four stateful roles. + Serverless names: serverless_t1_analyst, serverless_t2_analyst, serverless_soc_manager. + Serverless aliases: "serverless" = all 3 built-in roles. + "serverless_all" = all stateful + all serverless. + "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 @@ -178,17 +204,18 @@ interface AdminBasics { basicAuth: { username: string; password: string }; } -function loadAdminBasics(): AdminBasics { +function loadAdminBasics(mode: DeploymentMode): 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"; + // Serverless local dev uses "elastic_serverless"; stateful uses "elastic". + // Both can be overridden via ELASTIC_USERNAME env var. + const defaultUsername = mode === "serverless" ? "elastic_serverless" : "elastic"; + const username = process.env.ELASTIC_USERNAME || defaultUsername; 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'." + `ELASTIC_USERNAME defaults to '${defaultUsername}' in ${mode} mode.` ); } return { @@ -612,6 +639,45 @@ async function provisionRole( return { role, roleName, apiKey }; } +interface ServerlessBuiltinArtifacts { + role: ServerlessRoleName; + apiKey: CreatedApiKey; +} + +interface ServerlessBuiltinUnavailable { + role: ServerlessRoleName; + reason: string; +} + +/** + * Provisions a scoped API key for a serverless built-in role user. The user + * already exists as a file-realm user in the local serverless cluster — no + * role or user creation needed. The key is invalidated on cleanup. + */ +async function provisionServerlessBuiltin( + admin: AdminConfig, + role: ServerlessRoleName, + suffix: string +): Promise { + const { roleName, password } = SERVERLESS_BUILTINS[role]; + const keyName = `${TEST_RESOURCE_PREFIX}serverless-${roleName}-${suffix}`; + try { + const apiKey = await grantApiKeyForUser( + { + elasticsearchUrl: admin.elasticsearchUrl, + username: admin.basicAuth.username, + password: admin.basicAuth.password, + }, + roleName, + password, + keyName + ); + return { role, apiKey }; + } catch (err) { + return { role, reason: formatError(err) }; + } +} + interface LayerAResult { role: "full" | "readonly"; outcome: "pass" | "fail"; @@ -816,7 +882,7 @@ function symbolFor(outcome: "pass" | "fail" | "skipped"): string { } function printRoleReport( - role: RoleName, + role: AssertedRoleName, layerA: LayerAResult | null, layerB: CheckResult[] ) { @@ -847,10 +913,35 @@ function printRoleReport( } } +function outcomeSymbol(outcome: RunOutcome): string { + if (outcome === "pass") return SYM_OK; + if (outcome === "403") return SYM_FAIL; + return SYM_SKIP; +} + +function printObservedReport(role: ServerlessRoleName, observed: ObservedRun[]) { + console.log(`\n── ${role.toUpperCase()} (observe-only) ──`); + console.log( + " Layer A: skipped (built-in role — privileges not enumerable from a role descriptor)" + ); + console.log(" Layer B (operations, observed — no pass/fail assertions):"); + for (const group of GROUP_ORDER) { + const inGroup = observed.filter((r) => r.check.group === group); + if (inGroup.length === 0) continue; + console.log(` [${group}]`); + for (const r of inGroup) { + console.log( + ` ${outcomeSymbol(r.outcome)} ${r.check.name} — ${r.outcome}: ${r.detail}` + ); + } + } +} + async function cleanupRoleArtifacts( adminSvc: Services, artifacts: RoleArtifacts[], quickstartArtifacts: QuickstartArtifacts[], + serverlessArtifacts: ServerlessBuiltinArtifacts[], exceptionListId: string | undefined, opts: CliOptions ) { @@ -867,6 +958,11 @@ async function cleanupRoleArtifacts( console.log(` api key: ${q.apiKey.name} (id=${q.apiKey.id})`); console.log(` encoded: ${q.apiKey.encoded}`); } + for (const s of serverlessArtifacts) { + console.log(` serverless role: ${s.role}`); + console.log(` api key: ${s.apiKey.name} (id=${s.apiKey.id})`); + console.log(` encoded: ${s.apiKey.encoded}`); + } if (exceptionListId) { console.log(` exception list: ${exceptionListId} (namespace_type=single)`); } @@ -914,11 +1010,24 @@ async function cleanupRoleArtifacts( ); } } + for (const s of serverlessArtifacts) { + try { + await deleteApiKey(adminSvc.esClient, s.apiKey.id); + } catch (err) { + console.warn( + ` warning: failed to invalidate API key ${s.apiKey.id}: ${formatError(err)}` + ); + } + } +} + +function isServerlessRole(role: RoleName): role is ServerlessRoleName { + return role in SERVERLESS_BUILTINS; } async function main() { const opts = parseArgs(process.argv.slice(2)); - const basics = loadAdminBasics(); + const basics = loadAdminBasics(opts.mode); // Bootstrap an admin API key via Basic auth. Two reasons: // 1. The standard ES + Kibana client factories only support @@ -951,6 +1060,7 @@ async function main() { const provisioned: RoleArtifacts[] = []; const provisionedQuickstarts: QuickstartArtifacts[] = []; + const provisionedServerless: ServerlessBuiltinArtifacts[] = []; let seededExceptionListId: string | undefined; let interrupted = false; @@ -971,6 +1081,7 @@ async function main() { adminSvc, provisioned, provisionedQuickstarts, + provisionedServerless, seededExceptionListId, opts ) @@ -1005,11 +1116,32 @@ async function main() { layerA: LayerAResult | null; layerB: CheckResult[]; } + interface ObservedRoleRun { + role: ServerlessRoleName; + observed: ObservedRun[]; + } const assertedRuns: AssertedRun[] = []; + const observedRoleRuns: ObservedRoleRun[] = []; const unavailableQuickstarts: UnavailableQuickstart[] = []; + const unavailableServerless: ServerlessBuiltinUnavailable[] = []; for (const role of opts.roles) { console.log(`\n→ Provisioning role "${role}"…`); + + if (isServerlessRole(role)) { + const result = await provisionServerlessBuiltin(admin, role, fixtures.suffix); + if ("reason" in result) { + console.warn(` ! ${role} unavailable: ${result.reason}`); + unavailableServerless.push(result); + continue; + } + provisionedServerless.push(result); + const scopedSvc = scopedServices(admin, result.apiKey.encoded); + const observed = await runOpsObserve(scopedSvc, role, fixtures); + observedRoleRuns.push({ role, observed }); + continue; + } + let apiKey: CreatedApiKey; let layerA: LayerAResult | null = null; if (role === "full" || role === "readonly") { @@ -1052,9 +1184,25 @@ async function main() { } } + for (const { role, observed } of observedRoleRuns) { + printObservedReport(role, observed); + } + + if (unavailableServerless.length > 0) { + console.log("\nUnavailable serverless built-in roles (skipped):"); + for (const u of unavailableServerless) { + console.log(` ! ${u.role}: ${u.reason}`); + } + } + if (assertedRuns.length > 0) { console.log( - `\nSummary: ${passed} passed, ${failed} failed, ${skipped} skipped` + `\nSummary (asserted roles): ${passed} passed, ${failed} failed, ${skipped} skipped` + ); + } + if (observedRoleRuns.length > 0) { + console.log( + `Observed (serverless built-ins): ${observedRoleRuns.length} role(s) run — see reports above for details.` ); } @@ -1082,6 +1230,7 @@ async function main() { adminSvc, provisioned, provisionedQuickstarts, + provisionedServerless, seededExceptionListId, opts );