AI-powered Zabbix chat assistant for enhanced monitoring
Ever wished your monitoring team could just ask Zabbix a question in plain English instead of clicking through dashboards? Imagine a chat interface where your NOC engineer types “Show me all critical triggers on production servers” and gets an instant, formatted answer — powered by Claude and connected directly to your Zabbix instance.
Now imagine 10 users across three teams — viewers, operators, and admins — all sharing the same chat app, but each seeing and doing only what their role allows. That’s what we’re building today.
In this tutorial, we’ll wire together OpenWebUI (the chat frontend), Claude (the LLM brain), Zabbix MCP (the monitoring bridge), and Keycloak SSO (the identity layer) into a secure, role-aware monitoring assistant. The entire stack runs on Docker Compose and enforces per-user permissions end to end — from the SSO login screen all the way down to the Zabbix API call.
What We’re Building
Here’s the big picture:
┌──────────────────────────────────────────────────────────────────┐
│ KEYCLOAK SSO │
│ │
│ Groups: │
│ zabbix-viewers (5 users) → Read-only access │
│ zabbix-operators (3 users) → Read + write access │
│ zabbix-admins (2 users) → Full access │
│ │
└──────────┬───────────────────────────────────┬───────────────────┘
│ OIDC │ SAML/OIDC
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ OpenWebUI │ │ Zabbix Server │
│ (Chat Frontend) │ │ │
│ │ │ API Tokens: │
│ Claude Model │ │ - viewer_token │
│ connected │ │ - operator_token │
│ │ │ - admin_token │
└────────┬─────────┘ └─────────▲─────────┘
│ │
▼ │
┌──────────────────────────────────────────────┘
│ RBAC MCP Gateway
│ (Permission enforcement layer)
│
│ 1. Receives MCP tool call from Claude
│ 2. Extracts user's SSO group from JWT
│ 3. Checks: Is this tool allowed for this group?
│ 4. If YES → forwards to Zabbix with group-specific token
│ 5. If NO → returns ACCESS_DENIED
└──────────────────────────────────────────────┘
The key insight: OpenWebUI connects to MCP servers, but it treats all users equally — there’s no built-in per-user MCP permission filtering. We solve this with an RBAC MCP Gateway that sits between OpenWebUI and Zabbix, intercepting every tool call and enforcing permissions based on the user’s SSO group.
Prerequisites
Before we start, make sure you have:
- Docker and Docker Compose installed
- A Zabbix server running (v6.0+ recommended)
- Basic familiarity with Keycloak, OpenWebUI, and MCP concepts
- An Anthropic API key for Claude
Step 1 — Define Your User Groups and Permissions
Before touching any code, plan your access matrix. Here’s a practical example with 10 users across three groups:
┌─────────────────┬──────────────────────┬──────────────────────────────────────────┐
│ SSO Group │ Members │ Allowed Zabbix Operations │
├─────────────────┼──────────────────────┼──────────────────────────────────────────┤
│ zabbix-viewers │ alice, bob, charlie, │ host.get, hostgroup.get, trigger.get, │
│ │ diana, eve │ event.get, item.get, history.get, │
│ │ │ graph.get │
├─────────────────┼──────────────────────┼──────────────────────────────────────────┤
│ zabbix-operators│ frank, grace, hank │ All viewer ops + host.update, │
│ │ │ event.acknowledge, maintenance.create, │
│ │ │ maintenance.delete │
├─────────────────┼──────────────────────┼──────────────────────────────────────────┤
│ zabbix-admins │ ivan, judy │ All operations (including host.create, │
│ │ │ host.delete, user.*, template.*) │
└─────────────────┴──────────────────────┴──────────────────────────────────────────┘
The principle here is defense in depth — we enforce permissions at three layers:
- System prompt — tells Claude what the user can do (soft guard, can be bypassed by prompt injection)
- RBAC Gateway — blocks unauthorized tool calls before they reach Zabbix (hard guard)
- Zabbix API tokens — per-group tokens with matching Zabbix user roles (backend guard)
Even if the LLM hallucinates or someone tries prompt injection, the gateway and Zabbix itself will block unauthorized operations.
Step 2 — Set Up Keycloak as Your SSO Provider
Keycloak serves as the single source of truth for all user identities and group memberships. Both OpenWebUI and Zabbix will authenticate against it.
2.1 Create the Keycloak Realm
First, create a new realm for your organization:
# Access Keycloak admin console
# Navigate to: Administration Console → Create Realm
# Realm name: company
2.2 Create Groups
Create three groups matching our permission matrix:
Keycloak Admin → Groups → Create Group:
- zabbix-viewers
- zabbix-operators
- zabbix-admins
2.3 Create Users and Assign Groups
Create your 10 users and assign them to appropriate groups:
Users → Add User:
alice → Groups → Join: zabbix-viewers
bob → Groups → Join: zabbix-viewers
charlie → Groups → Join: zabbix-viewers
diana → Groups → Join: zabbix-viewers
eve → Groups → Join: zabbix-viewers
frank → Groups → Join: zabbix-operators
grace → Groups → Join: zabbix-operators
hank → Groups → Join: zabbix-operators
ivan → Groups → Join: zabbix-admins
judy → Groups → Join: zabbix-admins
2.4 Create Client for OpenWebUI
Clients → Create Client:
Client ID: openwebui
Client type: OpenID Connect
Root URL: https://chat.company.com
Valid redirect URIs: https://chat.company.com/oauth/callback
Client authentication: ON (Confidential)
Critical step — add the groups claim to the ID token:
Client Scopes → openwebui-dedicated → Add mapper → By configuration:
Mapper type: Group Membership
Name: groups
Token Claim Name: groups
Full group path: OFF
Add to ID token: ON
Add to access token: ON
2.5 Create Client for Zabbix
Clients → Create Client:
Client ID: zabbix
Client type: OpenID Connect (or SAML if preferred)
Root URL: https://zabbix.company.com
Valid redirect URIs: https://zabbix.company.com/index.php
After this setup, when any user logs in via SSO, their JWT token will contain something like:
{
"sub": "alice",
"email": "alice@company.com",
"groups": ["zabbix-viewers"],
"iat": 1750000000,
"exp": 1750003600,
"iss": "https://keycloak.company.com/realms/company"
}
This JWT is the foundation of everything that follows.
Step 3 — Configure Zabbix with Per-Group API Tokens
We need three Zabbix users (one per group), each with matching permissions, and each generating an API token.
3.1 Create Zabbix User Groups
In Zabbix Administration → User groups, create:
| Zabbix User Group | Permissions |
|---|---|
api-viewers | Read-only access to all host groups |
api-operators | Read-write access to all host groups |
api-admins | Super admin role |
3.2 Create API Users and Generate Tokens
Create a service account user for each group:
User: svc-viewer
Groups: api-viewers
Role: User role
→ Generate API Token → save as ZABBIX_VIEWER_TOKEN
User: svc-operator
Groups: api-operators
Role: Admin role
→ Generate API Token → save as ZABBIX_OPERATOR_TOKEN
User: svc-admin
Groups: api-admins
Role: Super admin role
→ Generate API Token → save as ZABBIX_ADMIN_TOKEN
Store these tokens securely — they’ll be used by the RBAC Gateway.
3.3 Enable SSO on Zabbix (Optional but Recommended)
In Zabbix Administration → Authentication → SAML/OIDC settings, point to your Keycloak realm. This lets your users log into the Zabbix web UI with the same SSO credentials, keeping everything consistent.
Step 4 — Build the RBAC MCP Gateway
This is the heart of the architecture — a lightweight MCP server that intercepts every tool call, checks the caller’s SSO group, and forwards only authorized calls to Zabbix.
4.1 Project Structure
rbac-zabbix-proxy/
├── server.py # Main MCP proxy server
├── permissions.py # Permission matrix and validation
├── auth.py # JWT/SSO token handling
├── requirements.txt
└── Dockerfile
4.2 Permission Matrix
# permissions.py
ROLE_CONFIG = {
"zabbix-viewers": {
"label": "Read-Only",
"allowed_methods": [
"host.get",
"hostgroup.get",
"trigger.get",
"event.get",
"item.get",
"history.get",
"graph.get",
"template.get",
"problem.get",
"service.get",
],
"zabbix_token_env": "ZABBIX_VIEWER_TOKEN",
},
"zabbix-operators": {
"label": "Operator",
"allowed_methods": [
# All viewer methods
"host.get",
"hostgroup.get",
"trigger.get",
"event.get",
"item.get",
"history.get",
"graph.get",
"template.get",
"problem.get",
"service.get",
# Write operations
"host.update",
"event.acknowledge",
"maintenance.create",
"maintenance.update",
"maintenance.delete",
"service.update",
],
"zabbix_token_env": "ZABBIX_OPERATOR_TOKEN",
},
"zabbix-admins": {
"label": "Admin",
"allowed_methods": ["*"], # Wildcard = all methods
"zabbix_token_env": "ZABBIX_ADMIN_TOKEN",
},
}
# Priority order for resolving multi-group membership
GROUP_PRIORITY = ["zabbix-admins", "zabbix-operators", "zabbix-viewers"]
def resolve_role(sso_groups: list) -> dict:
"""Pick the highest-privilege Zabbix role from the user's SSO groups."""
for group in GROUP_PRIORITY:
if group in sso_groups:
return {**ROLE_CONFIG[group], "group": group}
# Default: read-only (principle of least privilege)
return {**ROLE_CONFIG["zabbix-viewers"], "group": "zabbix-viewers"}
def is_method_allowed(method: str, role: dict) -> bool:
"""Check if a Zabbix API method is allowed for the given role."""
allowed = role["allowed_methods"]
return "*" in allowed or method in allowed
4.3 JWT Authentication Handler
# auth.py
import jwt
import httpx
import os
from functools import lru_cache
KEYCLOAK_URL = os.environ["KEYCLOAK_URL"] # e.g., https://keycloak.company.com
KEYCLOAK_REALM = os.environ["KEYCLOAK_REALM"] # e.g., company
@lru_cache(maxsize=1)
def get_jwks():
"""Fetch Keycloak's public keys for JWT verification."""
url = f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs"
resp = httpx.get(url)
return resp.json()
def decode_sso_token(token: str) -> dict:
"""
Decode and verify a Keycloak JWT.
Returns user info including groups.
"""
jwks = get_jwks()
# Get the signing key from Keycloak's JWKS
header = jwt.get_unverified_header(token)
key = None
for k in jwks["keys"]:
if k["kid"] == header["kid"]:
key = jwt.algorithms.RSAAlgorithm.from_jwk(k)
break
if not key:
raise ValueError("No matching key found in Keycloak JWKS")
decoded = jwt.decode(
token,
key=key,
algorithms=["RS256"],
audience="openwebui",
issuer=f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}",
)
return {
"user_id": decoded.get("sub"),
"email": decoded.get("email", ""),
"name": decoded.get("preferred_username", ""),
"groups": decoded.get("groups", []),
}
def get_user_from_headers(headers: dict) -> dict:
"""
Extract user identity from request headers.
OpenWebUI Pipeline injects these headers when forwarding
tool calls to the MCP proxy.
"""
# Option A: JWT token forwarded in Authorization header
auth = headers.get("Authorization", "")
if auth.startswith("Bearer "):
return decode_sso_token(auth.replace("Bearer ", ""))
# Option B: Custom headers injected by OpenWebUI Pipeline
return {
"user_id": headers.get("X-User-Id", "unknown"),
"email": headers.get("X-User-Email", ""),
"name": headers.get("X-User-Name", ""),
"groups": headers.get("X-User-Groups", "").split(","),
}
4.4 Main MCP Proxy Server
# server.py
import os
import json
import httpx
import logging
from mcp.server import Server
from mcp.types import Tool, TextContent
from permissions import resolve_role, is_method_allowed
from auth import get_user_from_headers
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("zabbix-rbac-proxy")
ZABBIX_API_URL = os.environ["ZABBIX_API_URL"]
app = Server("zabbix-rbac-proxy")
# ── Expose available tools based on what Zabbix supports ──
@app.list_tools()
async def list_tools():
"""Advertise all Zabbix tools. Filtering happens at call time."""
return [
Tool(name="zabbix_host_get", description="Get hosts from Zabbix",
inputSchema={"type": "object", "properties": {
"filter": {"type": "object"}, "output": {"type": "array"}}}),
Tool(name="zabbix_trigger_get", description="Get triggers from Zabbix",
inputSchema={"type": "object", "properties": {
"filter": {"type": "object"}, "min_severity": {"type": "integer"}}}),
Tool(name="zabbix_event_get", description="Get events from Zabbix",
inputSchema={"type": "object", "properties": {
"eventids": {"type": "array"}, "time_from": {"type": "integer"}}}),
Tool(name="zabbix_event_acknowledge", description="Acknowledge a Zabbix event",
inputSchema={"type": "object", "properties": {
"eventids": {"type": "array"}, "message": {"type": "string"}}}),
Tool(name="zabbix_host_update", description="Update a Zabbix host",
inputSchema={"type": "object", "properties": {
"hostid": {"type": "string"}, "status": {"type": "integer"}}}),
Tool(name="zabbix_maintenance_create", description="Create a maintenance window",
inputSchema={"type": "object", "properties": {
"name": {"type": "string"}, "active_since": {"type": "integer"},
"active_till": {"type": "integer"}, "hostids": {"type": "array"}}}),
# ... add more tools as needed
]
def tool_to_zabbix_method(tool_name: str) -> str:
"""Convert MCP tool name to Zabbix API method.
Example: zabbix_host_get → host.get
"""
parts = tool_name.replace("zabbix_", "").rsplit("_", 1)
if len(parts) == 2:
return f"{parts[0]}.{parts[1]}"
return tool_name
@app.call_tool()
async def handle_tool_call(name: str, arguments: dict, context=None):
"""Intercept every MCP tool call and enforce RBAC."""
# 1. Identify the caller
headers = context.headers if context else {}
user_info = get_user_from_headers(headers)
# 2. Resolve their role
role = resolve_role(user_info["groups"])
# 3. Convert tool name to Zabbix method
zabbix_method = tool_to_zabbix_method(name)
logger.info(
f"User={user_info['email']} Group={role['group']} "
f"Tool={name} Method={zabbix_method}"
)
# 4. Check permission
if not is_method_allowed(zabbix_method, role):
logger.warning(
f"ACCESS DENIED: {user_info['email']} ({role['group']}) "
f"attempted {zabbix_method}"
)
return [TextContent(
type="text",
text=json.dumps({
"error": "ACCESS_DENIED",
"user": user_info["email"],
"role": role["label"],
"group": role["group"],
"method_requested": zabbix_method,
"message": (
f"Your SSO group '{role['group']}' does not allow "
f"'{zabbix_method}'. Contact your Keycloak admin to "
f"request elevated access."
),
}, indent=2)
)]
# 5. Forward to Zabbix with the group-specific API token
zabbix_token = os.environ[role["zabbix_token_env"]]
try:
result = await call_zabbix(zabbix_method, arguments, zabbix_token)
return [TextContent(type="text", text=json.dumps(result, indent=2))]
except Exception as e:
logger.error(f"Zabbix API error: {e}")
return [TextContent(
type="text",
text=json.dumps({"error": "ZABBIX_API_ERROR", "message": str(e)})
)]
async def call_zabbix(method: str, params: dict, token: str) -> dict:
"""Call the Zabbix JSON-RPC API."""
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
ZABBIX_API_URL,
json={
"jsonrpc": "2.0",
"method": method,
"params": params,
"auth": token,
"id": 1,
},
)
data = response.json()
if "error" in data:
raise Exception(f"Zabbix error: {data['error']}")
return data.get("result", data)
if __name__ == "__main__":
import asyncio
from mcp.server.stdio import stdio_server
async def main():
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
asyncio.run(main())
4.5 Dockerfile
# rbac-zabbix-proxy/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8100
CMD ["python", "server.py"]
4.6 Requirements
# requirements.txt
mcp>=1.0.0
httpx>=0.27.0
PyJWT>=2.8.0
cryptography>=42.0.0
Step 5 — Configure OpenWebUI with SSO and Claude
5.1 OpenWebUI OIDC Configuration
# OpenWebUI environment variables for Keycloak SSO
ENABLE_OAUTH_SIGNUP=true
OAUTH_PROVIDER_NAME=keycloak
OAUTH_CLIENT_ID=openwebui
OAUTH_CLIENT_SECRET=<your-client-secret>
OPENID_PROVIDER_URL=https://keycloak.company.com/realms/company/.well-known/openid-configuration
OAUTH_SCOPES="openid email profile groups"
# Map SSO groups into OpenWebUI
ENABLE_OAUTH_GROUP_MANAGEMENT=true
OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true
OAUTH_GROUP_CLAIM=groups
5.2 Connect Claude as the Model Backend
In OpenWebUI Admin → Settings → Connections, add:
API Base URL: https://api.anthropic.com/v1
API Key: sk-ant-xxxxx
Model: claude-sonnet-4-5-20250929
5.3 Connect the RBAC MCP Proxy
In OpenWebUI Admin → Settings → MCP Servers, add:
Name: Zabbix Monitoring
URL: http://zabbix-rbac-proxy:8100
Important: Point this to the RBAC proxy, NOT directly to a Zabbix MCP server.
Step 6 — Create the OpenWebUI Pipeline for User Context Injection
This Pipeline filter is the critical glue that passes the user’s SSO identity to the RBAC gateway. Without it, the proxy wouldn’t know who is making the request.
# pipelines/zabbix_rbac_pipeline.py
"""
Zabbix RBAC Pipeline for OpenWebUI
This pipeline:
1. Extracts the user's SSO groups from their OpenWebUI session
2. Injects an RBAC-aware system prompt so Claude knows the user's role
3. Attaches user identity headers for the RBAC MCP proxy
"""
from pydantic import BaseModel, Field
class Pipeline:
class Valves(BaseModel):
zabbix_proxy_url: str = Field(
default="http://zabbix-rbac-proxy:8100",
description="URL of the RBAC MCP proxy"
)
priority: int = Field(
default=0,
description="Pipeline priority (lower = runs first)"
)
def __init__(self):
self.name = "Zabbix RBAC Filter"
self.valves = self.Valves()
async def inlet(self, body: dict, __user__: dict) -> dict:
"""
Runs before every LLM call.
Injects user context into the system prompt and headers.
"""
# Extract user info from OpenWebUI's user object
user_email = __user__.get("email", "unknown")
user_name = __user__.get("name", "unknown")
user_groups = __user__.get("groups", [])
# Resolve access level
if "zabbix-admins" in user_groups:
access_level = "full"
allowed_desc = "all Zabbix operations"
elif "zabbix-operators" in user_groups:
access_level = "read_write"
allowed_desc = (
"read operations (*.get) plus host.update, "
"event.acknowledge, and maintenance management"
)
else:
access_level = "read_only"
allowed_desc = "read operations only (*.get methods)"
# Build RBAC context for the system prompt
rbac_prompt = f"""
## Zabbix Monitoring Access Control
Current user: {user_name} ({user_email})
SSO groups: {', '.join(user_groups)}
Access level: {access_level}
Allowed operations: {allowed_desc}
Rules you must follow:
- Only call Zabbix MCP tools that match this user's access level.
- If the user asks for an operation outside their permissions, explain
what they CAN do and tell them to contact their Keycloak/SSO admin
to request a group change (e.g., from zabbix-viewers to zabbix-operators).
- Never attempt to bypass, work around, or escalate permissions.
- Be helpful within the user's allowed scope.
"""
# Inject into the conversation's system message
messages = body.get("messages", [])
if messages and messages[0].get("role") == "system":
messages[0]["content"] = rbac_prompt + "\n" + messages[0]["content"]
else:
messages.insert(0, {"role": "system", "content": rbac_prompt})
body["messages"] = messages
# Attach user identity as metadata for the RBAC proxy
if "metadata" not in body:
body["metadata"] = {}
body["metadata"]["user_identity"] = {
"user_id": __user__.get("id", ""),
"email": user_email,
"name": user_name,
"groups": user_groups,
}
return body
async def outlet(self, body: dict, __user__: dict) -> dict:
"""Runs after LLM response. Can be used for audit logging."""
# Optional: Log all Zabbix interactions for compliance
return body
Install this pipeline in OpenWebUI Admin → Settings → Pipelines → Upload.
Step 7 — Docker Compose: Putting It All Together
Here’s the complete docker-compose.yml that brings up the entire stack:
# docker-compose.yml
version: "3.8"
services:
# ── SSO Provider ──
keycloak:
image: quay.io/keycloak/keycloak:26.0
container_name: keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD}
command: start-dev
ports:
- "8443:8080"
depends_on:
- keycloak-db
keycloak-db:
image: postgres:16-alpine
container_name: keycloak-db
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
volumes:
- keycloak-data:/var/lib/postgresql/data
# ── Chat Frontend ──
openwebui:
image: ghcr.io/open-webui/open-webui:main
container_name: openwebui
ports:
- "3000:8080"
environment:
# SSO Configuration
ENABLE_OAUTH_SIGNUP: "true"
OAUTH_PROVIDER_NAME: keycloak
OAUTH_CLIENT_ID: openwebui
OAUTH_CLIENT_SECRET: ${OPENWEBUI_OAUTH_SECRET}
OPENID_PROVIDER_URL: http://keycloak:8080/realms/company/.well-known/openid-configuration
OAUTH_SCOPES: "openid email profile groups"
ENABLE_OAUTH_GROUP_MANAGEMENT: "true"
OAUTH_MERGE_ACCOUNTS_BY_EMAIL: "true"
OAUTH_GROUP_CLAIM: groups
# Claude API
OPENAI_API_BASE_URL: https://api.anthropic.com/v1
OPENAI_API_KEY: ${ANTHROPIC_API_KEY}
volumes:
- openwebui-data:/app/backend/data
depends_on:
- keycloak
# ── RBAC MCP Gateway ──
zabbix-rbac-proxy:
build: ./rbac-zabbix-proxy
container_name: zabbix-rbac-proxy
environment:
ZABBIX_API_URL: http://zabbix-web:8080/api_jsonrpc.php
ZABBIX_VIEWER_TOKEN: ${ZABBIX_VIEWER_TOKEN}
ZABBIX_OPERATOR_TOKEN: ${ZABBIX_OPERATOR_TOKEN}
ZABBIX_ADMIN_TOKEN: ${ZABBIX_ADMIN_TOKEN}
KEYCLOAK_URL: http://keycloak:8080
KEYCLOAK_REALM: company
ports:
- "8100:8100"
depends_on:
- zabbix-web
# ── Zabbix Stack ──
zabbix-server:
image: zabbix/zabbix-server-pgsql:7.0-alpine-latest
container_name: zabbix-server
environment:
DB_SERVER_HOST: zabbix-db
POSTGRES_USER: zabbix
POSTGRES_PASSWORD: ${ZABBIX_DB_PASSWORD}
depends_on:
- zabbix-db
zabbix-web:
image: zabbix/zabbix-web-nginx-pgsql:7.0-alpine-latest
container_name: zabbix-web
ports:
- "8080:8080"
environment:
ZBX_SERVER_HOST: zabbix-server
DB_SERVER_HOST: zabbix-db
POSTGRES_USER: zabbix
POSTGRES_PASSWORD: ${ZABBIX_DB_PASSWORD}
depends_on:
- zabbix-server
zabbix-db:
image: postgres:16-alpine
container_name: zabbix-db
environment:
POSTGRES_DB: zabbix
POSTGRES_USER: zabbix
POSTGRES_PASSWORD: ${ZABBIX_DB_PASSWORD}
volumes:
- zabbix-data:/var/lib/postgresql/data
volumes:
keycloak-data:
openwebui-data:
zabbix-data:
Environment File
# .env
KC_ADMIN_PASSWORD=your-keycloak-admin-password
KC_DB_PASSWORD=your-keycloak-db-password
OPENWEBUI_OAUTH_SECRET=your-openwebui-oauth-secret
ANTHROPIC_API_KEY=sk-ant-your-key
ZABBIX_DB_PASSWORD=your-zabbix-db-password
ZABBIX_VIEWER_TOKEN=your-viewer-api-token
ZABBIX_OPERATOR_TOKEN=your-operator-api-token
ZABBIX_ADMIN_TOKEN=your-admin-api-token
Launch
docker compose up -d
Step 8 — See It in Action
Let’s walk through what happens when two different users interact with the system.
Scenario 1: Alice (Viewer) Tries to Acknowledge an Alert
Alice logs in → Keycloak SSO → JWT: { groups: ["zabbix-viewers"] }
│
OpenWebUI reads JWT groups ───────────────┘
Pipeline injects: "This user has read-only access"
│
Alice types: "Acknowledge the alert on db-server-01"
│
Claude reads system prompt, knows Alice is read-only
│
Claude responds:
"I can see the alert on db-server-01 — it's a high CPU
usage trigger that fired 20 minutes ago. However, your
account has read-only access, so I can't acknowledge it
for you.
To get write access, ask your SSO admin to move you from
the zabbix-viewers group to zabbix-operators in Keycloak.
In the meantime, would you like me to show you more
details about this alert?"
Even if Claude tried to call event.acknowledge, the RBAC Gateway would block it and return ACCESS_DENIED.
Scenario 2: Frank (Operator) Acknowledges the Same Alert
Frank logs in → Keycloak SSO → JWT: { groups: ["zabbix-operators"] }
│
Pipeline injects: "This user has read+write access"
│
Frank types: "Acknowledge the alert on db-server-01
with message 'investigating high CPU'"
│
Claude calls MCP tool: zabbix_event_acknowledge
→ RBAC Gateway: "zabbix-operators" + "event.acknowledge" → ✅ ALLOWED
→ Forwards to Zabbix API with OPERATOR token
→ Zabbix acknowledges the event
│
Claude responds:
"Done — I've acknowledged the high CPU alert on db-server-01
with your message. The event ID is 28374."
Audit Logging
For compliance and debugging, add audit logging to the RBAC proxy. Every tool call should log who asked for what and whether it was allowed:
# Add to server.py
import datetime
AUDIT_LOG = "/var/log/zabbix-rbac/audit.jsonl"
def audit_log(user: dict, method: str, role: dict, allowed: bool, result: str = ""):
entry = {
"timestamp": datetime.datetime.utcnow().isoformat(),
"user_email": user.get("email"),
"user_id": user.get("user_id"),
"sso_group": role["group"],
"role_label": role["label"],
"zabbix_method": method,
"allowed": allowed,
"result_summary": result[:200],
}
with open(AUDIT_LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
Sample audit output:
{"timestamp": "2025-02-15T10:30:45", "user_email": "alice@company.com", "sso_group": "zabbix-viewers", "zabbix_method": "event.acknowledge", "allowed": false}
{"timestamp": "2025-02-15T10:31:12", "user_email": "alice@company.com", "sso_group": "zabbix-viewers", "zabbix_method": "trigger.get", "allowed": true}
{"timestamp": "2025-02-15T10:32:01", "user_email": "frank@company.com", "sso_group": "zabbix-operators", "zabbix_method": "event.acknowledge", "allowed": true}
Key Takeaways
One identity source: Keycloak is the single source of truth. Change a user’s group in Keycloak, and their chat permissions change instantly — no code changes, no redeployment.
Defense in depth: Three layers of protection (system prompt, RBAC Gateway, Zabbix API tokens) ensure that even prompt injection or LLM hallucination can’t bypass permissions.
The RBAC Gateway is the key innovation: OpenWebUI doesn’t natively support per-user MCP permissions. The proxy layer solves this cleanly without forking OpenWebUI or modifying Claude’s behavior.
Audit everything: Every tool call is logged with who, what, when, and whether it was allowed. This is critical for compliance and incident investigation.
What’s Next?
There’s a lot you can build on top of this foundation:
- Dynamic tool filtering: Instead of advertising all tools and blocking at call time, filter the tool list per user so Claude never even suggests operations the user can’t perform.
- Approval workflows: For critical operations (like disabling a host), require a second user’s approval before executing.
- Slack/Teams integration: Forward alerts to chat channels and let operators acknowledge them inline.
- Natural language Zabbix dashboards: Use Claude to generate custom monitoring summaries on demand.
The combination of LLMs, MCP, and proper RBAC opens up a new paradigm for infrastructure management — one where your team can interact with monitoring systems conversationally, safely, and at scale.