Join our Discord Server
Collabnix Team The Collabnix Team is a diverse collective of Docker, Kubernetes, and IoT experts united by a passion for cloud-native technologies. With backgrounds spanning across DevOps, platform engineering, cloud architecture, and container orchestration, our contributors bring together decades of combined experience from various industries and technical domains.

Building a Secure AI-Powered Zabbix Chat Assistant with OpenWebUI, Claude, and SSO-Based Access Control

12 min read

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:

  1. System prompt — tells Claude what the user can do (soft guard, can be bypassed by prompt injection)
  2. RBAC Gateway — blocks unauthorized tool calls before they reach Zabbix (hard guard)
  3. 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 GroupPermissions
api-viewersRead-only access to all host groups
api-operatorsRead-write access to all host groups
api-adminsSuper 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.

Have Queries? Join https://launchpass.com/collabnix

Collabnix Team The Collabnix Team is a diverse collective of Docker, Kubernetes, and IoT experts united by a passion for cloud-native technologies. With backgrounds spanning across DevOps, platform engineering, cloud architecture, and container orchestration, our contributors bring together decades of combined experience from various industries and technical domains.
Join our Discord Server
Index