OpenSearch Document Level Security 4 with Wazuh for True Multi-Tenant SOC Isolation

opensearch document level security 4 with wazuh for true multi-tenant soc isolation

Executive Summary — TL;DR

OpenSearch Document Level Security (DLS) is one of the most operationally critical and underutilized features in enterprise SIEM deployments built on Wazuh.

Most organizations configure role-based access at the index level — which is insufficient for multi-tenant architectures where multiple clients, departments, or SOC analysts share the same OpenSearch cluster.

4 Key Insights:

Sponsored
  1. Index-level RBAC alone is not enough — DLS enforces row-level filtering so each user only retrieves documents matching their authorized scope (e.g., agent.id, agent.name, or custom tags).
  2. Wazuh’s OpenSearch Security plugin supports DLS natively via JSON query filters embedded directly in role definitions — no external proxy or filter layer required.
  3. Production DLS implementation requires careful alignment between Wazuh agent metadata, OpenSearch index mappings, and the DLS query filter syntax.
  4. Multi-tenancy in OpenSearch with Wazuh goes beyond tenant namespaces — true isolation demands DLS policies applied per role, per tenant, enforced at query time by the security engine.

What you will gain: A production-ready understanding of DLS architecture, hands-on configuration steps from a real SOC environment, diagrams to visualize the access control flow.

Understanding OpenSearch Document Level Security Architecture

What DLS Is and Why Index-Level RBAC Falls Short

OpenSearch, the open-source fork of Elasticsearch maintained by AWS and the community, ships with a powerful Security plugin that provides authentication, authorization, audit logging, and fine-grained access control.

Role-Based Access Control (RBAC) at the index level grants or restricts access to entire indices.

In a Wazuh deployment, all agent logs land in the same index pattern — typically wazuh-alerts-* and wazuh-archives-*.

Granting a SOC analyst read access to wazuh-alerts-* exposes every alert from every monitored agent — regardless of which client or department owns those agents.

This is architecturally unacceptable in shared SOC environments.

Document Level Security solves this by embedding a query filter inside the role definition itself.

When a user with that role executes any search — whether from Wazuh Dashboard, a Kibana Lens visualization, or a direct API call — OpenSearch intercepts the query and applies the DLS filter as a mandatory condition.

The user never sees documents that don’t match their authorized filter.

This happens transparently, at the search engine level, with no application-side logic required.

DLS Internal Mechanics in OpenSearch Security Plugin

The Security plugin intercepts every search request at the Transport layer before it reaches the shard-level query engine.

The DLS filter is parsed as a standard OpenSearch query DSL object and injected as a bool.filter clause into the user’s actual query.

This means:

  • The merge is performed server-side — the client cannot bypass it.
  • It works with all search APIs: _search, _msearch, _async_search, scroll, and point-in-time.
  • Field Level Security (FLS) can be combined with DLS to also restrict which fields are returned per document.

The role definition in roles.yml (or via REST API) looks like this:

yaml

my_tenant_role:
  cluster_permissions:
    - 'cluster_composite_ops_ro'
  index_permissions:
    - index_patterns:
        - 'wazuh-alerts-*'
      dls: '{"term": {"agent.id": "001"}}'
      allowed_actions:
        - 'read'
        - 'indices:data/read/search'
        - 'indices:data/read/msearch'

The dls value is a raw JSON string representing any valid OpenSearch query DSL.

For multi-tenant deployments, this value is typically parameterized using DLS templating — a feature that allows ${user.name} or custom attribute substitution at query time.

DLS Templating for Dynamic Multi-Tenancy

Static DLS is sufficient for fixed role-to-agent mappings.

But in large SOC environments where the agent roster changes or users are dynamically provisioned, DLS templating is the correct approach.

OpenSearch Security supports substituting user attributes — defined in the backend role, the internal user definition, or fetched from an LDAP/Active Directory attribute — directly into the DLS query at runtime.

Example using extensions user attributes:

yaml

# internal_users.yml
analyst_client_a:
  hash: "..."
  attributes:
    tenant_id: "client_a"

yaml

# roles.yml
role_client_a:
  index_permissions:
    - index_patterns:
        - 'wazuh-alerts-*'
      dls: '{"term": {"agent.labels.tenant": "${attr.internal.tenant_id}"}}'
      allowed_actions:
        - 'read'

At query time, ${attr.internal.tenant_id} is resolved to client_a — the DLS filter becomes {"term": {"agent.labels.tenant": "client_a"}}.

This pattern scales to hundreds of tenants without creating one static role per client.

diagram 1 — dls query interception flow

Implementing DLS in a Production Wazuh OpenSearch Deployment

Prerequisites and Environment Alignment

Before implementing DLS, the following conditions must be validated in your Wazuh environment:

1. Agent metadata must be indexed consistently.

The field you use as a DLS filter key — typically agent.id, agent.name, or a custom label — must exist as a keyword field in the OpenSearch index mapping.

Text fields with standard analyzers will not work correctly with term queries used in DLS filters.

Verify the mapping:

bash

curl -k -u admin:<REDACTED> \
  -X GET "https://localhost:9200/wazuh-alerts-4.x-*/_mapping?pretty" \
  | grep -A3 '"agent"'

Expected output confirming keyword type:

json

"agent" : {
  "properties" : {
    "id" : {
      "type" : "keyword"
    },
    "name" : {
      "type" : "keyword"
    }
  }
}

2. OpenSearch Security plugin must be active.

bash

curl -k -u admin:<REDACTED> \
  https://localhost:9200/_cat/plugins?v | grep security
opensearch-node1 opensearch-security  2.x.x.x

3. The securityadmin.sh tool must be accessible for applying configuration changes if you manage security via flat files.

Creating DLS Roles via REST API

The REST API approach is recommended for production — it avoids needing to restart the security configuration and supports automation via Ansible or Terraform.

Create a tenant-scoped role:

bash

curl -k -u admin:<REDACTED> \
  -X PUT "https://localhost:9200/_plugins/_security/api/roles/role_tenant_alpha" \
  -H 'Content-Type: application/json' \
  -d '{
    "cluster_permissions": ["cluster_composite_ops_ro"],
    "index_permissions": [
      {
        "index_patterns": ["wazuh-alerts-*", "wazuh-archives-*"],
        "dls": "{\"term\": {\"agent.id\": \"005\"}}",
        "allowed_actions": [
          "read",
          "indices:data/read/search",
          "indices:data/read/msearch",
          "indices:data/read/scroll"
        ]
      }
    ],
    "tenant_permissions": [
      {
        "tenant_patterns": ["tenant_alpha"],
        "allowed_actions": ["kibana_all_read"]
      }
    ]
  }'

Expected response:

json

{
  "status": "CREATED",
  "message": "'role_tenant_alpha' created."
}

Map the role to a user or backend role:

bash

curl -k -u admin:<REDACTED> \
  -X PUT "https://localhost:9200/_plugins/_security/api/rolesmapping/role_tenant_alpha" \
  -H 'Content-Type: application/json' \
  -d '{
    "users": ["analyst_alpha"],
    "backend_roles": []
  }'

json

{
  "status": "CREATED",
  "message": "'role_tenant_alpha' created."
}

Validating DLS Enforcement in Production

After applying the role and mapping, validate DLS behavior by running a search as the restricted user.

Test as the tenant analyst:

bash

curl -k -u analyst_alpha:<REDACTED> \
  -X GET "https://localhost:9200/wazuh-alerts-*/_search?pretty" \
  -H 'Content-Type: application/json' \
  -d '{"query": {"match_all": {}}}'

What to verify:

  • hits.total.value should only reflect documents where agent.id == "005".
  • Attempting to add a term filter for a different agent.id in the query body must return zero results — the DLS filter overrides the client-side query.

Real terminal output from our SOC validation (sanitized):

json

{
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1423,
      "relation": "eq"
    },
    "hits": [
      {
        "_index": "wazuh-alerts-4.x-2025.05.18",
        "_source": {
          "agent": {
            "id": "005",
            "name": "[REDACTED]"
          },
          "rule": {
            "level": 7,
            "description": "SSH brute force - Multiple failed logins"
          }
        }
      }
    ]
  }
}

All 1,423 returned documents belong exclusively to agent.id: 005 — DLS enforcement is confirmed.

diagram 2 — wazuh multi-tenant architecture with dls

Operational Challenges and Best Practices for DLS at Scale

Performance Implications of DLS

DLS adds a query-time overhead because every search is rewritten with an additional filter clause before reaching the shards.

In practice, for most Wazuh deployments, this overhead is negligible — term queries on keyword fields are resolved via inverted index lookups, which are O(1) operations.

The critical risk is poorly constructed DLS queries.

Avoid using wildcard, regex, or script queries inside DLS definitions.

These trigger full shard scans and will degrade performance dramatically at scale.

Recommended DLS query patterns (fast):

json

// Single agent
{"term": {"agent.id": "005"}}

// Multiple agents for one tenant
{"terms": {"agent.id": ["005", "006", "007"]}}

// Label-based (requires agent label indexing in Wazuh)
{"term": {"agent.labels.client": "alpha"}}

Patterns to avoid in DLS:

json

// AVOID — triggers full shard scan
{"wildcard": {"agent.name": "client-alpha-*"}}

// AVOID — script queries are blocked by default and expensive
{"script": {"script": "doc['agent.id'].value.startsWith('0')"}}

Combining DLS with Field Level Security and Tenant Namespaces

OpenSearch Security supports layering multiple access control mechanisms simultaneously:

DLS — filters which documents are visible.

FLS — filters which fields within a document are returned.

Tenant namespaces — isolate Dashboards (saved objects, visualizations, index patterns) per tenant.

A complete production role definition combining all three:

bash

curl -k -u admin:<REDACTED> \
  -X PUT "https://localhost:9200/_plugins/_security/api/roles/role_tenant_beta_full" \
  -H 'Content-Type: application/json' \
  -d '{
    "cluster_permissions": ["cluster_composite_ops_ro"],
    "index_permissions": [
      {
        "index_patterns": ["wazuh-alerts-*"],
        "dls": "{\"term\": {\"agent.id\": \"012\"}}",
        "fls": [
          "rule.*",
          "agent.*",
          "timestamp",
          "data.*"
        ],
        "allowed_actions": ["read"]
      }
    ],
    "tenant_permissions": [
      {
        "tenant_patterns": ["tenant_beta"],
        "allowed_actions": ["kibana_all_write"]
      }
    ]
  }'

This role:

  • Restricts documents to agent.id: 012 via DLS.
  • Returns only rule.*, agent.*, timestamp, and data.* fields via FLS — sensitive fields like full_log or internal system fields are excluded.
  • Grants full read-write access to the tenant_beta Dashboard namespace.

Automating DLS Role Provisioning with Python

In environments where tenants are onboarded programmatically, manual role creation via curl is insufficient.

The following Python script automates DLS role creation and role mapping for new Wazuh tenants:

python

import requests
import json
import urllib3

urllib3.disable_warnings()

OPENSEARCH_URL = "https://localhost:9200"
ADMIN_USER = "admin"
ADMIN_PASS = "<REDACTED>"

def create_dls_role(tenant_name: str, agent_ids: list[str]):
    role_name = f"role_{tenant_name}"
    dls_filter = json.dumps({"terms": {"agent.id": agent_ids}})

    role_payload = {
        "cluster_permissions": ["cluster_composite_ops_ro"],
        "index_permissions": [
            {
                "index_patterns": ["wazuh-alerts-*", "wazuh-archives-*"],
                "dls": dls_filter,
                "allowed_actions": ["read"]
            }
        ],
        "tenant_permissions": [
            {
                "tenant_patterns": [tenant_name],
                "allowed_actions": ["kibana_all_read"]
            }
        ]
    }

    response = requests.put(
        f"{OPENSEARCH_URL}/_plugins/_security/api/roles/{role_name}",
        auth=(ADMIN_USER, ADMIN_PASS),
        headers={"Content-Type": "application/json"},
        json=role_payload,
        verify=False
    )
    print(f"[ROLE] {role_name}: {response.status_code} — {response.json()}")
    return role_name

def map_role_to_user(role_name: str, username: str):
    mapping_payload = {
        "users": [username],
        "backend_roles": []
    }

    response = requests.put(
        f"{OPENSEARCH_URL}/_plugins/_security/api/rolesmapping/{role_name}",
        auth=(ADMIN_USER, ADMIN_PASS),
        headers={"Content-Type": "application/json"},
        json=mapping_payload,
        verify=False
    )
    print(f"[MAPPING] {role_name} -> {username}: {response.status_code} — {response.json()}")

# Example usage — onboard a new tenant
role = create_dls_role("client_delta", ["022", "023", "024"])
map_role_to_user(role, "analyst_delta")

Terminal output from onboarding run (sanitized):

[ROLE] role_client_delta: 201 — {'status': 'CREATED', 'message': "'role_client_delta' created."}
[MAPPING] role_client_delta -> analyst_delta: 201 — {'status': 'CREATED', 'message': "'role_client_delta' created."}

This script is production-ready and can be integrated into a Wazuh onboarding pipeline triggered via API gateway or internal ticketing system.

OpenSearch DLS in the Context of AWS OpenSearch Service and Hybrid Deployments

DLS on AWS OpenSearch Service

AWS OpenSearch Service (formerly Amazon Elasticsearch Service) supports Fine-Grained Access Control (FGAC) — AWS’s implementation of the OpenSearch Security plugin DLS/FLS capabilities.

The configuration approach differs slightly from self-managed deployments:

  • DLS roles are managed via the OpenSearch Dashboards Security UI or the REST API — securityadmin.sh is not available.
  • IAM roles can be mapped to OpenSearch internal roles — enabling AWS-native identity integration.
  • Master user credentials are required for initial role provisioning.

For Wazuh deployments targeting AWS OpenSearch Service, the same DLS query syntax applies — but agent data must be forwarded to the AWS-managed cluster via Wazuh’s Filebeat output module.

yaml

# Wazuh Filebeat output targeting AWS OpenSearch Service
output.elasticsearch:
  hosts:
    - "https://[REDACTED-DOMAIN].eu-west-1.es.amazonaws.com:443"
  ssl.certificate_authorities:
    - /etc/ssl/certs/ca-certificates.crt
  username: "wazuh-indexer"
  password: "<REDACTED>"

DLS role definitions on AWS remain identical in syntax — only the administration method changes.

Hybrid SOC Architecture with Self-Managed and Cloud OpenSearch

Large enterprises often operate hybrid SOC architectures — a self-managed Wazuh cluster for on-premise assets, and an AWS OpenSearch Service cluster for cloud workloads.

In this model:

  • On-premise Wazuh agents write to a self-managed OpenSearch cluster — DLS configured via securityadmin.sh or REST API.
  • Cloud workload agents (EC2, ECS, Lambda logs via Wazuh agent or CloudWatch integration) write to AWS OpenSearch Service — DLS configured via Fine-Grained Access Control.
  • Both clusters are exposed to SOC analysts via a unified Wazuh Dashboard pointing to their respective OpenSearch endpoints.

DLS policies must be mirrored across both clusters to maintain consistent tenant isolation.

Automation via the Python provisioning script above — parameterized for both endpoints — is the recommended approach

diagram 3 — dls role provisioning lifecycle. www.solideinfo.com

Advanced FAQ: OpenSearch Document Level Security for IT Architects and SOC Engineers

Q1: Can DLS filters reference multiple fields simultaneously?

Yes — any valid OpenSearch query DSL is accepted inside the dls field.

You can use bool.must to combine conditions:

json

{
  "bool": {
    "must": [
      {"term": {"agent.id": "005"}},
      {"term": {"agent.labels.environment": "production"}}
    ]
  }
}

This restricts the user to documents from agent.id: 005 that are also tagged as production environment.

Q2: Does DLS work with OpenSearch aggregations and Wazuh Dashboard visualizations?

Yes — DLS filters are applied to all search operations, including aggregations.

When a Wazuh Dashboard visualization performs a terms aggregation over the wazuh-alerts-* index, the underlying search request passes through the Security plugin — the DLS filter is injected before the aggregation is computed.

The user will only see aggregated data derived from their authorized document scope.

Q3: How do you handle DLS for Wazuh agents that change teams or clients?

The cleanest approach is label-based DLS rather than agent.id-based DLS.

Assign a label to each Wazuh agent in ossec.conf:

xml

<labels>
  <label key="client">alpha</label>
  <label key="environment">production</label>
</labels>

These labels are indexed under agent.labels.* in OpenSearch.

The DLS filter then becomes:

json

{"term": {"agent.labels.client": "alpha"}}

When an agent changes client assignment, only the label needs updating — the DLS role definition remains unchanged.

Q4: What happens if the DLS query references a field that doesn’t exist in the index mapping?

The DLS filter will still execute — but will return zero documents, since no documents match a field that doesn’t exist.

This silently breaks the user’s visibility without producing an error — which is a dangerous misconfiguration.

Always validate field existence in the mapping before deploying a DLS role in production.

Q5: Is DLS compatible with Wazuh’s threat intelligence and vulnerability modules?

Yes — the wazuh-alerts-* index pattern covers alerts generated by all Wazuh modules, including:

  • Vulnerability Detection (rule.groups: vulnerability-detector)
  • Threat Intelligence / VirusTotal integration
  • CIS Benchmark compliance alerts

DLS filtering by agent.id or agent.labels.* applies uniformly across all these alert types — a tenant analyst sees vulnerability alerts only for their authorized agents.

Q6: What should IT architects consider before deploying DLS in a large-scale Wazuh SOC?

Three critical architectural decisions:

  1. Field consistency — Establish a standard for DLS filter fields (agent.id, agent.name, or agent.labels.*) and enforce it across all Wazuh agents before deploying DLS roles.
  2. Role provisioning automation — Manual DLS role management does not scale. Implement a programmatic provisioning pipeline from day one.
  3. Audit logging — Enable OpenSearch Security audit logging to verify DLS is enforcing correctly and to maintain a compliance audit trail of which users accessed which document scopes.

OpenSearch Document Level Security is not a niche configuration option — it is a fundamental requirement for any Wazuh deployment serving multiple clients, departments, or regulated environments.

The combination of DLS with Field Level Security and tenant namespace isolation gives you a complete, layered access control model that enforces data sovereignty at the document level — directly inside OpenSearch — without requiring application-side filtering logic.

From agent label standardization to Python-automated role provisioning and hybrid cloud alignment, the patterns documented here reflect production-grade SOC engineering.

Whether your cluster is self-managed or deployed on AWS OpenSearch Service, opensearch DLS gives your architecture the data isolation guarantees that enterprise multi-tenancy demands.


Discover more from Solide Info | The Engineer’s Authority on Cyber Defense

Subscribe to get the latest posts sent to your email.

Leave a Reply