Vancetope — Audit System
Central audit pipeline for Vancetope Server (Brain + Anus). Producers emit typed audit events; an
AuditServicenormalizes, buffers, and fans them out to a configurable list ofAuditConsumerimplementations. Synchronous or asynchronous, switchable at runtime, with a bounded queue + drop counter and guaranteed drain on shutdown.
See also: llm-resource-management identity-credentials settings-system
1. Purpose & Scope
Problem. Various parts of the system already emit, or will want to emit, operations that are relevant for security and compliance review:
- Login/Logout, failed authentication attempts, token refresh
- Settings changes (Vault crypto, model aliases, bootstrap flags)
- Project/Tenant lifecycle (Create, Delete, Owner change)
- Permission grants & denies, role changes
- Vault access (PASSWORD setting decrypt, Keystore read)
- Kit Install/Update/Apply, code execution (scripts, tool calls with Exec)
- Service account operations
Currently, there is no unified collection point — anyone who wants to audit writes directly to the log or nothing at all. This is:
- Inconsistent — Loggers have different levels and filters, schema varies per caller.
- Not aggregatable — no central filtering, no common sink for SIEM integration.
- Prone to loss on shutdown — async loggers drop buffers without drain guarantee.
Solution. A @Service in vance-shared that clearly separates the two layers:
- Producer → AuditService: typed DTO, fire-and-forget.
- AuditService → AuditConsumer(s): central fan-out, bounded queue, lifecycle management.
Consumer implementations are interchangeable. For v1, there is NoopAuditConsumer (effectively: empty consumer list — no separate no-op bean needed) and an optional LogAuditConsumer. Later consumers (Mongo-Sink, SIEM-Forwarder, Webhook) will implement the same interface — no code changes to the service.
What it is not:
- Not a logger replacement. Loggers retain their purpose (debugging, operator visibility). Audit is what happened, who did it, with what outcome — not which code path was traversed.
- No persistence in v1. The default consumer is Noop;
LogAuditConsumeris opt-in. A Mongo consumer with retention policy is in scope for later, not for the initial implementation. - Not a real-time alerting engine. Audit is a collection point + fan-out — alerting builds on top of it, not within the service itself.
- No event versioning API. The DTO has a generic
detailsmap; we will introduce strict schema versions when a consumer requires it.
2. Architecture
┌─────────────────┐
│ Producers │ Services everywhere: AuthService, SettingsService,
│ (Brain, Anus) │ KitService, ProjectService, …
└────────┬────────┘
│ auditService.record(AuditEventDto)
▼
┌─────────────────────────────────────────────┐
│ AuditService │
│ • Set defaults (timestamp, severity) │
│ • Mode switch (SYNC ↔ ASYNC) │
│ • Bounded Queue + Drop-Counter │
│ • Lifecycle (PostConstruct / PreDestroy) │
└────────┬────────────────────────────────────┘
│ for each consumer: consumer.consume(event)
▼
┌────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ LogAuditCons. │ │ MongoAuditCons. │ │ SiemAdapter │
│ (opt-in) │ │ (later) │ │ (later) │
└────────────────┘ └─────────────────┘ └──────────────┘
Module Placement: vance-shared, package de.mhus.vance.shared.audit. This allows both vance-brain and vance-anus to use the same service without duplicate implementation — Anus runs as a short interactive shell, Brain as a long-running server.
3. API
3.1 AuditEventDto
@Data
@Builder
public class AuditEventDto {
Instant timestamp; // auto-set on record() if null
String action; // dotted, lowercase, verb-last
AuditSeverity severity; // INFO | WARN | CRITICAL — default INFO
String outcome; // "success" | "failure" | "denied" — nullable
String actor; // userId / service-account name — nullable
String tenantId; // scope — nullable
String projectId; // scope — nullable
String sessionId; // scope — nullable
String target; // affected entity ref, e.g. "user:_admin"
String message; // human-readable summary — nullable
Map<String, Object> details; // free-form payload — nullable
}
Naming convention for action: dotted, lowercase, verb-last. Examples:
| action | severity | outcome | target |
|---|---|---|---|
auth.login |
INFO | success | user:alice |
auth.login |
WARN | failure | user:alice |
auth.token.refresh |
INFO | success | user:alice |
settings.update |
INFO | success | setting:ai.default.provider |
settings.password.read |
WARN | success | setting:api.anthropic.key |
project.create |
INFO | success | project:acme/p-001 |
project.delete |
CRITICAL | success | project:acme/p-001 |
permission.grant |
WARN | success | user:_daemon-01,role:admin |
permission.check |
INFO | denied | tool:exec.shell |
kit.install |
INFO | success | kit:test-kit-v1@1.2.3 |
target format: <kind>:<identifier>. Kinds: user, setting, project, tenant, session, kit, tool, role, keystore-entry. Comma-separated if multiple targets are relevant.
details map: use sparingly — keep it JSON-serializable. Example settings.update:
details = Map.of(
"key", "ai.default.provider",
"oldValue", "openai",
"newValue", "anthropic")
3.2 AuditConsumer
@FunctionalInterface
public interface AuditConsumer {
void consume(AuditEventDto event);
}
Thread-safe — calls come from the caller thread (SYNC) or worker (ASYNC), and even concurrently during a mode switch. RuntimeExceptions are caught by the AuditService, logged, and counted via vance.exceptions{source=<ConsumerClass>,context=audit.consume} — a faulty consumer does not kill the pipeline.
3.3 AuditService
public class AuditService {
// === Generic Producer Path ===
void record(AuditEventDto event);
// === Specialized Emitters (one method per call site) ===
// Auth (Brain)
void authLoginSuccess(String tenantId, String username);
void authLoginFailure(String tenantId, String username, String reason);
void authTokenRefresh(String tenantId, String username);
void authLogout(String tenantId, @Nullable String username);
// Auth (Anus shell — single-user, no Tenant)
void anusLoginSuccess();
void anusLoginFailure();
// Settings
void settingsUpdate(String tenantId, String refType, String refId, String key, SettingType type);
void settingsPasswordRead(String tenantId, String refType, String refId, String key);
// Projects
void projectCreate(String tenantId, String projectName);
void projectClose(String tenantId, String projectName, String closedGroupId);
// LLM (engine path)
void llmEngineCall(@Nullable String tenantId, @Nullable String projectId,
@Nullable String sessionId, @Nullable String processId,
String engine, @Nullable String modelAlias,
@Nullable Integer tokensIn, @Nullable Integer tokensOut,
long elapsedMs, boolean success, @Nullable String errorMessage);
// LLM (LightLlmService path)
void llmLightCall(@Nullable String tenantId, @Nullable String projectId,
String recipe, @Nullable String modelAlias,
@Nullable Integer tokensIn, @Nullable Integer tokensOut,
long elapsedMs, boolean success, @Nullable String errorMessage);
// === Consumer Management (list is internal, mutable, Spring collects initially) ===
void addConsumer(AuditConsumer consumer);
boolean removeConsumer(AuditConsumer consumer);
List<AuditConsumer> getConsumers();
// === Mode Control ===
AuditMode getMode();
void setMode(AuditMode newMode); // global, threadsafe
int getQueueDepth();
}
The constructor collects all AuditConsumer beans from the Spring context via List<AuditConsumer> injection. At runtime, additional consumers can be added via addConsumer(...) — e.g., a test, a per-Tenant webhook, an on-demand activated sink. The list is a CopyOnWriteArrayList, iteration is safe against modification during dispatch.
Specialized methods instead of DTO building at the caller. Convention: for every producing call site, there is a dedicated method on AuditService that builds the AuditEventDto internally. Caller code remains a single line (auditService.authLoginSuccess(tenant, user)), action vocabulary and severity choice are centralized. New producers add a new method instead of directly using the builder.
4. Delivery Modes
| Mode | Behavior | When to use |
|---|---|---|
SYNC |
Caller thread dispatches inline. Producer bears full consumer cost. | Anus (short CLI sessions, no worker overhead, guaranteed flush before prompt return), tests, diagnostic phase with heavy consumers. |
ASYNC |
Event lands in bounded queue, a dedicated worker thread (vance-audit-worker, daemon) dispatches. |
Brain (long-running, producer latency must not be blocked by consumer I/O). |
Boot Sequence: The service always starts in SYNC. In @PostConstruct, it switches to the configured mode. This ensures that events emitted by other @PostConstruct methods during bean wiring are guaranteed to pass through — even if the worker is not yet running.
Shutdown Sequence (@PreDestroy):
- Call
setMode(SYNC)— flips the flag (new records go directly), then drains the existing queue on the caller thread. worker.shutdown()+awaitTermination(drainTimeoutMs)— waits for any ongoing dispatch in the worker.- Belt-and-suspenders: clear the queue again (in case a producer offered an event in the race window between flag flip and queue drain).
This prevents event loss — modulo hard-killing the JVM.
Runtime Switch ASYNC → SYNC (e.g., via JMX, admin endpoint, on-incident):
synchronized(modeLock) {
mode = SYNC // 1. Flip the flag — new producers go directly
while (poll() != null) dispatch(event) // 2. Drain the queue on the caller thread
}
Ordering is preserved within a producer thread; between different producer threads, audit ordering is generally not guaranteed (applies to SYNC and ASYNC alike — wall-clock on timestamp remains the anchor).
Runtime Switch SYNC → ASYNC: Start worker (if not already active), flip flag. Trivial operation.
5. Backpressure
The async queue is bounded (default 10,000, configurable via vance.audit.queueSize). If the queue is full:
record()does not block — audit must never hold up a producer.- Event is dropped.
- Counter
vance.audit.droppedis incremented (Micrometer, via MetricService). - WARN log with
actionandactorfor forensics.
Default 10,000 is sufficient in practice, far exceeding typical burst lengths. If events are regularly dropped, there is either a runaway producer (bug) or a consumer that is too slow (Mongo with fsync on every write, webhook without batching). Both are separate discussions, not a matter of queue size tuning.
Deliberately not implemented:
- Block-on-Full: would make audit a critical path.
- Spill-to-Disk: persistence is a consumer concern (see future Mongo consumer), not a service concern.
- Drop-Oldest instead of Drop-Newest: with a full queue, both are bad choices — we opt for the simpler variant.
6. Consumer Inventory
6.1 Default: Empty (Noop)
If neither LogAuditConsumer is enabled nor external AuditConsumer beans are present in the context, the consumer list is empty. record() only writes the metrics counters — no I/O, no log. This is v1’s “the default does nothing.”
(An explicit NoopAuditConsumer bean was deliberately not created — the empty list is semantically identical and saves a class.)
6.2 LogAuditConsumer (opt-in)
Activation:
vance:
audit:
consumers:
log:
enabled: true
Writes each event as SLF4J INFO to the de.mhus.vance.shared.audit.LogAuditConsumer logger. Schema:
AUDIT ts=2026-05-27T19:42:17.293Z action=auth.login severity=INFO outcome=success
actor=alice tenant=acme project=null session=null target=user:alice
message="login via password" details={ip=10.0.1.4, ua="Mozilla/5.0…"}
For stable action vocabulary phases or dev environments. Default off in Production, to prevent immature action names from flooding the log.
6.3 Planned Consumers (not in v1)
| Consumer | Sink | Notes |
|---|---|---|
MongoAuditConsumer |
audit_log Collection in MongoDB |
With retention TTL, indexed on tenantId, actor, action, timestamp. Webview-capable. |
WebhookAuditConsumer |
HTTPS-POST to external endpoint | Per-Tenant config, batch + retry, HMAC signature. SIEM integration. |
JsonlAuditConsumer |
Rotating .jsonl file on disk |
For air-gapped deployments without external backend. |
All implement the same interface. Order of registration determines dispatch order (irrelevant, as consumers are independent of each other).
7. Configuration (application.yml)
vance:
audit:
mode: async # sync | async — Mode after @PostConstruct
queueSize: 10000 # bounded; drop + counter when full
drainTimeoutMs: 5000 # @PreDestroy drain budget
consumers:
log:
enabled: false # Enable LogAuditConsumer
Brain Default: mode: async, consumers.log.enabled: false.
Anus Default: mode: sync, consumers.log.enabled: false. Anus runs as a short interactive shell — no worker overhead needed, each command flushes its audit trail before the next prompt.
The properties class is AuditServiceProperties with @ConfigurationProperties(prefix = "vance.audit") and is registered in VanceBrainApplication and VanceAnusApplication via @EnableConfigurationProperties (same convention as WorkspaceProperties).
8. Metrics
| Metric | Type | Tags | Description |
|---|---|---|---|
vance.audit.events |
Counter | severity={info,warn,critical} |
All accepted events. |
vance.audit.dropped |
Counter | — | Events dropped due to full queue. |
vance.exceptions |
Counter | source=<ConsumerClass>, context=audit.consume |
Thrown consumer exceptions (central exception counter, see MetricService). |
Tag cardinality remains low: severity has 3 values. No tenantId/actor/action as a tag — that is the Prometheus footgun. To see action distribution, query the audit sink (Mongo consumer later).
9. Producer Patterns
Rule: Producers call the specialized method — they do not build AuditEventDtos themselves. The record(AuditEventDto) API exists for custom sites (tests, new producers in development), not as the preferred path.
// Auth — login success/failure
auditService.authLoginSuccess(tenant, username);
auditService.authLoginFailure(tenant, username, "bad-password");
// Settings — caller passes SettingType; Severity is determined by the service
auditService.settingsUpdate(tenantId, refType, refId, key, SettingType.PASSWORD);
auditService.settingsPasswordRead(tenantId, refType, refId, key);
// Project — close is CRITICAL, create is INFO (also set by the service)
auditService.projectCreate(tenantId, projectName);
auditService.projectClose(tenantId, projectName, closedGroupId);
// LLM — Engine vs. Light. Token counts come from ChatResponse, the caller
// extracts them (vance-shared must not depend on langchain4j).
auditService.llmEngineCall(tenantId, projectId, sessionId, processId,
engineName, modelAlias, tokensIn, tokensOut, elapsedMs, success, errorMsg);
auditService.llmLightCall(tenantId, projectId,
recipeName, modelAlias, tokensIn, tokensOut, elapsedMs, success, errorMsg);
New Producers (Convention):
- Write a dedicated method on
AuditService(one method per call site). - The method builds the
AuditEventDtointernally — noBuildercalls in service code. actionfollows the dotted convention, severity belongs to the action definition (not the caller).- Do not expose sensitive values (passwords, tokens, plaintext secrets) in
details/message. - For “actor”-missing sites (e.g., bootstrap calls without SecurityContext):
actor=null— separate PR to plumb SecurityContext.
10. What is deliberately excluded (v1)
- Async persistence with at-least-once guarantee. Hard crash losses are accepted; those who do not accept this should write a synchronous consumer.
- Per-event filtering in the service. Filtering is a consumer concern — if
LogAuditConsumershould only log CRITICAL events, it checks that itself. Generic service-level filtering will only be added if multiple consumers require the same filtering behavior. - Action schema registry. Action names are free strings with a convention; no compile-time enumeration. If the action list becomes stable, a registry with lookup for documentation/tooling can be added later.
- Multi-consumer routing/topics. There is a flat list, no pub-sub with subscriptions. If Consumer X only wants
auth.*, it filters itself. - Async worker pool > 1. A single thread is sufficient for realistic volumes. More workers introduce ordering breaks between producers and are not worth the cost/benefit.
11. Implementation Status
Available (as of 2026-05-28):
Service Infrastructure (vance-shared/src/main/java/de/mhus/vance/shared/audit/):
AuditService(@Service, Spring-managed) — genericrecord(...)path + 12 specialized methods.AuditEventDto,AuditConsumer,AuditMode,AuditSeverity.LogAuditConsumer(@ConditionalOnProperty).AuditServiceProperties(@ConfigurationProperties).- Properties registered in
VanceBrainApplicationandVanceAnusApplication. - Default YAML blocks in both
application.yml(Brain ASYNC, Anus SYNC).
Producer Integrations (all live with tests):
| Producer | File | Audit Method(s) |
|---|---|---|
| Brain Login (success) | AccessController.createToken |
authLoginSuccess |
| Brain Login (failure) | AccessController.rejectLogin (Helper for all 9 reject paths) |
authLoginFailure |
| Brain Token Refresh | AccessController.refreshToken |
authTokenRefresh |
| Brain Logout | AccessController.logout |
authLogout |
| Anus Shell Login | anus/AccessService.login |
anusLoginSuccess/anusLoginFailure |
| Anus Shell Logout | anus/AccessService.logout |
authLogout (with null-Tenant) |
| Settings Write | SettingService.set (central funnel — all typed setters route here including setEncryptedPassword) |
settingsUpdate |
| Vault Decrypt | SettingService.getDecryptedPassword |
settingsPasswordRead |
| Project Create | ProjectService.create (shared layer — Brain + Anus + Bootstrap use the same path) |
projectCreate |
| Project Close | ProjectService.close |
projectClose |
| LLM Engine Call | EngineChatFactory.applyDefaults Trace-Writer-Lambda — fires for every roundtrip (success + error via resp==null), regardless of ctx.traceLlm() |
llmEngineCall |
| LLM Light Call | LightLlmServiceImpl.buildChatModel Trace-Writer-Lambda — same mechanism, recipe.name() as target |
llmLightCall |
Coverage (488 vance-shared + 2007 vance-brain + 44 vance-anus Tests green):
AuditServiceTest(21 Tests): generic path + each specialized method + SYNC/ASYNC Dispatch, mode switch + queue drain, shutdown drain, drop-on-full, consumer exception isolation, empty-list noop, default-fill timestamp/severity.- Producer tests (
AccessControllerTest,AccessServiceTest(Anus),LightLlmServiceImplTest) — constructor extension considered; tests green.
Open for later PRs:
- Actor Plumbing.
SettingServiceandProjectService.create/closecall Audit withactor=null— the current User is not available in the shared layer. SecurityContext propagation is a separate PR. - MongoAuditConsumer. Persistent Sink with TTL index. Only then is a webview editor worthwhile.
- Webview in Brain (editor “audit”) — filter/search on the Mongo sink.
- Permission Audit.
PermissionService.checkdoes not emit anything yet. If audit is added there, namingpermission.check+ outcome=denied/granted. - Kit Lifecycle Audit.
kit.install/update/apply/export— analogous to Project. - OAuth Audit. Connect/disconnect/refresh paths in
OAuthController/OAuthTokenRefresher.