Runbooks / Siem Webhook

Audit-write webhook (HTTPS push)

Operator runbook for piping IdentityMesh audit events to a customer-supplied HTTPS endpoint in near-real-time. This is the push counterpart to the pull-style audit feeds described in siem-integration.md and the syslog stream shipped via siem-syslog.md. Use it when your SIEM (or detection-orchestration platform — Splunk SOAR, Tines, Torq, custom Logic Apps / Cloud Run) wants events delivered synchronously rather than polled.

The webhook sink is opt-in. Nothing is wired by default, and nothing the audit-row write path does depends on the webhook being healthy. The audit row is committed to IM_AdminAudit / IM_ObjectAudit and stamped into the ER-005 hash chain before the webhook fires; if the receiver is unreachable, the durable record is still intact.

When to choose the webhook

If your receiver speaks syslog, prefer the syslog sink — it has fewer moving parts, no TLS cert juggling, and lower per-event overhead. If your receiver wants ArcSight / QRadar envelopes, see siem-cef-leef.md.

What gets shipped

The webhook ships audit-row events only — admin-surface mutations recorded to IM_AdminAudit, plus identity-data changes recorded to IM_ObjectAudit. Operational debug / info lines are filtered out so the receiver isn’t drowned in connector chatter.

The filter is expression-based and ships in appsettings.Webhook.example.json:

EndsWith(SourceContext, 'AuditStore') or @Properties['AuditRow'] = 'true'

Tighten or loosen this expression to your taste. The AuditRow property is reserved for future use — when audit-row tagging moves to an explicit LogContext.PushProperty("AuditRow", true) at the audit-write call sites, the filter picks it up automatically.

Each event is delivered as a JSON object — by default the Compact JSON Format shape. Example:

{
  "@t": "2026-04-25T11:32:18.4210000Z",
  "@m": "Admin audit row recorded: POST /api/connectors -> 201",
  "@l": "Information",
  "SourceContext": "IdentityMesh.Infrastructure.Sql.SqlAdminAuditStore",
  "ActorUpn": "alice@corp",
  "Action": "POST /api/connectors",
  "TargetKind": "Connector",
  "TargetId": "8e6b8c44-...",
  "StatusCode": 201,
  "CorrelationId": "0HMV-...",
  "MachineName": "im-engine-01",
  "InstanceName": "IdentityMeshEngine"
}

Batches of up to 100 events (or 256 KB, whichever first) are POSTed to the configured requestUri every two seconds.

Enabling the webhook

The Sync Engine ships with a sample at:

<install dir>\appsettings.Webhook.example.json

Copy the Serilog.Using array entry, the Serilog.WriteTo block, and the Serilog.Filter block into the live appsettings.json (or, preferred for a host-specific override, appsettings.Production.json). Restart the IdentityMesh service.

Minimal recipe

{
  "Serilog": {
    "Using": [
      "Serilog.Sinks.Console",
      "Serilog.Sinks.File",
      "Serilog.Sinks.EventLog",
      "Serilog.Sinks.Http"
    ],
    "WriteTo": [
      {
        "Name": "Http",
        "Args": {
          "requestUri": "https://siem.corp.example/identitymesh/webhook",
          "queueLimitBytes": 67108864,
          "logEventsInBatchLimit": 100,
          "batchSizeLimitBytes": 262144,
          "period": "00:00:02",
          "textFormatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact",
          "restrictedToMinimumLevel": "Information"
        }
      }
    ],
    "Filter": [
      {
        "Name": "ByIncludingOnly",
        "Args": {
          "expression": "EndsWith(SourceContext, 'AuditStore') or @Properties['AuditRow'] = 'true'"
        }
      }
    ]
  }
}

Key knobs:

FieldMeaning
requestUriHTTPS endpoint on the receiver. HTTP is permitted but not recommended; the sink does not disable TLS verification.
logEventsInBatchLimitMax events per POST. 50-200 is sane.
batchSizeLimitBytesMax batch payload size (262144 = 256 KB).
periodFlush interval (TimeSpan). 1-5 s is sane.
queueLimitBytesProcess-memory queue cap (67108864 = 64 MB). Sustained outage past this drops oldest events.
textFormatterPer-event JSON shape. CompactJsonFormatter is recommended.
restrictedToMinimumLevelSeverity floor for the webhook specifically (Information catches admin-audit + object-audit; Warning is the safe minimum on a noisy host).

Receiver expectations

The receiver should:

  1. Accept POST on the configured path with content-type application/json.
  2. Read the body as a JSON envelope with an events array (the sink’s batch shape):
    { "events": [ { ...event 1... }, { ...event 2... } ] }
  3. Respond with a 2xx status within ~10 seconds. Non-2xx is treated as a transient failure and the batch is retried.
  4. Be idempotent: under retry the same event may be delivered twice. Use @t + the audit-row primary key (AuditId if present in @mt / properties) as the dedup key.
  5. Verify the HMAC signature header (see “Signing” below) once it’s enabled.

Sample receiver — Node.js (Express)

const express = require('express');
const crypto  = require('crypto');

const HMAC_KEY = Buffer.from(process.env.IM_HMAC_KEY, 'base64');
const app = express();
app.use(express.raw({ type: 'application/json', limit: '1mb' }));

app.post('/identitymesh/webhook', (req, res) => {
  const sig  = req.header('X-IdentityMesh-Signature') || '';
  const body = req.body;
  const expected = crypto
    .createHmac('sha256', HMAC_KEY)
    .update(body)
    .digest('hex');
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).end();
  }
  const { events } = JSON.parse(body.toString('utf8'));
  for (const ev of events) {
    // ... persist / forward / alert ...
  }
  res.status(202).end();
});

app.listen(8443);

Sample receiver — Python (Flask)

import os, hmac, hashlib, json
from flask import Flask, request, abort

HMAC_KEY = bytes.fromhex(os.environ["IM_HMAC_KEY"])
app = Flask(__name__)

@app.post("/identitymesh/webhook")
def receive():
    raw = request.get_data()
    sig = request.headers.get("X-IdentityMesh-Signature", "")
    expected = hmac.new(HMAC_KEY, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    payload = json.loads(raw)
    for ev in payload.get("events", []):
        # persist / forward / alert
        pass
    return "", 202

Sample receiver — C# (IHostedService)

app.MapPost("/identitymesh/webhook", async (HttpRequest req, byte[] body) =>
{
    var sig = req.Headers["X-IdentityMesh-Signature"].ToString();
    var expected = Convert.ToHexString(
        HMACSHA256.HashData(hmacKey, body)).ToLowerInvariant();
    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.ASCII.GetBytes(sig),
            Encoding.ASCII.GetBytes(expected)))
        return Results.Unauthorized();

    using var doc = JsonDocument.Parse(body);
    foreach (var ev in doc.RootElement.GetProperty("events").EnumerateArray())
    {
        // persist / forward / alert
    }
    return Results.Accepted();
});

Signing

ER-041 specifies HMAC-SHA256 origin verification on every batch. The shared key is configured under Webhook:HmacKey in appsettings.json; the receiver verifies the X-IdentityMesh-Signature header against the raw request body.

Status: signature emission ships as a follow-up in this release. The current Serilog.Sinks.Http configuration delivers batches over HTTPS but does not stamp the HMAC header — the sink’s request-shaping hook landed in 9.x but is not yet wired through the engine. Operators who need signed delivery today have two options:

  1. Reverse-proxy mode: front the receiver with NGINX / HAProxy / Cloudflare and let the proxy inject the header by computing the HMAC on the body.
  2. Custom sink wrapper: subclass Serilog.Sinks.Http’s IHttpClient to inject the header at request build time. Document and contribute the wrapper back to this repo for the next release.

The receiver code samples above already verify the header so when signing flips on, no receiver changes are required.

HMAC key rotation

  1. Generate the new key on the receiver side (openssl rand -base64 32).
  2. Update the receiver to accept either the old key or the new key during the rotation window.
  3. Update Webhook:HmacKey in IdentityMesh’s appsettings.json (or its secrets backend per secrets-keyvault.md / secrets-vault.md).
  4. Restart the IdentityMesh service. The new key takes effect on first emit.
  5. Once the next batch is observed at the receiver verifying under the new key, drop the old key from the receiver.

Failure modes

To surface the sink’s own diagnostics (TLS handshake errors, 4xx drops), enable Serilog SelfLog once at startup or temporarily point SELF_LOG at a writable file:

Serilog.Debugging.SelfLog.Enable(msg =>
    File.AppendAllText("logs\\serilog-selflog.txt", msg + "\n"));

Operational notes

Configurable fields cheat-sheet

ConcernWhere to set it
Severity floorSerilog.WriteTo[Http].Args.restrictedToMinimumLevel
Per-event field shapetextFormatter arg — CompactJsonFormatter recommended
Filter (audit-only)Serilog.Filter[ByIncludingOnly].Args.expression
Receiver URLrequestUri
Batch size / periodlogEventsInBatchLimit / batchSizeLimitBytes / period
Memory capqueueLimitBytes (drops-oldest beyond this)
HMAC keyWebhook:HmacKey (secrets-backed; see signing follow-up)