Wazuh has no tenant object. What it has are agent groups, indexer security roles, role mappings, dashboard tenants and a manager API permission model scoped to groups, and multi-tenancy is what you get when those five are wired together consistently. The tenant key is an agent group, chosen before the first agent enrols, because it is enforced at enrolment and lands on every document as wazuh.agent.groups. Everything else in this lesson hangs off that one decision.
What you need
- A working deployment, ideally the multi-node shape from Wazuh cluster and distributed architecture, sized with the measurements from performance tuning.
- Administrative access to the indexer Security application and to the manager API.
- A naming convention you are willing to live with for years. Renaming a tenant later means recreating a group, moving every agent, rewriting a security role and rebuilding saved objects.
- Familiarity with agent groups and centralised configuration, which this lesson assumes rather than repeats.
Choose the tenant key before the first agent enrols
Two candidates carry tenant identity onto documents: the agent group and agent labels.
Agent groups are the stronger choice. The group named in <enrollment><groups> has to exist on the manager before the agent enrols, and the manager rejects the enrolment if it does not, which makes the tenant key a server-enforced value rather than a client assertion. The 4.x to 5.x rule migration guide lists agent groups mapping to wazuh.agent.groups on documents, so the value travels with the data.
Agent labels are more flexible and weaker. They are set on the endpoint, so an endpoint can claim any tenant it likes, and the beta5 agent configuration reference does not list a labels section at all. The compiled 5.0 field mapping puts them at wazuh.agent.labels.*, but that path is not confirmed in the beta pages themselves.
The design I would take into production:
| Concern | Carried by | Why |
|---|---|---|
| Tenant identity | Agent group, one per tenant | Enforced at enrolment, cannot be self-asserted |
| Role, environment, site | Additional groups or labels | Changes often, no security decision depends on it |
| Anything a security role queries | Groups only | A label is a claim; a group is a fact the manager checked |
Naming
Use a short, lowercase, stable identifier. Group names allow letters, numbers, dots, underscores and hyphens. Prefix consistently so tenant groups never collide with operational ones: t-acmeretail for the tenant root, t-acmeretail-servers for a role group inside it, ops-collectors for something that belongs to nobody. Read the hyphen warning in the next section before settling on separators.
Fence the data with an indexer role
The Wazuh indexer ships the OpenSearch Security plugin, whose roles carry cluster, index and tenant permissions. The documented stateful-read role shows the shape:
stateful-read:
reserved: true
cluster_permissions: []
index_permissions:
- index_patterns:
- "wazuh-states-*"
allowed_actions:
- "read"
tenant_permissions: []
static: true
A tenant role is the same structure plus a dls query on each index pattern. Document-level security determines which documents a role can retrieve during read operations, using OpenSearch query DSL, supplied as a string:
PUT _plugins/_security/api/roles/tenant_acmeretail_read
{
"cluster_permissions": [
"cluster_composite_ops_ro",
"indices:data/read/scroll/clear"
],
"index_permissions": [
{
"index_patterns": [
"wazuh-findings-v5-*",
"wazuh-events-v5-*",
"wazuh-states-*"
],
"dls": "{\"term\": {\"wazuh.agent.groups\": \"t-acmeretail\"}}",
"allowed_actions": ["read"]
}
]
}
Five properties of document-level security decide whether this holds:
- It filters reads, not writes. If a role has permission to index, update or delete, it can still modify or remove documents the query hides. A tenant role gets
readand nothing else. - Queries from multiple roles combine with OR. A second role widens a tenant. Worse, when a role using document-level security is combined with one that does not, the documented behaviour is that results are filtered to the first role’s query, and
plugins.security.dfm_empty_overrides_allchanges that cluster-wide. Decide that once, set it explicitly, and never map a tenant user to a general read role. - Text analysis can break a term match. The standard analyser splits text fields on Unicode word boundaries, so a hyphenated value is tokenised into parts and a
matchonUser-1can also matchUser-2. The documented remedies are a custom analyser or mapping the field askeyword. Tenant identifiers with hyphens are the normal convention, which makes this the most likely way a correct-looking role leaks. Confirm the field is a keyword, and useterm, notmatch. - There is a size limit. The maximum document-level security configuration is 1024 KB: generous for one term query per role, restrictive if you try to list a whole estate by agent ID.
- Parameter substitution exists.
${user.name},${user.roles}and${attr.internal.<NAME>}are substituted at query time, so one role can serve many tenants if the tenant is stored as a user attribute. Wazuh uses this itself: thewazuh_ai_assistantrole restricts reads on the AI assistant session data stream with{"term": {"user": "${user.name}"}}.
The pattern works on the 5.0 data streams
Wazuh does not document document-level security for its own data streams, which left the central question of this design open: does a dls query apply to documents that live in backing indices behind a data stream? Verified on the 5.0.0-beta5 lab, it does. The role above was created with a term query on wazuh.agent.groups, a user was created and mapped to it, and the same aggregations were run as an administrator and as the tenant user:
$ PUT _plugins/_security/api/roles/tenant-lab-linux (DLS: wazuh.agent.groups = lab-linux)
{"status":"CREATED","message":"'tenant-lab-linux' created."}
$ PUT _plugins/_security/api/internalusers/tenant-lab-linux-user
{"status":"CREATED","message":"'tenant-lab-linux-user' created."}
--- as admin, events by agent/group:
agents: [('lab-agent-ubuntu', 690)]
groups: [('lab-linux', 690)]
--- as tenant-lab-linux-user, same query:
agents: [('lab-agent-ubuntu', 690)]
groups: [('lab-linux', 690)]
--- as tenant user: states-sca by agent
agents: [('lab-agent-ubuntu', 279)]
groups: [('lab-linux', 279)]
--- as tenant user: findings by agent
agents: [('lab-agent-ubuntu', 636)]
groups: [('lab-linux', 636)]
--- as tenant user: read wazuh-agent-config (not in role) ->
{"error":{"root_cause":[{"type":"security_exception","reason":"no permissions for [indices:data/read/search] and User [name=tenant-lab-linux-user, backend_roles=[], requestedTenant=null]"}],"type":"security_exception","reason":"no permissions for [indices:data/read/search] and User [name=tenant-lab-linux-user, backend_roles=[], requestedTenant=null]"},"status":403}
Read the numbers carefully, because two different controls are visible in them. The events and findings counts match for both users only because the second agent in the lab had not produced events in that window, so there was nothing to hide there. The filtering shows in the configuration assessment state index, where the tenant user sees 279 documents out of the 461 an administrator sees, and in the last line: an index the role does not name returns 403 rather than an empty result. That is the behaviour you want, and it is worth reproducing on your own cluster as the demonstration a customer asks for during onboarding.
Two details from the same run are worth carrying into your role design. wazuh.agent.groups is mapped as a keyword, which is what makes the term query safe with a hyphenated tenant name. And the role covered wazuh-events-v5-*, wazuh-findings-v5-* and wazuh-states-* as three patterns in one index_permissions entry, which is enough for a read-only tenant.
Index patterns a tenant role must not be given
A tenant role naming only the three patterns above is narrow by construction. The mistake is granting read on * or wazuh-* and relying on the query, because these indices hold cross-tenant material and several will not carry wazuh.agent.groups:
| Index or pattern | Why it stays out of a tenant role |
|---|---|
wazuh-events-raw-v5 |
Pre-normalisation events; a document-level query against a normalised field may not apply |
wazuh-active-responses |
Response requests across every tenant, including agent and cluster metadata |
wazuh-agent-config, wazuh-agent-stats |
Configuration and statistics for the whole fleet |
wazuh-metrics-* |
Platform operational metrics |
wazuh-threatintel-* |
Your indicator content, and the effort you put into it |
.wazuh-settings, .wazuh-internal-state, .wazuh-cti-consumers |
Platform internals owned by plugins |
restored_* |
Read-only 4.x data restored from snapshot, with 4.x field names |
Give each tenant a login
Create an internal user, map it to the tenant role, and map it again on the Wazuh side so the manager API respects it. The documented flow through the dashboard is:
- Indexer management > Security > Internal users, create the user with a backend role such as
tenant-acmeretail. - Indexer management > Security > Roles, select the tenant role, then Mapped users > Manage mapping and add the user or the backend role.
- Server management > Security > Roles mapping, tie the same user or backend role to a Wazuh role.
- Confirm
run_as: truein/etc/wazuh-dashboard/opensearch_dashboards.yml. It is enabled by default and the role mapping does nothing without it. - Restart the dashboard and clear browser cache and cookies.
Default users and roles cannot be modified; duplicate and edit the duplicate. For SAML or LDAP tenants, map the identity provider group to the backend role rather than creating internal users. The mechanics are in Wazuh SSO and RBAC.
Both objects can be created through the API instead, which is what onboarding automation should do. A PUT to _plugins/_security/api/roles/<role> and another to _plugins/_security/api/internalusers/<user> each answer with a one-line confirmation, as the lab output above shows.
Keep saved objects apart with dashboard tenants
Tenants in the Wazuh dashboard are containers for index patterns, visualisations, dashboards and other saved objects. Every user has a global tenant and a private tenant; administrators can create custom tenants and assign them to roles. Enable it under Indexer management > Security > Tenants, tick the box under Dashboards multi-tenancy, save and apply. A tenant selector then appears.
This is object isolation, not data isolation. A dashboard saved in a custom tenant is invisible to other tenants, but the data it renders is still governed by the role’s document-level security. Both are needed, and confusing one for the other is how a customer ends up with their own dashboard full of somebody else’s endpoints.
Scope the manager API to a tenant
The manager API has its own role-based access control, and its permissions are expressed as actions against resources. Two resource types matter for tenancy: agent:group references agents by group name, and group:id references a group. A policy scoped to one group is real isolation, not cosmetic:
POST /security/policies
{
"name": "tenant_acmeretail_agents",
"policy": {
"actions": [
"agent:read",
"agent:restart",
"agent:upgrade"
],
"resources": [
"agent:group:t-acmeretail"
],
"effect": "allow"
}
}
Attach the policy to a role, attach the role to the user, and that user’s GET /agents returns their agents only. Note which actions cannot be scoped this way: agent:create, group:create and the enrolment token actions take the resourceless *:*:* resource, so anyone who can create an agent or mint an enrolment token can do it for any tenant. Those actions belong to your onboarding automation, not to a tenant administrator.
Withhold agent:read_secrets from every tenant role. It reads the pre-shared keys from client.keys in clear.
Onboard a tenant end to end
Order matters, and one step out of sequence produces a rejected enrolment or, worse, agents silently in the wrong group.
- Create the group first.
POST /groupswith{"group_id": "t-acmeretail"}, oragent_groups -a -g t-acmeretail -q. Enrolment into a group that does not exist is rejected. - Write the group’s shared configuration at
/var/wazuh-manager/etc/shared/t-acmeretail/agent.conf, where the tenant’s collection scope lives. - Create the indexer role with the document-level security query, pinned to that group name.
- Create the user or identity provider mapping, and the Wazuh role mapping.
- Create the manager API policy and role scoped to
agent:group:t-acmeretail. - Mint an enrolment token instead of handing out the shared enrolment password.
POST /agents/enrollment-tokenstakes anaddressthat must be a name in the listener certificate, attland amax_uses. The token text is returned once and never listed again. - Enrol agents with
<enrollment><groups>t-acmeretail</groups>. - Reconcile.
GET /groups/t-acmeretail/agentsagainst the customer’s asset list, andGET /agents/no_groupfor anything that landed nowhere.
The companion files for this lesson include a shell skeleton for steps 1, 2, 6 and 8, with the parts that depend on your naming and your secret store left as explicit gaps.
Per-tenant notifications, monitors and reporting
Alerting monitors and notification channels are ordinary indexer objects, and by default they are visible to everyone. Two settings change that:
curl -sk -u <USERNAME>:<PASSWORD> -X PUT \
"https://localhost:9200/_cluster/settings" \
-H "Content-Type: application/json" \
-d '{"persistent": {"plugins.alerting.filter_by_backend_roles": true, "opensearch.notifications.general.filter_by_backend_roles": true}}'
plugins.alerting.filter_by_backend_roles restricts access to alerting resources based on backend roles, and the notifications equivalent does the same for channel configurations. Both default to false. The four default channels created at first startup carry an empty access list, which makes them visible to all users, so review them before you enable anything. On beta5 those four are a Slack channel and webhook channels for Jira, PagerDuty and Shuffle, none of them configured with a destination, which is exactly the kind of object a tenant should never be able to point at their own endpoint.
The monitor limit shapes the design. The Alerting module page states support for up to 10 custom monitors per user, while plugins.alerting.monitor.max_monitors is documented with a default of 1000. Until that is resolved, build monitors per severity band across all tenants and route by tenant in the payload rather than one monitor per tenant. A per-document monitor on wazuh-findings-v5-* with a Mustache action body carrying the matched document’s fields gives your automation platform what it needs to fan out per customer. The webhook contract is in SOAR with Wazuh.
Reporting is the OpenSearch Reporting fork: report definitions produce PDF or PNG from dashboards and visualisations and CSV or XLSX from saved searches, on demand or on a schedule, with optional email notification, all governed by indexer role-based access control. A per-tenant monthly report is a saved search inside that tenant’s dashboard tenant, a scheduled report definition and the customer’s recipient group. That is as close to native per-customer reporting as 5.0 gets; the old per-module Wazuh PDF reports are gone.
Isolation failure modes
Work through this before a second customer goes live. These are the ways the design above fails in practice.
- An agent in two groups. Multi-group membership is normal, and the document then carries both groups, so a
termquery matches for both tenants. Either check for it, or make the tenant group the only group an agent holds and express roles through the shared configuration. - A tenant user with a second role. Queries combine with OR, so a second role widens access. A tenant user holds exactly one indexer role.
- A hyphen in the tenant identifier plus a
matchquery on an analysed field. The failure is silent: the role looks right and returns more than it should. - Write permission on a tenant role. Document-level security does not filter writes. A tenant with
indexordeletecan touch documents it cannot see. - Backend-role filtering left off for alerting and notifications, so every tenant can see and edit every monitor and channel.
- Saved objects in the global tenant. A dashboard your analysts built there is visible to every user who can reach it.
- The index patterns you forgot. Anything outside the tenant role’s three patterns is either inaccessible, which is correct, or reachable through a second role, which is the bug.
- Dev Tools. A user who reaches Indexer management > Dev Tools issues raw queries. Document-level security still applies, which is why the query has to be right rather than the interface restricted.
- Cross-cluster settings.
plugins.alerting.cross_cluster_monitoring_enableddefaults totrue, so monitors can query any remote cluster you connect. - Restored 4.x indices. They carry 4.x field names, so a role written against
wazuh.agent.groupsdoes not filter them at all.
What tenants actually ask for
Five things, in the order they come up. None is a Wazuh feature request.
- Their own login, through their own identity provider. Role mapping to a SAML or LDAP group.
- Evidence they cannot see anyone else. Give them the role definition and a demonstration: log in as their user and run a query that would return another tenant’s agents. Do it during onboarding, not during an audit.
- A report on a schedule. A report definition on a saved search inside their dashboard tenant.
- Their own notification destination. A channel per tenant, with backend-role filtering on.
- To be told when their agents stop reporting. The manager marks an agent disconnected after
<agents_disconnection_time>, 15 minutes by default. The GitHub migration guide marks<agents_disconnection_alert_time>as going, but beta5 still ships both options in the default<global>block, with the alert timer set to0. Either way, do not rely on a manager-side alert reaching a tenant. Build the per-tenant disconnected-agent notification on the indexer deliberately; it is the first thing a tenant notices when it is missing.
What is not documented in the beta
State these plainly to anyone relying on this design, and re-verify each at general availability.
| Item | Status as of beta5, September 2026 | What to verify at GA |
|---|---|---|
| Document-level security on Wazuh data streams | Still not documented by Wazuh, but lab-verified on beta5: the dls query does reach documents in the backing indices |
Whether Wazuh publishes a supported pattern, and re-run the tenant test after each upgrade |
wazuh.agent.labels.* |
Not confirmed in the beta pages; labels is absent from the agent configuration section list |
The exact path, with the Log test tool, before using labels in any security decision |
| Custom monitor limit | Two different figures documented | The effective limit on your own cluster |
wazuh.space.name |
Described as the policy space in the normalisation engine documentation and as “space or tenant information” on the active response stream | Whether it can ever carry a customer identity. Assume not |
| Field-level security and field masking for tenants | Not discussed for Wazuh indices | Whether masking agent names and IP addresses is viable for a shared analyst role |
Common errors
A tenant user logs in and sees nothing
Cause: the document-level security query references a field that is absent or spelled differently, so it matches no documents. Fix: read one real document in Discover as an administrator, confirm the field path and its type, then correct the role.
A tenant user sees other tenants’ agents
Cause, in order of likelihood: a second role mapped to the same user; a match query on an analysed field where the tenant identifier contains a hyphen; or an agent genuinely in two groups. Fix: check the user’s mapped roles first, switch the query to term on a keyword field, then audit group membership.
Permission denied on the manager API for a tenant user
Cause: the manager API policy is scoped to agent:group:<tenant>, and the action being attempted is one of the resourceless ones such as agent:create or enrollment_token:create, which cannot be scoped to a group. Fix: keep those actions in the onboarding role, not the tenant role.
Role mapping saved but the user still has old permissions
Cause: run_as is not true in /etc/wazuh-dashboard/opensearch_dashboards.yml, or the browser is holding a session. Fix: set run_as: true, restart wazuh-dashboard, clear browser cache and cookies.
Enrolment rejected for a new tenant’s first agent
Cause: the group named in <enrollment><groups> does not exist yet. Fix: create the group before enrolling. This is step one of the onboarding order for a reason.
A tenant can edit a monitor that is not theirs
Cause: plugins.alerting.filter_by_backend_roles is still at its default of false. Fix: set it and the notifications equivalent as persistent cluster settings, then re-check the default channels, which are created with an empty access list.
Where to go next
The role and role-mapping structures used here are documented on the official Wazuh indexer security page, and the document-level security behaviour, including parameter substitution and the multiple-roles rule, is in the OpenSearch document-level security documentation.
Per-tenant logins through an identity provider are the subject of Wazuh SSO and RBAC. The group mechanics this whole design rests on are in agent groups and centralised configuration, and the intake design that lets one set of monitors serve many customers is in SOAR with Wazuh.
Next: Wazuh SSO and RBAC with Entra ID, Okta and Keycloak.
Files for this lesson
Every file this lesson refers to, exactly as used. Open a file to read it in the browser, or download the set as a zip. Download wazuh-multi-tenant.zip. The full set for all lessons is at wazuh-tutorial-files.zip.
README.md4.7 KBonboard-tenant.sh7.8 KBtenant-api-policy.json1.6 KBtenant-role.json2.4 KBtenant-rolesmapping.yml1.9 KB
Frequently asked questions
Does Wazuh support multi-tenancy natively?
Not as a single feature you switch on. You build tenancy from parts that do exist: agent groups as the tenant key, indexer security roles with document-level security to fence the data, role mappings for per-tenant logins, dashboard tenants for saved objects, and manager API policies scoped to a group. There is no tenant object in Wazuh that ties those together for you.
Which field identifies the tenant on a Wazuh 5.0 document?
wazuh.agent.groups is the field to build on. The official 4.x to 5.x rule migration guide lists agent groups mapping to wazuh.agent.groups on documents, and groups are enforced at enrolment, so the value cannot drift the way a hand-set label can. Confirm the exact path on your own deployment with the Log test tool before you write a security role against it.
Does document-level security work on the Wazuh 5.0 data streams?
Yes. It is still not documented for the Wazuh data streams as of beta5, but it was verified on a 5.0.0-beta5 lab: a role with a term query on wazuh.agent.groups restricted a tenant user to one group's documents across the events, findings and states indices, and returned 403 on an index the role did not name. The field is mapped as a keyword, so use term rather than match.
Can one Wazuh dashboard serve several customers with separate logins?
Yes. Each customer gets an internal user or an identity provider group, mapped to an indexer role that carries a document-level security query restricting reads to their agents, plus a Wazuh role mapping for the manager API. Dashboard multi-tenancy then keeps their saved objects apart. The weak point is not the login, it is every index the role forgot to restrict.
What breaks tenant isolation in Wazuh most often?
Three things, in this order: an agent that belongs to two groups so its documents match two tenants; a tenant user who also holds a broad read-all role, which widens their access rather than narrowing it; and alerting or notification objects that are visible to everyone because backend-role filtering was never enabled.