feat(web-app-wazuh): add Wazuh SIEM role with OIDC/LDAP RBAC

Add single-node Wazuh stack (manager, indexer, dashboard) as a new
Ansible role, integrated with the platform's Keycloak realm via native
OIDC and secondary LDAP authorization, with three-tier RBAC.

- Pins wazuh/wazuh-{manager,indexer,dashboard}:4.14.6 and
  wazuh-certs-generator:0.0.4 (one-shot cert bootstrap job)
- No external database: wazuh.indexer (OpenSearch) is self-contained
- Auth: native OIDC against Keycloak, working end-to-end after two
  infra fixes (JVM cacerts trust for the indexer's outbound calls to
  Keycloak, front-proxy vhost wiring)
- LDAP wired as a secondary, authentication-independent authorization
  backend (group-based backend_roles enrichment only)
- RBAC: three roles (administrator, security-analyst, readonly-auditor)
  via the platform's standard meta/rbac.yml + rbac_group_path pipeline
- Documents a genuine upstream Wazuh bug: wz-home as the default
  landing route throws an uncaught client exception for any
  non-all_access role; default route moved to OpenSearch Dashboards'
  own /app/home

Addressed two full rounds of review feedback (Kevin, Alejandro):
WEB_PROTOCOL usage, service-account naming, comment cleanup, shared
Keycloak-admin-helper extraction, a real TOCTOU race in RBAC test
setup, a Mermaid auth-flow diagram, meta/services.yml placement for
the certs-generator, CA-trust-path SPOT usage, CSP tightening
(including a genuine worker-src: blob: requirement found via live
testing), and a loud (non-silent) dev-bucket fallback warning.

Rebased onto main after a 3-month gap; brought the role in line with
~30 platform-convention changes introduced in that window (swarm
compose-template rules, Tor/onion contract, Playwright timeout/goto
wrappers, file layout, task numbering, comment policy, and more).

Live-deploy verification (9 iterations) found and fixed 6 further
real bugs invisible to lint/unit tests alone:
- credentials.* / meta/schema.yml -> secrets.credentials.* /
  meta/secrets.yml (another platform-drift casualty)
- notify: "compose up" -> "compose-up" (day-one handler-name typo)
- local.subnet collision with web-app-mailu's /24
- missing primary: false alongside tor.exclusive: false, which let
  the onion domain shadow the clearnet vhost entirely
- a second front-proxy include for the onion domain, later reverted
  (see below)
- shared sys-stk-front-proxy/sys-front-tls TLS-lookup keying bug
  (app-id-keyed vs. domain-keyed), which silently applied TLS to
  Wazuh's onion vhost

The last two are related: enabling genuine dual-stack (clearnet +
onion) reachability for tor.exclusive: false surfaced a real bug in
two shared platform roles. That fix was verified safe against all 103
apps currently in the repo, but is deliberately NOT included here -
shared-role changes deserve their own PR and review, not to ride in
on a single app's feature branch. Wazuh's second front-proxy include
was reverted accordingly; the resulting onion-reachability gap is
documented in the README as a known, platform-scoped limitation.

make test: 5508 passed, 3 failed (all three trace to untracked local
scratch files - Dockerfile.bak, scripts/lab-*.sh - not present in any
CI checkout).
This commit is contained in:
Prageeth Panicker committed 2026-09-23 20:55:25 +00:00
1 parent 9bd8978b29
commit 0e5288d2c5
40 files changed
+3056 -2

No files matched your search

@@ -0,0 +1,53 @@
import unittest
import bcrypt
from plugins.filter.bcrypt_hash import FilterModule
class TestBcryptHash(unittest.TestCase):
def setUp(self):
self.f = FilterModule().filters()["bcrypt_hash"]
def test_hash_verifies_against_the_original_password(self):
password = "correct horse battery staple"
hashed = self.f(password)
self.assertTrue(
bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
)
def test_hash_does_not_verify_against_a_different_password(self):
hashed = self.f("password-one")
self.assertFalse(bcrypt.checkpw(b"password-two", hashed.encode("utf-8")))
def test_hash_is_randomized_salt_each_call(self):
password = "same-password"
self.assertNotEqual(self.f(password), self.f(password))
def test_hash_has_bcrypt_prefix(self):
hashed = self.f("some-password")
self.assertTrue(hashed.startswith(("$2b$", "$2a$")))
def test_64_character_password_hashes_successfully(self):
# Exception: Ansible's password_hash('bcrypt') filter routes through
# passlib, whose bcrypt self-test can misreport a well-under-72-byte
# password as "too long" when passlib and the installed bcrypt
# package's version detection drift apart. The raw bcrypt package
# used here has no such self-test.
password = "A" * 64
hashed = self.f(password)
self.assertTrue(
bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
)
def test_none_raises(self):
with self.assertRaises(ValueError):
self.f(None)
def test_empty_string_raises(self):
with self.assertRaises(ValueError):
self.f("")
if __name__ == "__main__":
unittest.main()