Back to Kibana

Saved Object `esql` method

dev_docs/tutorials/saved_objects_esql.mdx

9.4.08.6 KB
Original Source

SavedObjectsClientContract.esql allows you to query Saved Objects using ES|QL (Elasticsearch Query Language). It returns tabular results (columns and values) directly from Elasticsearch, which can be useful for analytics, aggregations, and cross-type queries that don't fit the find or search methods.

MethodUse caseResponse format
findSimple filtering and pagination of saved objectsStructured SavedObject[]
searchComplex queries using Elasticsearch Query DSLRaw Elasticsearch search hits
esqlTabular queries using ESQL syntax

Use esql when you need ES|QL-specific features like STATS, EVAL, ENRICH, or pipe-based query composition.

<DocCallOut title="With great power comes great responsibility"> While the `esql` method is powerful, it can increase code complexity, introduce performance issues and introduce security risks (like injection attacks). Carefully consider how you would like to use this method in your plugin to unlock value for users. </DocCallOut>

The pipeline concept

Like search and find, you specify saved object types as a dedicated parameter — you never need to know or write index names. The esql method resolves the correct Elasticsearch indices from the type parameter and auto-generates the FROM clause. Security filters (namespace + type restriction) are injected via the filter parameter, so you don't need WHERE type == either.

You write only the ES|QL processing pipeline — everything after FROM:

ts
import { isResponseError } from '@kbn/es-errors';
import { MY_TYPE } from './saved_objects';

/** ...inside a route handler: */
async (ctx, req, res) => {
  const core = await ctx.core;
  const savedObjectsClient = core.savedObjects.client;
  try {
    const result = await savedObjectsClient.esql({
      type: [MY_TYPE],
      namespaces: ['default'],
      pipeline: `| KEEP ${MY_TYPE}.title, ${MY_TYPE}.description
        | SORT ${MY_TYPE}.title
        | LIMIT 100`,
    });
    return res.ok({ body: { columns: result.columns, values: result.values } });
  } catch (e) {
    if (isResponseError(e)) {
      log.error(JSON.stringify(e.meta.body, null, 2));
    }
    throw e;
  }
}

To include METADATA fields on the auto-generated FROM clause, use the metadata option:

ts
const result = await savedObjectsClient.esql({
  type: [MY_TYPE],
  namespaces: ['default'],
  metadata: ['_id', '_source'],
  // generates: FROM .kibana METADATA _id, _source | WHERE ...
  pipeline: '| WHERE my_type.title LIKE "test*" | LIMIT 100',
});

See the full example in the Kibana repository at examples/saved_objects.

Safe pipeline construction with ES|QL params

When interpolating user input into ES|QL pipelines, never use string concatenation. Instead, use ES|QL's native parameterization — named params (?paramName) or positional params (?) — to separate code from data at the protocol level.

Use ?paramName placeholders in the pipeline string and pass the values via the params array as { name: value } entries. This is true parameterization — the values are never interpolated into the query string, preventing injection attacks.

ts
import type { estypes } from '@elastic/elasticsearch';

const userInput = req.body.searchTerm;

const result = await savedObjectsClient.esql({
  type: ['my_type'],
  namespaces: ['default'],
  pipeline: '| WHERE my_type.title LIKE ?searchTerm | LIMIT 100',
  // Named params are supported by ES at runtime, but the ES client TypeScript types
  // only define positional params — cast through unknown to bridge the type gap.
  params: [{ searchTerm: userInput }] as unknown as estypes.EsqlESQLParam[],
});

The pipeline sent to Elasticsearch will be | WHERE my_type.title LIKE ?searchTerm | LIMIT 100 — with searchTerm as a separate parameter, never interpolated into the pipeline string.

Positional params ? (alternative)

ES|QL also supports positional ? placeholders. Params are plain values (string, number, boolean, or null) matched by position:

ts
const result = await savedObjectsClient.esql({
  type: ['my_type'],
  namespaces: ['default'],
  pipeline: '| WHERE my_type.title LIKE ? | LIMIT 100',
  params: [userInput],
});

Security model

Execution context — kibana_system user

The ES|QL pipeline executes with the privileges of the kibana_system Elasticsearch user, which has elevated access including manage_enrich and broad index monitoring permissions. This means the pipeline can access resources that the end user may not be authorized to see.

Never inject arbitrary or untrusted user input directly into the pipeline string. If a user can control the full pipeline, they could use commands like ENRICH to join against enrich policies whose source data they would not normally have access to — this is a privilege escalation. Always construct the pipeline server-side and use parameterized values (see Safe pipeline construction) for any user-provided input.

Using ENRICH in your pipeline is perfectly fine when you control the pipeline and are enriching from a policy whose data is appropriate for all users who will see the results.

Index resolution from types

Like search (which passes index: getIndicesForTypes(types) internally), the esql method resolves the correct Elasticsearch indices from the type parameter and auto-generates the FROM clause. You never need to know the index name — it is an implementation detail handled by the saved objects system.

Space and type filtering

When you call esql(), namespace (space) and type filters are automatically injected into the filter parameter of the ES|QL request. The filter restricts results to the specified types and namespaces, so you don't need WHERE type == "..." in your pipeline. This works the same way as the search method:

  1. spacesExtension.getSearchableNamespaces() resolves which namespaces the user can access
  2. securityExtension.authorizeFind() checks RBAC permissions
  3. A namespace bool filter (including type restriction) is constructed and merged with any user-provided filter

If the user is not authorized to access any of the requested namespaces or types, an empty response is returned.

User-provided filters are merged, not replaced

If you provide a filter in the options, it is merged with the security filter using { bool: { must: [securityFilter, yourFilter] } }. Your filter is never used in isolation.

Encrypted attributes

Encrypted saved object attributes are handled differently depending on whether _source is present in the response:

  • With _source (via metadata: ['_id', '_source']): The full document in _source contains all attributes needed for AAD (Additional Authenticated Data) reconstruction. Encrypted attributes are by default stripped from _source, or are decrypted in _source, using the same path as find and search, if registered with the dangerouslyExposeValue option. If decryption fails (e.g., key rotation), all encrypted attributes are stripped from _source.
  • Standalone scalar columns (e.g., connector.secrets): Always replaced with null, regardless of _source decryption. These columns contain raw ciphertext that cannot be used outside the document context.

For example, if a connector type has an encrypted secrets attribute:

  • connector.secrets column → always null
  • _source column → contains the full document with secrets decrypted (or stripped on failure)

Response structure

The esql method returns the raw ES|QL response with columns and values:

json
{
  "columns": [
    { "name": "index-pattern.title", "type": "keyword" },
    { "name": "type", "type": "keyword" }
  ],
  "values": [
    ["logs-*", "index-pattern"],
    ["metrics-*", "index-pattern"]
  ]
}

When to use

  • You need ES|QL-specific operations like STATS, EVAL, ENRICH, DISSECT, or GROK
  • You want tabular results for analytics or reporting
  • You need to compute aggregations across saved object types

When not to use

  • You want structured SavedObject instances with id, attributes, references - use find instead
  • You need Elasticsearch Query DSL features like runtime mappings or aggregation trees - use search instead
  • Simple filtering and pagination - use find instead
  • You need ES|QL source commands like ROW, SHOW, or METRICS - use the raw Elasticsearch client directly