# wazuh-multi-tenant

Companion files for the lesson **Multi-Tenant Wazuh for MSSPs**:
<https://maheshtata.com/wazuh/wazuh-multi-tenant/>

Tested against: Wazuh 5.0.0-beta5 (official Docker stack), Ubuntu 24.04,
lab-verified 23 September 2026. The document-level security pattern in
`tenant-role.json` was exercised against a real tenant user; the manager API
policy and the role mapping file were not.

## Files

| File | What it is |
| --- | --- |
| `tenant-role.json` | Indexer security role for one tenant: read-only on the three Wazuh index patterns, fenced with a document-level security `term` query on `wazuh.agent.groups`. |
| `tenant-rolesmapping.yml` | Role mapping for the same tenant, in `roles_mapping.yml` file form, with both the internal-user and identity-provider routes. |
| `tenant-api-policy.json` | Manager API policy scoping a tenant to `agent:group:t-<tenant>` on port 55000. |
| `onboard-tenant.sh` | Manager-side onboarding skeleton: group first, shared config, enrolment token, reconciliation. |

Everything here uses `acmeretail` as the example tenant and `t-acmeretail` as
its group. Search and replace before use.

## Order of operations

The order is not cosmetic. Enrolment into a group that does not exist is
rejected by Wazuh 5.0, so the group comes first, always.

```bash
WAZUH_API=https://manager.example.com:55000 \
WAZUH_API_USER=onboarding \
WAZUH_API_PASS='...' \
TENANT=acmeretail \
MANAGER_ADDRESS=wazuh-manager.example.com \
  ./onboard-tenant.sh
```

Then, in this order:

1. `tenant-role.json` through `PUT _plugins/_security/api/roles/tenant_acmeretail_read`
2. The internal user or identity-provider group, then `tenant-rolesmapping.yml`
3. The Wazuh role mapping in **Server management > Security > Roles mapping**
4. `tenant-api-policy.json` through `POST /security/policies`, attached to a role and a user
5. The dashboard tenant in **Indexer management > Security > Tenants**
6. Agent enrolment with `<enrollment><groups>t-acmeretail</groups>`
7. Reconciliation, and then on a schedule forever

## Read before you deploy these

### Document-level security works, but Wazuh still does not document it

As of beta5 (September 2026) Wazuh does not document document-level security
against `wazuh-events-v5-*`, `wazuh-findings-v5-*` or `wazuh-states-*`. The
pattern in these files was verified on a 5.0.0-beta5 lab on 23 September 2026:
the tenant user saw only their own group's documents in the events, findings
and configuration assessment state indices, and an index the role did not name
answered with HTTP 403 rather than an empty result. `wazuh.agent.groups` is
mapped as a keyword, so the `term` query is the correct form.

One lab run on one beta build is not a support statement. Re-check the general
availability documentation, and re-run the test on your own cluster after every
upgrade, particularly for how a `dls` query behaves against the backing indices
of a data stream.

### `term`, not `match`

The role uses a `term` query deliberately. The standard analyser splits text
fields on Unicode word boundaries, so a `match` on a hyphenated value such as
`t-acme-retail` can also match documents carrying a value that shares a token.
Confirm `wazuh.agent.groups` is a keyword field on your deployment before
trusting either form.

### One role per tenant user

Document-level security queries from multiple roles combine with a logical OR,
so mapping a tenant user to a second role widens their access. Where a role
with a query is combined with one without, the documented behaviour depends on
`plugins.security.dfm_empty_overrides_all`. Decide that cluster-wide once.

### Read-only, and only read

Document-level security filters reads, not writes. A role holding `index` or
`delete` can modify documents its query hides. None of these files grant a
write action to a tenant.

### Verify the field first

Before any of this, read one real document in Discover or run the Log test
tool, and confirm that your findings actually carry `wazuh.agent.groups`. A
security control written against an absent field matches nothing, and how that
fails depends on the rest of the role.

## Also enable, cluster-wide

Neither is on by default, and without them tenants can see and edit each
other's monitors and notification channels:

```bash
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
      }}'
```

The four default notification channels created at first startup have an empty
access list, which makes them visible to all users. On beta5 they are a Slack
channel and webhook channels for Jira, PagerDuty and Shuffle. Review them.
