Skip to content

Audit & security

Three features for compliance, secret management and protection against runaway workloads.

audit

Status: ✅ Stable

What: Every write through the pipeline (create/update/delete) is recorded in audit_log — who, when, what, with before/after diff. Comes out of the box; your handlers don’t need to know about it.

How it works: Registered as a postSave system hook (priority 1002). Receives the SaveContext from the lifecycle pipeline ({ id, data, changes, previous, isNew, user }) and writes one row per entity mutation. Read it later via r.queryHandler — or build yourself an audit dashboard via the AuditQueries constant.

Performance: async append, in the same transaction as the write. No extra round-trip, but the audit write can slow a boot down if the table is huge and indices are missing — use a partition-by-month setup for high-volume tenants.

Example:

import { createAuditFeature, AuditQueries } from "@kumiko/bundled-features/audit";
await runDevApp({
features: [createAuditFeature(), myFeature],
});
// Custom query: last 10 audit events for the current tenant
r.queryHandler({
qn: "admin:audit:recent",
handler: async (ctx) => {
return ctx.dispatcher.query(AuditQueries.list, {
tenantId: ctx.tenantId,
limit: 10,
orderBy: { createdAt: "desc" },
});
},
});

secrets

Status: ✅ Stable

What: Per-tenant encrypted values — API keys for external services, OAuth client secrets, webhook tokens. Encrypted with a per-tenant key, which is itself encrypted with your master key (envelope encryption).

How it works: r.secret({ key: "stripe.api_key" }) declares a secret slot. The operator writes a value through the admin UI or directly into tenant_secrets (encrypted). In handler code: const stripe = await ctx.secrets.read("stripe.api_key"). Every read emits a tenant.secret.read event — you see in the audit trail which code accessed which secret slot when.

Rotation: built in. rotateJob re-encrypts all of a tenant’s secrets against a new master key — as a background job, no downtime. Turns a per-tenant master-key compromise into a routine ops task, not a disaster.

secrets vs config encrypted: config is for settings the operator maintains (SMTP password from a settings dialog), secrets is for external service credentials with rotation and audit requirements (Stripe API key, OAuth client secret). When a compliance auditor asks “who accessed the Stripe key when” — that’s secrets territory.

Recipe: samples/recipes/encrypted-tenant-config shows the end-to-end: schema, write, read, rotation.

Example:

import { createSecretsFeature } from "@kumiko/bundled-features/secrets";
await runDevApp({
features: [createSecretsFeature(), myFeature],
});
// Declare a secret slot + read it
export const stripeFeature = defineFeature("stripe", (r) => {
r.secret({ key: "stripe.api_key" });
r.writeHandler({
qn: "stripe:charge",
handler: async (ctx, { amount }) => {
const apiKey = await ctx.secrets.read("stripe.api_key");
const stripe = new Stripe(apiKey);
return stripe.charges.create({ amount, currency: "eur" });
},
});
});

rate-limiting

Status: ✅ Stable

What: Per-tenant + per-user throttle on write paths. Protects against bot attacks, runaway scripts, broken integrations hammering an endpoint.

How it works: Sliding window in Redis. On every handler invocation a pre-hook checks whether the counter is below the limit; if not → rate_limited error with retry-after. Defaults: 60 writes/minute per user, 600 per tenant — overridable per handler via r.writeHandler({ ..., rateLimit: { perUserPerMinute: 5 } }).

When custom limits: the login endpoint should be tighter (5/min/IP); public read endpoints often need no rate limiting at all. Implementation runs as a Lua script in Redis, atomic.

Example:

import { createRateLimitingFeature } from "@kumiko/bundled-features/rate-limiting";
await runDevApp({
features: [createRateLimitingFeature(), myFeature],
});
// Tighter limit per handler (login: 5/min/user)
r.writeHandler({
qn: "auth:login",
rateLimit: { perUserPerMinute: 5 },
handler: async (ctx, payload) => { /* ... */ },
});

See also