Enterprise Middleware

Audit Log Middleware

Signed, structured records of who did what, when and from where in your Kipchak API, with personal data redacted, written to Kafka, OpenSearch, NATS JetStream or the log.

Introduction

Note: This middleware is only available with Kipchak Enterprise.

Keeps an audit trail of your API: who did what, to what, when, from where, and whether it worked. SOC 2, ISO 27001, HIPAA and PCI DSS all ask for one, and an API without it is hard to sell to the organisations that have to pass those audits.

  • Every change, recorded: POST, PUT, PATCH and DELETE requests by default, with any route able to record a read (viewing a patient's record, say) or leave a request out.
  • Who, verified: the consumer and tenant come from Consumer Identity after authentication, so a record names the client the token was issued to. Refused requests (401, 403) are recorded too.
  • What, in your terms: a controller adds the action (order.refund), the target (order 42) and details; the method, route pattern, arguments, query and body are recorded without being asked.
  • Personal data redacted: bodies, query strings, details and error messages go through the PII Redaction rules before they are written.
  • Tamper-evident: each record can carry an HMAC-SHA256 signature over its contents, which anyone holding the key can check.
  • Delivered where your security team looks: Kafka, OpenSearch, NATS JetStream and the log, at once if you like. A sink that fails never fails the request, and its records are logged in full so none is lost.

Installation

To install this middleware, you need access to the Enterprise Composer repository at https://php.pkgs.1x.ax/.

composer require kipchak/middleware-audit

It depends on PII Redaction, which Composer installs with it. Add the driver for each sink you use (Kafka, OpenSearch or NATS) to drivers/drivers.php.

Copy the sample config to config/kipchak.audit.php and add the middleware in middlewares/middlewares.php after the authentication middlewares, before PII Redaction:

use Kipchak\Middleware\Audit\Audit;
use Kipchak\Middleware\Redaction\Redaction;

Error::initialise($app);
// The authentication middlewares, e.g. AuthJwks::initialise($app);
Audit::initialise($app);
Redaction::initialise($app);

Slim runs the last-added middleware first, so the recorder runs before authentication and sees refused requests as well. To see who a request is really from, it also hooks the routes' invocation strategy, which runs after every middleware, route-level authentication included: the consumer recorded is the one the verified token names, and an exception is recorded before the Error middleware turns it into a 500.

Configuration

<?php

use function Kipchak\Core\env;

return [
    'enabled' => true,

    'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'],
    'paths' => ['*'],
    'except_paths' => ['/health'],

    'record' => [
        'request_body' => true,
        'response_body' => false,
        'query' => true,
        'headers' => ['x-request-id', 'user-agent'],
        'max_body_bytes' => 64 * 1024,
    ],

    // PII Redaction's 'logs' rules (field rules and detectors), 'responses', or false.
    'redaction' => 'logs',

    // At least 32 characters, kept secret; empty for unsigned records.
    'signing_key' => env('AUDIT_SIGNING_KEY', ''),

    'sinks' => [
        ['type' => 'kafka', 'connection' => 'default', 'topic' => 'audit'],
        ['type' => 'opensearch', 'connection' => 'default', 'index' => 'audit-{Y.m}', 'timeout' => 2],
        ['type' => 'nats', 'connection' => 'default', 'subject' => 'audit.{service}'],
        ['type' => 'log', 'level' => 'info'],
    ],
];

The config, and that each sink's driver connection exists, are checked when the worker starts.

The record

{
    "id": "01a19201-4903-740b-8b62-c313668591e2",
    "time": "2026-10-31T12:42:03.743691Z",
    "service": "orders-api",
    "action": "order.refund",
    "outcome": "success",
    "actor": { "consumer": "acme", "tenant": "org-1", "ip": "203.0.113.9", "user_agent": "pos/1.0" },
    "target": { "type": "order", "id": "42" },
    "request": {
        "id": "req-7f3a",
        "method": "POST",
        "path": "/v1/orders/42/refund",
        "route": "/v1/orders/{id}/refund",
        "route_name": null,
        "arguments": { "id": "42" },
        "query": { "reason": "damaged" },
        "headers": { "x-request-id": "req-7f3a", "user-agent": "pos/1.0" },
        "body": { "amount": 25, "email": "j***@example.com", "card_number": "************4242" }
    },
    "response": { "status": 200, "duration_ms": 12.48, "body": null },
    "error": null,
    "details": { "amount": 25.0 },
    "signature": "hmac-sha256:5c1f..."
}
  • id is a UUID version 7, so records sort by time. Sinks use it to make retries idempotent.
  • outcome is success (below 400), denied (401 or 403), failure (other 4xx) or error (5xx, or an exception).
  • action defaults to the method and route pattern, such as POST /v1/orders/{id}/refund.
  • actor.consumer and actor.tenant are the verified identity where the route ran. For a request refused before that, they are whatever the request itself carries (an API key or a header); a JWT that was refused names no one.
  • actor.ip is the connecting address. With Subashi Pro installed, client_ip (the same settings Subashi Pro takes) reads forwarding headers from trusted proxies.
  • error holds the exception's class and redacted message.

Describing what happened

use Kipchak\Middleware\Audit\Entry;

public function refund(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
    // ...
    Entry::of($request)?->action('order.refund')->target('order', $args['id'])->detail('amount', $amount);

    return Http\Response::json($response, $refund);
}
Entry::of($request)?->force()->action('patient.view')->target('patient', $id);   // record a read
Entry::of($request)?->skip();                                                     // leave this one out

Entry::of() returns null when the middleware is not running, hence the ?->.

Sinks

Every record goes to every sink.

SinkSettingsDelivery
kafkaconnection, topicQueued in librdkafka and delivered in the background, so a slow cluster never slows a request. Keyed by consumer, so one consumer's records stay in order.
opensearchconnection, index (a date in braces, PHP date() format, UTC: audit-{Y.m}), timeout (seconds, default 2)One document per record, the record id as document id, written with op_type=create so it is never overwritten.
natsconnection, subject (may contain {service})Published to JetStream and confirmed. The record id is the message id, so a retry is stored once. Needs a stream that captures the subject.
loglevel (default info)Through the Logger driver, on the audit channel, under audit_record, for a log shipper to route.
servicekeyYour own container entry implementing Kipchak\Middleware\Audit\Sink\Sink. MemorySink is there for your tests.

When a sink fails, the request carries on and the record is logged at error level, in full under audit_record, with the message Audit record was not delivered, so it can be found and replayed. The sink is then skipped for retry_after seconds (default 30, set per sink), its records going straight to the log, so an unreachable sink does not add a timeout to every request. Kafka reports a failure later, from its delivery report, and it is logged the same way.

For OpenSearch, map the record's string fields as keyword with an index template, so id, actor.consumer and target.id can be searched exactly, and use Index State Management to roll indices over and keep them read-only. Use kipchak/driver-opensearch 1.13 or later: earlier versions kept the OpenSearch client's default connection pool, which, in a long-lived worker, refused a node for 120 seconds to an hour after a single failure.

Verifying records

With a signing_key, each record's signature is an HMAC-SHA256 over its canonical JSON (keys sorted at every level). Checking a stored record:

use Kipchak\Middleware\Audit\Record\Signature;

Signature::verify($recordFromOpenSearch, $key);   // the decoded record, or its JSON exactly as stored

A changed field, a removed field or a different key gives false. Keep the key out of the systems that store the records, so that whoever can change a record cannot re-sign it.

Limits

  • The record is written after the route has run. If every sink fails, the action has still happened; the record is then only in the log.
  • Request bodies are taken from the parsed body (JSON and form bodies). Bodies over max_body_bytes are noted with their size, not copied.
  • Routes that are RequestHandlerInterface objects bypass the invocation strategy; their records have the identity the request itself carries.

Performance

A record costs one JSON encoding, the redaction of the request body, and the sinks' own time: microseconds for the log and Kafka, a round trip for NATS and OpenSearch. In testing on the Kipchak Enterprise runtime in worker mode, 2,400 concurrent requests wrote 2,400 records to Kafka, OpenSearch and NATS at once: the same records in all three, each exactly once, every signature valid. With OpenSearch stopped mid-traffic, requests carried on at full speed and its records went to the log.

Git Repository

The source code for this middleware is available on 1x.ax at https://1x.ax/mamluk/enterprise/kipchak/middlewares/audit.

Previous
PII Redaction