Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Wazuh Indexer Setup plugin — development guide

This document describes how to extend the Wazuh Indexer setup plugin to create new index templates and index management policies (ISM) for OpenSearch. See Architecture for the conceptual overview.


Class diagram

---
title: Wazuh Indexer setup plugin
---
classDiagram
    %% Classes
    class IndexInitializer
    <<interface>> IndexInitializer
    class Index
    <<abstract>> Index
    class IndexStateManagement
    class WazuhIndex
    <<abstract>> WazuhIndex
    class StateIndex
    class StreamIndex
    class ContentIndex

    %% Relations
    IndexInitializer <|-- Index : implements
    Index <|-- IndexStateManagement
    Index <|-- WazuhIndex
    WazuhIndex <|-- StateIndex
    WazuhIndex <|-- StreamIndex
    WazuhIndex <|-- ContentIndex

    %% Schemas
    class IndexInitializer {
        +createIndex(String index) void
        +createTemplate(String template) void
    }
    class Index {
        Client client
        ClusterService clusterService
        IndexUtils utils
        String index
        String template
        +Index(String index, String template)
        +setClient(Client client) IndexInitializer
        +setClusterService(ClusterService clusterService) IndexInitializer
        +setIndexUtils(IndexUtils utils) IndexInitializer
        +indexExists(String indexName) bool
        +initialize() void
        +createIndex(String index) void
        +createTemplate(String template) void
    }
    class IndexStateManagement {
        -List~String~ policies
        +initialize() void
        -createPolicies() void
        -indexPolicy(String policy) void
    }
    class WazuhIndex {
    }
    class StreamIndex {
        -String alias
        +StreamIndex(String index, String template, String alias)
        +createIndex(String index)
    }
    class StateIndex {
    }
    class ContentIndex {
        +String SUFFIX_A
        +ContentIndex(String index, String template)
        +getPhysicalName() String
        +createIndex(String index)
        +createTemplate(String template)
        -deleteSquatterIndex(String alias) void
        -ensureAlias(String alias, String physicalName) void
    }

The SetupPlugin class holds the list of indices to create. The logic for the creation of the index templates and the indices is encapsulated in the Index abstract class. Each subclass can override this logic if necessary. The SetupPlugin::onNodeStarted() method invokes the Index::initialize() method, effectively creating every index in the list. The plugin implements the ClusterPlugin interface to hook into this method.

Sequence diagram

Note Calls to Client are asynchronous.

sequenceDiagram
    actor Node
    participant SetupPlugin
    participant Index
    participant Client
    Node->>SetupPlugin: plugin.onNodeStarted()
    activate SetupPlugin
    Note over Node,SetupPlugin: Invoked on Node::start()

    activate Index
    loop i..n indices
        SetupPlugin->>Index: i.initialize()


        Index-)Client: createTemplate(i)
        Client--)Index: response

        Index-)Client: indexExists(i)
        Client--)Index: response
        alt index i does not exist
            Index-)Client: createIndex(i)
            Client--)Index: response
        end
    end

    deactivate Index
    deactivate SetupPlugin

JavaDoc

The plugin is documented using JavaDoc. You can compile the documentation using the Gradle task for that purpose. The generated JavaDoc is in the build/docs folder.

./gradlew javadoc

Creating a new index

1. Add a new index template

Create a new JSON file in the directory: /plugins/setup/src/main/resources

Follow the existing structure and naming convention. Example:

{
  "index_patterns": ["<pattern>"],
  "mappings": {
    "date_detection": false,
    "dynamic": "strict",
    "properties": {
      <custom mappings and fields>
    }
  },
  "order": 1,
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    }
  }
}

2. Register the index in the code

Edit the constructor of the SetupPlugin class located at: /plugins/setup/src/main/java/com/wazuh/setup/SetupPlugin.java

Add the template and index entry to the indices map. There are three kinds of indices:

  • Stream index. Stream indices contain time-based events of any kind (alerts, statistics, logs…). These are created as Data Streams.
  • Stateful index. Stateful indices represent the most recent information of a subject (active vulnerabilities, installed packages, open ports, …). These indices are different from Stream indices as they do not contain timestamps. The information is not based on time, as they always represent the most recent state.
  • Content index. Content indices hold the CTI catalog content the Content Manager downloads. They are not addressed by their own name: see Threat intel content indices below.
/**
* Main class of the Indexer Setup plugin. This plugin is responsible for the creation of the index
* templates and indices required by Wazuh to work properly.
*/
public class SetupPlugin extends Plugin implements ClusterPlugin {

  // ...

  // Stream indices
  this.indices.add(new StreamIndex("my-stream-index", "templates/streams/my-index-template-1"));
  // State indices
  this.indices.add(new StateIndex("my-state-index", "templates/states/my-index-template-2"));
  // Content indices
  this.indices.add(new ContentIndex("my-content-index", "templates/content/my-index-template-3"));

  //...
}

Verifying template and index creation After building the plugin and deploying the Wazuh Indexer with it, you can verify the index templates and indices using the following commands:

curl -X GET <indexer-IP>:9200/_index_template/
curl -X GET <indexer-IP>:9200/_cat/indices?v

Alternatively, use the Developer Tools console from the Wazuh Dashboard, or your browser.

Creating a new ISM (Index State Management) policy

1. Add rollover alias and policy ID to the index template

Edit the existing index template JSON file and add the following settings:

"plugins.index_state_management.rollover_alias": "<index-name>",
"plugins.index_state_management.policy_id": "<index-name>-policy"

2. Define the ISM policy

Refer to the OpenSearch ISM Policies documentation for more details.

Here is an example ISM policy:

{
  "policy": {
    "policy_id": "<index-name>-policy",
    "description": "<policy-description>",
    "last_updated_time": <unix-timestamp-in-milliseconds>,
    "schema_version": 1,
    "default_state": "hot",
    "states": [
      {
        "name": "hot",
        "actions": [
          {
            "retry": {
              "count": 3,
              "backoff": "exponential",
              "delay": "1m"
            },
            "rollover": {
              "min_doc_count": 200000000,
              "min_primary_shard_size": "20gb"
            }
          }
        ],
        "transitions": [
          {
            "state_name": "delete",
            "conditions": {
              "min_index_age": "<retention-time>"
            }
          }
        ]
      },
      {
        "name": "delete",
        "actions": [
          {
            "retry": {
              "count": 3,
              "backoff": "exponential",
              "delay": "1m"
            },
            "delete": {}
          }
        ],
        "transitions": []
      }
    ],
    "ism_template": [
      {
        "index_patterns": [
          "wazuh-<pattern>-*"
        ],
        "priority": <priority-int>
      }
    ]
  }
}

3. Register the ISM policy in the plugin code

Edit the IndexStateManagement class located at: /plugins/setup/src/main/java/com/wazuh/setup/index/IndexStateManagement.java

Register the new policy constant and add it in the constructor:

// ISM policy name constant (filename without .json extension)
static final String MY_POLICY = "my-policy-filename";

...

/**
 * Constructor
 *
 * @param index    Index name
 * @param template Index template name
 */
public IndexStateManagement(String index, String template) {
    super(index, template);
    this.policies = new ArrayList<>();

    // Register the ISM policy to be created
    this.policies.add(MY_POLICY);
}

Additional notes

Always follow existing naming conventions to maintain consistency.

Use epoch timestamps (in milliseconds) for last_updated_time fields. Update the policy’s one whenever you change the policy, and keep it in the past: IndexStateManagement.indexPolicy() copies it into every ism_template entry before indexing the policy, and ISM applies a template only to indices created after that timestamp. A template without it is stamped with the current time on every read, so it matches no index at all.

ISM policies and templates must be properly deployed before the indices are created.

IndexStateManagement.initialize() runs on every cluster-manager election, and indexPolicy() writes each policy with a plain IndexRequest keyed by policy id, so the six shipped policies are overwritten on every restart. Any edit an operator makes to one of them through the ISM API is reverted on the next boot. This is why Retention tells operators to create a policy under their own id rather than editing a shipped one.


Event stream templates

Overview

Event and findings data streams are both category-based: 8 event categories share a single base template (templates/streams/events.json), and the 8 corresponding findings categories share their own base template (templates/streams/findings.json). At deployment time, StreamIndex.createTemplate() generates one index template per category from the applicable base, overriding exactly two fields:

  • index_patterns — always set to <category-index-name>*.
  • settings["plugins.index_state_management.rollover_alias"] — set to the category’s index name, but only if that key already exists in the base template’s settings.

Every other part of the generated template — most importantly mappings — is copied through unchanged. This means all category templates within a stream family (all 8 event categories, or all 8 findings categories) have identical mappings; only the index pattern and rollover alias differ.

  • Source of truth: Only events.json and findings.json exist in the repository; no per-category template files exist.
  • At runtime: One index template is created for each category (e.g., wazuh-events-v5-cloud-services-template, wazuh-events-v5-security-template, etc.), and likewise for each findings category.

The StreamIndex class handles this: when constructed with only an index name (no explicit template path), it defaults to templates/streams/events and rewrites index_patterns and (conditionally) rollover_alias to match the specific index. Findings categories are registered the same way but with the two-arg constructor pointing at templates/streams/findings.

How it works

// Single-arg constructor defaults to the shared events template
new StreamIndex("wazuh-events-v5-cloud-services");
// Equivalent to:
new StreamIndex("wazuh-events-v5-cloud-services", "templates/streams/events")

During createTemplate(), the plugin:

  1. Reads events.json from the classpath
  2. Overrides index_patterns to ["wazuh-events-v5-cloud-services*"]
  3. If rollover_alias is present in the base template’s settings, overrides it to "wazuh-events-v5-cloud-services"
  4. Creates the composable index template in OpenSearch, with mappings copied through unchanged

Verifying deployed templates

To list all event templates in a running cluster:

GET /_index_template/wazuh-events-*

Likewise, to list all findings templates:

GET /_index_template/wazuh-findings-*

Specialized stream templates

Some data streams use their own dedicated templates instead of the shared events.json:

Data StreamTemplateNotes
wazuh-events-raw-v5templates/streams/raw.jsonStores original unprocessed events
wazuh-active-responsestemplates/streams/active-responses.jsonActive Response execution requests
wazuh-ai-assistant-sessionstemplates/streams/ai-assistant-sessions.jsonAI assistant conversation history

These are registered with the two-arg constructor:

new StreamIndex("wazuh-events-raw-v5", "templates/streams/raw");
new StreamIndex("wazuh-active-responses", "templates/streams/active-responses");
new StreamIndex("wazuh-ai-assistant-sessions", "templates/streams/ai-assistant-sessions");

Events data stream ISM policy (stream-events-policy)

Overview

The stream-events-policy manages all wazuh-events-v5-* data streams. It combines rollover (based on shard size or document count) with deletion of rolled-over indices once they reach a minimum age. See Retention for the effective retention this yields.

Policy details

  • Policy Name: stream-events-policy
  • Location: plugins/setup/src/main/resources/policies/stream-events-policy.json
  • Index Pattern: wazuh-events-v5-*
  • Deletion age (floor): 1 hour
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 1 hour
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Findings data stream ISM policy (stream-findings-policy)

Overview

The stream-findings-policy manages all wazuh-findings-v5-* data streams. It combines rollover with deletion of rolled-over indices once they reach 90 days of age, to maintain detection findings for compliance and investigation purposes. See Retention.

Policy details

  • Policy Name: stream-findings-policy
  • Location: plugins/setup/src/main/resources/policies/stream-findings-policy.json
  • Index Pattern: wazuh-findings-v5-*
  • Deletion age (floor): 90 days
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 90 days
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Raw events data stream ISM policy (stream-raw-events-policy)

Overview

The stream-raw-events-policy manages the wazuh-events-raw-v5 data stream. Rolled-over indices are deleted once they reach 10 minutes of age. Note that the write index only rolls over on volume, so raw events are not bounded in time — see Retention.

Policy details

  • Policy Name: stream-raw-events-policy
  • Location: plugins/setup/src/main/resources/policies/stream-raw-events-policy.json
  • Index Pattern: wazuh-events-raw-v5*
  • Deletion age (floor): 10 minutes
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 10 minutes
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Active responses data stream (wazuh-active-responses)

Overview

The wazuh-active-responses data stream stores Active Response execution requests generated when monitor triggers match their conditions. This is part of the Active Response 5.0 integration with Wazuh XDR, using the Indexer Alerting and Notifications plugins as the foundation.

Purpose

  • Active Response Pipeline: Structured and auditable execution pipeline for Active Response actions
  • Manager Retrieval: The Wazuh manager retrieves documents from this index to distribute and execute Active Responses on agents
  • Event Correlation: Each document references the source event (document ID and index) that triggered the response

Data stream configuration

Index template

  • Location: plugins/setup/src/main/resources/templates/streams/active-responses.json
  • Index Pattern: wazuh-active-responses*
  • Rollover Alias: wazuh-active-responses
  • Priority: 1

Fields included (WCS-compatible)

  • @timestamp: When the document was inserted into the wazuh-active-responses index (indexing time)
  • event.doc_id: Document ID of the matched alert that triggered the active response
  • event.index: Source index of the matched alert
  • wazuh.active_response.name: Name of the active response configured in the channel
  • wazuh.active_response.executable: Executable configured in the active response channel
  • wazuh.active_response.extra_arguments: Arguments configured in the channel
  • wazuh.active_response.location: Where to execute (local, defined-agent, all)
  • wazuh.active_response.agent_id: Agent configured in the channel
  • wazuh.active_response.type: Response type (stateless, stateful)
  • wazuh.active_response.stateful_timeout: Seconds configured in the channel (for stateful)
  • wazuh.agent.*: Agent metadata
  • wazuh.cluster.*: Cluster information
  • wazuh.space.name: Wazuh space/tenant information

ISM policy

Policy details

  • Policy Name: stream-active-responses-policy
  • Location: plugins/setup/src/main/resources/policies/stream-active-responses-policy.json
  • Deletion age (floor): 3 days
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Configuration

The data stream is created automatically during plugin initialization. Ensure:

  1. The template file active-responses.json exists in templates/streams/
  2. The ISM policy file stream-active-responses-policy.json exists in policies/
  3. Both are registered in SetupPlugin.java and IndexStateManagement.java

Testing

Integration tests for the active responses data stream are located at: plugins/setup/src/test/java/com/wazuh/setup/ActiveResponsesIT.java


Metrics data stream ISM policy (stream-metrics-policy)

Overview

The stream-metrics-policy manages all wazuh-metrics-* data streams (wazuh-metrics-agents, wazuh-metrics-comms-v4, wazuh-metrics-normalization). Rolled-over indices are deleted once they reach 30 days of age. See Retention.

Policy details

  • Policy Name: stream-metrics-policy
  • Location: plugins/setup/src/main/resources/policies/stream-metrics-policy.json
  • Index Pattern: wazuh-metrics-*
  • Deletion age (floor): 30 days
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 30 days
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Threat intel content indices

Overview

The wazuh-threatintel-* indices hold the CTI catalog content the Content Manager downloads: IoCs, CVEs and the Engine ruleset. The setup plugin provisions all of them.

AliasConcrete indexTemplate
wazuh-threatintel-enrichmentswazuh-threatintel-enrichments-atemplates/content/ioc.json
wazuh-threatintel-filterswazuh-threatintel-filters-atemplates/content/filters.json
wazuh-threatintel-decoderswazuh-threatintel-decoders-atemplates/content/decoders.json
wazuh-threatintel-ruleswazuh-threatintel-rules-atemplates/content/rules.json
wazuh-threatintel-kvdbswazuh-threatintel-kvdbs-atemplates/content/kvdbs.json
wazuh-threatintel-integrationswazuh-threatintel-integrations-atemplates/content/integrations.json
wazuh-threatintel-policieswazuh-threatintel-policies-atemplates/content/policies.json
.wazuh-threatintel-vulnerabilities.wazuh-threatintel-vulnerabilities-atemplates/content/vulnerabilities.json

Why the alias

A content index is never addressed by its own name. ContentIndex creates the concrete index as <name>-a and points a write alias named <name> at it. The Content Manager needs that indirection: when the content source changes (a plan upgrade or downgrade), it rebuilds the whole catalog into a hidden <name>-b shadow index and swaps the alias in a single atomic action, so readers never see a partially populated index. The two slots alternate on each swap.

ContentIndex.createTemplate() therefore rewrites the template’s index_patterns to <name>*, whatever the shipped JSON declares. That covers the live slot, the shadow slot and — as a last line of defence — the alias name itself.

Who creates them

This plugin does, and only this plugin. The Content Manager no longer provisions these indices: it waits for the .wazuh-setup-status readiness marker, and if setup has not marked itself ready it aborts its own initialization instead of creating anything. On a deployment without this plugin the threat-intel indices are therefore never created and the custom-ruleset endpoints do not work. Previously the Content Manager created them from its own copies, and without that wait it won the race by a wide margin — it created six of the eight about 16 seconds before this plugin reached its threat-intel block, so they were created before their index templates were installed and did not pick them up. That race is the bug this change removes.

Downloading CTI content requires two things to be true, and the Content Manager checks both before any write reaches these indices:

  1. .wazuh-setup-status reports status: ready, which this plugin writes only after every index it owns exists.
  2. The target indices of that consumer exist.

If either check fails the consumer skips its pass and logs at ERROR; it creates nothing. That is what keeps a mis-provisioned index from becoming a squatted one: a write through the alias name would otherwise auto-create a concrete index there with dynamic mappings. See issues #1476 and #1481, where the second of those took out vulnerability detection and package inventory because the global-map documents could not be indexed into an inferred mapping.

To confirm on a running cluster, read the templates field of the creation entries in the log:

grep 'creating index' /var/log/wazuh-indexer/wazuh-cluster.log | grep threatintel

Every wazuh-threatintel-*-a line must name its own <name>-template. An empty templates [] means something created the index before the template existed.

Recovering an index that squats the alias name

If a write reaches the alias name before anything creates the alias, OpenSearch auto-creates a concrete index called <name> with dynamic mappings. Every keyword the schema declares becomes a text field, which has no doc_values and so cannot be aggregated or sorted on, and the index occupies the name the alias needs. ContentIndex.createIndex() deletes such an index before creating <name>-a. The Content Manager then finds the index empty with a non-zero local offset and resets the offset, so the content is downloaded again on the same synchronization pass.

Where the mappings live

The mappings exist in two places on purpose. This plugin ships them as index templates under plugins/setup/src/main/resources/templates/content/, and the Content Manager keeps its own copies under plugins/content-manager/src/main/resources/mappings/cti-*-mappings.json, which it needs to create the <name>-b shadow indices during a blue/green content swap, without setup intervention. The verifyContentTemplates Gradle task, wired into check, fails the build if the two sets diverge.

All eight templates/content/*.json are generated by the WCS generator from the modules under wcs/content/, and the Content Manager copies are derived from them by sync_content_mappings.py; editing either set by hand is overwritten on the next generator run. templates/cve.json is not the CVE content template: it is a WCS artifact for the unrelated wazuh-cve* pattern and is intentionally not registered.

Testing

./gradlew :wazuh-indexer-setup:integTest --tests '*ThreatIntelIndicesIT*'

Against a deployed cluster, the alias must resolve to the -a index and its fields must be aggregatable:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD 'https://localhost:9200/_alias/wazuh-threatintel-*'
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD \
  'https://localhost:9200/wazuh-threatintel-enrichments/_field_caps?fields=document.type'

The indices array in the _field_caps response names what the alias resolved to. It must read ["wazuh-threatintel-enrichments-a"] with "aggregatable": true.


AI assistant indices

Overview

The AI assistant stores its conversation history in the wazuh-ai-assistant-sessions data stream (a StreamIndex). Its providers configuration, assistant-wide settings and field policy live in the hidden .wazuh-internal-state index reached only through the setup plugin’s administrative AI assistant API, described below. Both indices use strict mappings.

Sessions data stream (wazuh-ai-assistant-sessions)

Index template

  • Location: plugins/setup/src/main/resources/templates/streams/ai-assistant-sessions.json
  • Index Pattern: wazuh-ai-assistant-sessions*
  • Rollover Alias: wazuh-ai-assistant-sessions
  • Priority: 1

Fields

  • @timestamp: Indexing time of the document (data stream timestamp field, required)
  • user: Username the conversation belongs to. Used as the Document Level Security discriminator
  • title: Conversation title, usually the first user message
  • created_at / updated_at: Conversation creation and last update times
  • messages: The conversation turns (e.g. created_at, role, content, plus whatever else a given AI provider returns). Mapped as {"type": "object", "enabled": false}: stored in _source and returned as-is on _search/_get, but not parsed into the mapping at all — no sub-fields, no indexing, no strict-mapping enforcement inside it. This is deliberate: the shape of a message varies by provider, so there is no fixed schema to declare, and the assistant never queries into messages — it only reads whole documents back to reconstruct a conversation in the UI.

Access control

Access is granted by the wazuh_ai_assistant role, defined in the wazuh-indexer repository and mapped to every authenticated user. Reads are filtered with DLS parameter substitution ({"term": {"user": "${user.name}"}}), so a user only retrieves their own conversations. The restriction also applies to users holding a role that grants read on the * index pattern.

Reads and writes are scoped differently on purpose. DLS is a read-path filter and cannot scope a write: no role syntax can express “this document’s user must equal your name”. An index-level write grant on this data stream would therefore be unscoped, and every authenticated user holds this role.

So the role grants no index-level write. Sessions are still read directly against the data stream, under the per-owner DLS, but every write goes through the session API below, gated by the plugin:wazuh/ai_assistant/session/write cluster permission, which derives user from the authenticated principal. That is also what the read filter depends on: user is only meaningful to filter on because the server, not the client, decides it.

Session write API

EndpointMethodCluster permissionBacked by
/_plugins/_setup/ai_assistant/sessionsPOSTplugin:wazuh/ai_assistant/session/writePutAiAssistantSessionAction / TransportPutAiAssistantSessionAction, Operation.CREATE
/_plugins/_setup/ai_assistant/sessions/{id}PUTsamesame action, Operation.UPDATE
/_plugins/_setup/ai_assistant/sessions/{id}PATCHsamesame action, Operation.RENAME
/_plugins/_setup/ai_assistant/sessions/{id}DELETEsamesame action, Operation.DELETE

There is deliberately no read endpoint and no session/read permission. Listing sessions and reading a transcript are plain DLS-scoped searches, and OpenSearch already provides search, sorting, pagination, track_total_hits and source filtering; putting an endpoint in front of that would be reimplementing the search API for nothing. The consequence to accept is that the index mapping stays part of the Dashboard’s contract.

TransportPutAiAssistantSessionAction resolves the caller from the _opendistro_security_user_info thread-context transient (com.wazuh.setup.utils.AuthenticatedUser) before stashing the context, stamps it onto user, and only then calls AiAssistantSessionsIndex with the plugin’s own privileges. Every lookup behind PUT/PATCH/DELETE filters on that same resolved owner, so a session belonging to somebody else simply is not found — reported as 404, never 403, since a 403 would confirm that another user’s session id exists. There is no impersonation and no administrative override, not even for all_access, which preserves the DLS behaviour that already applied to admin.

Three data-stream mechanics shape the implementation, all of them platform constraints rather than choices:

  • A get-by-id is unusable. The backing index rolls over daily and the get API targets exactly one concrete index. findHit searches the stream instead, which fans out across every backing index and reports the one holding the document (hit.getIndex()), together with the seq_no/primary_term pair the following write needs — one round trip for all three facts.
  • Update and delete must target the backing index, never the stream name, which accepts appends only. And they must be a full index replace: the partial _update API is refused outright wherever DLS applies to the role (security_exception: Update is not supported when FLS or DLS or Fieldmasking is activated).
  • Optimistic concurrency is mandatory. A backing index rejects an unconditional write (illegal_argument_exception: index request with op_type=index and no if_primary_term and if_seq_no set targeting backing indices is disallowed), so every write carries a pair — the caller’s expected_version when it sent a decodable one, otherwise the pair the request just read. The version the API returns is that pair encoded as "<seq_no>:<primary_term>", opaque to clients. A genuine mismatch is a 409, never retried: retrying with a freshly read pair is exactly the silent overwrite expected_version exists to prevent.

Request bodies are capped at 5 MiB on every write route, checked against the raw payload before parsing so an oversized body never becomes a map in heap. MAX_MESSAGES (1000) bounds the number of turns but not their size, and the fallback ceiling would otherwise be http.max_content_length (100MB), which would leave the 500-session per-owner cap meaningless as a storage bound.

Two smaller rules worth knowing: PATCH deliberately does not re-stamp updated_at (a rename is not session activity, and bumping it would reorder a list sorted by last activity), and PUT’s title is optional (a chat client auto-saves every turn, and resending a recomputed title would silently revert a rename the user had just made). Every write uses RefreshPolicy.WAIT_UNTIL, because every read here is a search and a search only sees a write after the shard refreshes.

With the security plugin absent — the integTest cluster — there is no transient and the owner resolves to the _shared sentinel, treated as a real owner rather than as a bypass, so the tests exercise the whole stamp-and-scope path. See AiAssistantSessionsIT and TransportPutAiAssistantSessionActionTests.

Settings, field policy and providers (.wazuh-internal-state)

.wazuh-internal-state is created and owned by the Content Manager plugin (CredentialsIndex), not by setup — setup only reads and writes it through the administrative AI assistant API described below. Its mapping is generated by the wcs/internal-state WCS module and lands at plugins/content-manager/src/main/resources/mappings/internal-state-mapping.json; it is dynamic: strict.

The index holds several kinds of documents under one mapping, avoiding a separate index for what would otherwise be a handful of settings fields.

  • One document per configured AI provider, id an arbitrary UUID: name, type, base_url, model, api_key, is_default, updated_at. listProviders() caps the result at AiAssistantSettingsAdminIndex.MAX_PROVIDERS = 500.
  • A single reserved-id document (id "wazuh-ai-assistant-settings") holding the assistant-wide settings and the field anonymization policy, under field_policy. Its privacy_default_per_provider is keyed by provider document id, so it is mapped as flat_object: the keys are UUIDs minted at runtime and a plain object would reject every one of them under this index’s dynamic: strict mapping.
  • A single reserved-id document ("credentials"), owned entirely by Content Manager. The administrative AI assistant API never reads or returns this document.

Example documents:

// Provider document
{ "name": "test-ai", "type": "anthropic", "base_url": "https://api.anthropic.com", "model": "claude-opus-4-6", "api_key": "enc:v1:SC/RyOIBkdm+kGl", "is_default": true, "updated_at": "2026-08-03T09:54:52.193Z" }

// Settings + field policy document (reserved id "wazuh-ai-assistant-settings")
{
  "privacy_default_on": false,
  "privacy_default_per_provider": { "3f6b5f7e-6f2d-4a4f-9c1e-2f6d0a1b2c3d": true },
  "user_can_override": true,
  "field_policy": [
    { "field": "wazuh.agent.name", "action": "anonymize", "kind": "HOST" },
    { "field": "wazuh.agent.host.ip", "action": "anonymize", "kind": "IP" },
    { "field": "wazuh.agent.id", "action": "allow" }
  ]
}

Administrative AI assistant API

.wazuh-internal-state is registered as an OpenSearch Security system index, so no role’s index permissions can reach its documents. Every query to this index goes through the administrative API below:

| Endpoint | Method | Cluster permission | Backed by |

EndpointMethodCluster permissionBacked by
/_plugins/_setup/ai_assistant/settingsGETplugin:wazuh/ai_assistant/settings/readGetAiAssistantSettingsAction / TransportGetAiAssistantSettingsAction, Operation.SETTINGS
/_plugins/_setup/ai_assistant/settingsPUTplugin:wazuh/ai_assistant/settings/writePutAiAssistantSettingsAction / TransportPutAiAssistantSettingsAction, Operation.SETTINGS
/_plugins/_setup/ai_assistant/providersGETplugin:wazuh/ai_assistant/settings/readsame Get* action, Operation.LIST_PROVIDERS — AiAssistantSettingsAdminIndex.listProviders()
/_plugins/_setup/ai_assistant/providersPOSTplugin:wazuh/ai_assistant/settings/writesame Put* action, Operation.PUT_PROVIDER; body must carry the UUID id to create with
/_plugins/_setup/ai_assistant/providers/{id}PUT, DELETEplugin:wazuh/ai_assistant/settings/writesame, Operation.PUT_PROVIDER / Operation.DELETE_PROVIDER

GET /ai_assistant/settings returns the settings document’s source as-is, with its fields flat at the root. Providers are a separate resource, listed via GET /ai_assistant/providers, which excludes the two reserved document ids ("wazuh-ai-assistant-settings" and "credentials").

PUT /ai_assistant/settings always replaces the whole document: the caller sends the complete set of settings fields and the complete field_policy array on every write, not a partial diff

POST /ai_assistant/providers’s body must include an id field, since other integrations depend on the document ending up with the exact id they sent: it must be a UUID, rejected with 400 when missing or malformed

DELETE /ai_assistant/providers/{id} returns 404 when no provider exists with that id, including on a repeated delete.

PUT/DELETE /ai_assistant/providers/{id} both reject the reserved document ids (wazuh-ai-assistant-settings, credentials) with 400, checked in TransportPutAiAssistantSettingsAction before either operation reaches the index.

GET /ai_assistant/providers returns {"providers": [{..., "_id": "..."}]}, each entry the provider document’s source flattened with its _id, assembled by AiAssistantSettingsAdminIndex.listProviders().

ISM policy (ai-assistant-sessions-policy)

Policy details

  • Policy Name: ai-assistant-sessions-policy
  • Location: plugins/setup/src/main/resources/policies/ai-assistant-sessions-policy.json
  • Index Patterns: .ds-wazuh-ai-assistant-sessions-*, wazuh-ai-assistant-sessions*
  • Deletion age (floor): 7 days
  • Rollover Conditions: index age of 1 day (daily rotation), or 20 GB primary shard size / 200,000,000 documents, whichever comes first
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when the index is 1 day old, or reaches 20 GB / 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 7 days
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Testing

Integration tests for the AI assistant indices are located at: plugins/setup/src/test/java/com/wazuh/setup/AIAssistantIndicesIT.java

Integration tests for the administrative AI assistant API are located at: plugins/setup/src/test/java/com/wazuh/setup/AiAssistantSettingsAdminIT.java