Custom Audit Trail
Overview
Audit trail records explain who changed what, when, from where, and why. In TeaQL systems, audit customization should be connected to CustomUserContext because the context has the user identity, tenant, request metadata, locale, trace ID, and operation boundary.
Audit records are important for:
- Compliance
- Security review
- Debugging production issues
- Customer support
- Data recovery
- Workflow accountability
What Should Be Audited
Common audit targets:
| Event | Examples |
|---|---|
| Create | New customer, order, invoice, permission |
| Update | Field changes, status transitions, ownership changes |
| Delete | Logical delete through markToRemove() |
| Permission changes | Role assignment, access grant, access revoke |
| Sensitive reads | Viewing customer PII, payment data, compliance data |
| System operations | Ensure schema, import, export, scheduled job |
| Integration events | External callback, message consumption, sync operation |
Recommended Audit Record
A useful audit record should include:
| Field | Meaning |
|---|---|
traceId | Request or operation trace |
tenant | Tenant or business scope |
operatorId | Current user or system identity |
operatorName | Display name for review |
operation | Create, update, delete, read, approve, reject |
entityType | Domain object type |
entityId | Internal ID |
commonId | Business-facing ID, if available |
beforeValue | Previous value or field snapshot |
afterValue | New value or field snapshot |
reason | Business reason or workflow action |
clientIp | Request source |
createdAt | Audit time |
Recommended Design
Use an audit service behind CustomUserContext:
public interface AuditTrailService {
void record(AuditEvent event, CustomUserContext userContext);
}
Expose it from the context:
public class CustomUserContext extends UserContext {
public AuditTrailService auditTrailService() {
return getBean(AuditTrailService.class);
}
public void audit(AuditEvent event) {
auditTrailService().record(event, this);
}
}
Building Audit Events
Example event shape:
public class AuditEvent {
private String operation;
private String entityType;
private Object entityId;
private String commonId;
private Map<String, Object> beforeValue;
private Map<String, Object> afterValue;
private String reason;
// getters, setters, builder methods
}
Populate context fields during recording:
public void record(AuditEvent event, CustomUserContext userContext) {
event.setTraceId(userContext.traceId());
event.setTenant(userContext.currentTenantCode());
event.setOperatorId(userContext.currentUserId());
event.setOperatorName(userContext.currentUserName());
event.setClientIp(userContext.clientIp());
event.setCreatedAt(Instant.now());
auditRepository.save(event);
}
Auditing Entity Writes
The safest place to audit writes is around entity persistence:
public void afterSave(Object entity, ChangeSet changeSet) {
AuditEvent event = AuditEvent.builder()
.operation(changeSet.operation())
.entityType(entity.getClass().getSimpleName())
.entityId(changeSet.entityId())
.beforeValue(changeSet.beforeValues())
.afterValue(changeSet.afterValues())
.build();
audit(event);
}
Adapt ChangeSet to the change-tracking mechanism used in your project.
Auditing Business Operations
Not every audit entry should be field-level. Some operations need business-level records:
public void approveOrder(CustomUserContext userContext, String orderId, String reason) {
Order order = Q.orders().filterById(orderId).comment("Query orders").purpose("Load data").executeForOne(userContext);
order.approve(reason);
order.auditAs("Save order").save(userContext);
userContext.audit(AuditEvent.builder()
.operation("APPROVE_ORDER")
.entityType("Order")
.entityId(order.getId())
.commonId(order.getOrderNumber())
.reason(reason)
.build());
}
Business-level audit records are easier for auditors and support teams to understand.
Sensitive Data Handling
Audit records should be useful, but they must not leak secrets.
Avoid storing:
- Passwords
- Tokens
- Full payment card numbers
- Unmasked identity documents
- Large request bodies
- Unnecessary personal data
Mask or omit sensitive fields:
public Object auditValue(String fieldName, Object value) {
if ("password".equals(fieldName) || "token".equals(fieldName)) {
return "***";
}
if ("phone".equals(fieldName)) {
return maskPhone(value.toString());
}
return value;
}
Storage Strategy
Common audit storage options:
| Strategy | Use Case |
|---|---|
| Same database | Simple deployment, transaction consistency |
| Separate audit database | Large volume, retention policies |
| Append-only log | Compliance and tamper resistance |
| Message queue | Asynchronous audit processing |
| External SIEM | Security monitoring |
For critical compliance systems, prefer append-only or restricted-update audit storage.
Best Practices
- Put audit identity in
CustomUserContext. - Audit business operations, not only raw field changes.
- Use trace ID to connect audit records with logs.
- Mask sensitive values.
- Keep audit records immutable.
- Include tenant and operator identity.
- Write audit records in the same transaction when consistency is required.
- Use asynchronous audit only when the business can tolerate delayed records.
Testing Checklist
- Create, update, and delete operations produce audit records.
markToRemove()creates a delete audit event.- Audit records include user, tenant, trace ID, and client IP.
- Sensitive fields are masked.
- Failed transactions do not leave misleading audit records.
- Business operations such as approval or rejection are audit-visible.
- Audit data can be queried by entity, operator, tenant, and time.
Summary
Audit customization is one of the strongest reasons to centralize behavior in CustomUserContext. The context gives every TeaQL operation the identity and request metadata needed to produce reliable, explainable, and compliance-friendly audit records.