Elasticsearch Integration Guide

This guide covers how to connect an Elasticsearch cluster to BurnLedger, configure query templates, and set up read-only credentials.


Connection Configuration

BurnLedger connects to Elasticsearch using the HTTP API with basic authentication.

Configuration fields:

Field Required Description
addresses Yes Array of Elasticsearch node URLs (e.g., ["https://es.example.com:9200"]).
username No HTTP Basic Auth username.
password No HTTP Basic Auth password.
index Yes The index (or index pattern) to query.
ca_cert No Base64-encoded PEM certificate for custom CAs (self-signed clusters).
read_only Conditional Your assertion that the credential is read-only. Required when the cluster has security disabled (the _has_privileges API is then unavailable). Ignored when BurnLedger can introspect privileges. See Read-Only User Setup.

These are the exact JSON keys the connector reads. read_only and ca_cert are snake_case in both SDKs — do not write readOnly or caCert.

TypeScript

const system = await bl.registerSystem({
  name: "user-events",
  connectorType: "elasticsearch",
  connectionConfig: {
    addresses: ["https://es.example.com:9200"],
    username: "burnledger_ro",
    password: process.env.ES_PASSWORD,
    index: "user-events",
  },
  subjectQuery: '{"query": {"term": {"user_email": "$IDENTIFIER"}}}',
});

Python

system = bl.register_system(
    name="user-events",
    connector_type="elasticsearch",
    connection_config={
        "addresses": ["https://es.example.com:9200"],
        "username": "burnledger_ro",
        "password": os.environ["ES_PASSWORD"],
        "index": "user-events",
    },
    subject_query='{"query": {"term": {"user_email": "$IDENTIFIER"}}}',
)

Connection requirements:

Requirement Details
TLS https:// on every address is required. A single http:// entry now refuses the whole config before the connector is built: the client round-robins across the configured addresses and copies the selected one's scheme onto each request, so one plaintext entry means a fraction of real traffic — including the record counts that get signed — is plaintext. An address with no scheme, or any scheme other than http/https, is refused as unclassifiable. For a private CA, provide ca_cert (base64-encoded PEM), which is enforced as the trust root and keeps the config verified. See connector transport security.
Network access Add BurnLedger's egress address to your cluster's allowlist.
Permissions Read-only. BurnLedger refuses to connect if the credentials can create indices. See Read-Only User Setup.
_source Must be enabled on the index. BurnLedger checks this at connection time and returns SOURCE_DISABLED if _source.enabled is false.
Timeout Queries must complete within the plan's query timeout (30 seconds by default); the TLS handshake is capped at 10 seconds.

Query Template Format

For Elasticsearch, the query template is a JSON object that forms the body of a _count or _search request. Use "$IDENTIFIER" as a string value placeholder for the data subject.

Rules:

  • Must be valid JSON. Enforced at registration and again before every query.
  • Must contain at least one "$IDENTIFIER" string value. This is enforced: a template without it is rejected at registration and at query time, because it would match the entire index and could certify a false "0 records" result.
  • "$IDENTIFIER" is replaced with the actual subject value as a string (not interpolated — the JSON tree is walked and string values matching "$IDENTIFIER" are swapped).
  • Do not use script queries. The body is sent verbatim to _count/_search, so a script would execute server-side; BurnLedger does not parse it out. Scope the credential so scripts cannot do damage.

Examples:

// Simple term match
{"query": {"term": {"user_email": "$IDENTIFIER"}}}

// Match on a keyword field
{"query": {"term": {"user_id.keyword": "$IDENTIFIER"}}}

// Bool query across multiple fields
{"query": {"bool": {"should": [
  {"term": {"email": "$IDENTIFIER"}},
  {"term": {"secondary_email": "$IDENTIFIER"}}
]}}}

// Nested field
{"query": {"nested": {
  "path": "contacts",
  "query": {"term": {"contacts.email": "$IDENTIFIER"}}
}}}

How it works during attestation:

  1. BurnLedger parses the JSON template, walks the tree, and replaces "$IDENTIFIER" string values with the actual subject.
  2. For count mode: calls POST /{index}/_count with the query body.
  3. For Merkle mode: calls POST /{index}/_search with search_after pagination, sorting by _doc for stable ordering.
  4. Only _source content is hashed. Metadata (_id, _score, _index) is excluded.

Read-Only User Setup

BurnLedger requires credentials that cannot modify data. It detects write access during connection validation and rejects credentials that have it — without ever writing. It calls _security/user/_has_privileges to ask the cluster whether the user holds index write privileges; if so, construction is refused.

This works automatically whenever the cluster has security enabled. If the cluster has security disabled (the _has_privileges API is unavailable), set "read_only": true in the connector config to assert read-only; otherwise construction is refused (fails closed).

Elasticsearch Security (X-Pack / built-in)

Create a role with read-only permissions:

PUT _security/role/burnledger_readonly
{
  "cluster": ["monitor"],
  "indices": [
    {
      "names": ["user-events", "user-profiles"],
      "privileges": ["read", "view_index_metadata"]
    }
  ]
}

Create the user:

PUT _security/user/burnledger_ro
{
  "password": "your-secure-password",
  "roles": ["burnledger_readonly"],
  "full_name": "BurnLedger Read-Only"
}

Elastic Cloud

In the Elastic Cloud console:

  1. Go to Deployments > Security > Users.
  2. Create a new user.
  3. Assign a custom role with read and view_index_metadata on the target indices only.

OpenSearch

PUT _plugins/_security/api/roles/burnledger_readonly
{
  "cluster_permissions": ["cluster_monitor"],
  "index_permissions": [
    {
      "index_patterns": ["user-events*"],
      "allowed_actions": ["read", "indices_monitor"]
    }
  ]
}

PUT _plugins/_security/api/rolesmapping/burnledger_readonly
{
  "users": ["burnledger_ro"]
}

Verify permissions

# These should succeed
curl -u burnledger_ro:password "https://es.example.com:9200/user-events/_count"
curl -u burnledger_ro:password "https://es.example.com:9200/user-events/_search?size=1"

# These should fail with 403
curl -u burnledger_ro:password -X PUT "https://es.example.com:9200/test-index"
curl -u burnledger_ro:password -X POST "https://es.example.com:9200/user-events/_doc" -H 'Content-Type: application/json' -d '{"test": true}'

Endpoint role

POST /v1/systems/test-connection reports replication.role: no_primary for Elasticsearch, and the reason is worth reading rather than skipping: Elasticsearch 7.0 removed the _primary search preference. A search is answered by whichever copy of each shard the cluster picks, and there is no request BurnLedger could send that would pin it to the primaries. A preference value is a routing hint, not a consistency guarantee, so none is sent — a replica shard that has not caught up returns fewer hits than the primary holds.

What BurnLedger checks instead is the _shards block on every response: a search whose shards did not all report is refused, not counted, because Elasticsearch answers 200 with a reduced count rather than an error. See endpoint role.


Hash Scope

hash_scope is accepted on the system record for every connector, but the Elasticsearch connector does not read it — it only changes behavior for the object/document-store connectors (S3, GCS, Azure Blob, MarkLogic). Setting hashScope: "existence" on an Elasticsearch system does not switch to a metadata-only hash; it has no effect at all.

What actually varies for Elasticsearch is the proof mode of the attestation:

Proof mode Elasticsearch behavior
Count (default) Calls POST /{index}/_count. No document content is read or hashed.
Merkle (opt-in) Calls POST /{index}/_search and hashes the complete _source of every matching document. Fields are sorted lexicographically and nested objects are recursively sorted for deterministic hashing. Metadata (_id, _score, _index) is excluded.
const system = await bl.registerSystem({
  // ...
});

Important: _source must be enabled on the index for Merkle mode. If _source is disabled, BurnLedger returns SOURCE_DISABLED at connection time.


End-to-End Example

TypeScript

import { BurnLedger } from "burnledger";
import fs from "node:fs/promises";

const bl = new BurnLedger({
  apiKey: process.env.BURNLEDGER_API_KEY,
});

const system = await bl.registerSystem({
  name: "user-events",
  connectorType: "elasticsearch",
  connectionConfig: {
    addresses: ["https://es.example.com:9200"],
    username: "burnledger_ro",
    password: process.env.ES_PASSWORD,
    index: "user-events",
  },
  subjectQuery: '{"query": {"term": {"user_email": "$IDENTIFIER"}}}',
});

const attestation = await bl.attest("jane.doe@example.com", {
  systemIds: [system.id],
  proofMode: "merkle",
});

for (const s of attestation.systems) {
  console.log(`Found ${s.recordCount} documents`);
}

// Delete in your application
// await esClient.deleteByQuery({ index: "user-events", body: { query: { term: { user_email: "jane.doe@example.com" } } } });

// The certificate is ISSUED BY verify() once the data is gone -- there is
// no separate "create certificate" step.
const result = await bl.verify(attestation.id, "jane.doe@example.com", { timeout: 60 });

if (result.certificate) {
  await bl.savePdf(result.certificate.id, `es-deletion-cert-${result.certificate.id}.pdf`);
  console.log(`Certificate issued: ${result.certificate.id}`);
} else {
  console.log(`Not certified: status ${result.status}`);
}

Python

import os
from burnledger import BurnLedger

bl = BurnLedger(api_key=os.environ["BURNLEDGER_API_KEY"])

system = bl.register_system(
    name="user-events",
    connector_type="elasticsearch",
    connection_config={
        "addresses": ["https://es.example.com:9200"],
        "username": "burnledger_ro",
        "password": os.environ["ES_PASSWORD"],
        "index": "user-events",
    },
    subject_query='{"query": {"term": {"user_email": "$IDENTIFIER"}}}',
)

attestation = bl.attest(
    "jane.doe@example.com",
    system_ids=[system.id],
    proof_mode="merkle",
)

for s in attestation.systems:
    print(f"Found {s.record_count} documents")

# Delete in your application
# es_client.delete_by_query(index="user-events", body={"query": {"term": {"user_email": "jane.doe@example.com"}}})

# The certificate is ISSUED BY verify() once the data is gone -- there is
# no separate "create certificate" step.
result = bl.verify(attestation.id, "jane.doe@example.com", timeout=60)

if result.certificate:
    bl.save_pdf(result.certificate.id, f"es-deletion-cert-{result.certificate.id}.pdf")
    print(f"Certificate issued: {result.certificate.id}")
else:
    print(f"Not certified: status {result.status.value}")

Troubleshooting

Error Cause Fix
CONNECTION_FAILED with "no such host" Elasticsearch address is unreachable Verify the address URL and port. Check DNS and firewall rules.
CONNECTION_FAILED with status 401 Invalid credentials Check username and password.
WRITE_ACCESS_DETECTED User can create indices Restrict the role to read and view_index_metadata only. See Read-Only User Setup.
SOURCE_DISABLED _source is disabled on the index mapping Re-enable _source or create a new index with _source.enabled: true. BurnLedger cannot hash documents without _source.
BAD_REQUEST with "invalid query template" JSON is malformed or missing $IDENTIFIER Ensure the template is valid JSON with at least one "$IDENTIFIER" string value.
CONNECTION_FAILED on attestation: "connector elasticsearch: transport security plaintext is below the required minimum verified". A health check reports only connection failed — read transport_security on the system At least one entry in addresses is http:// Make every address https://; add ca_cert if the verification record comes from a private CA.
CONNECTION_FAILED on attestation: "connector elasticsearch: transport security could not be classified and is never assumed secure; the required minimum is verified". A health check reports only connection failed — read transport_security on the system An address has no scheme (es.example.com:9200), no host, or an unsupported scheme Write each address as a full URL: https://es.example.com:9200.
CONNECTION_FAILED with "cannot verify the credential is read-only" Cluster security is disabled, so _has_privileges is unavailable Enable cluster security, or set "read_only": true in the connector config to assert read-only.
Slow attestation/verification Large result set in Merkle proof mode Use count proof mode if content-level proof is not required, or narrow the query.
© 2026 ProChatFlow LLC Last updated present → absent → proven