Skip to content

feat(audit): support optional audit records for entity record views #388

@KofTwentyTwo

Description

@KofTwentyTwo

Summary

Add opt-in support for creating audit records when a user views entity records. Currently audits only cover insert, update, and delete operations. Some use cases require tracking read access for compliance, security, or usage analytics.

Proposed Behavior

  • Opt-in at the same level that all audits are currently opted into (not a separate table-level flag)
  • When enabled, a GET/view of a single record creates an audit entry
  • Explore adding "table-level audits" that would cover both individual record views and list/query views on a table
  • Audit detail level should be configurable (e.g., just the fact of the view vs. which fields were accessed)

Considerations

  • Performance impact of auditing reads (far more frequent than writes)
  • Should integrate with the existing pluggable audit handler system (PR feat(audit): add pluggable audit handler system #356)
  • Table-level audit scope: individual record views vs. list/query views may warrant different audit granularity
  • May want to support additional optional audit types in the future beyond just "view"

ADR: Design and Approach

Decision: Separate readAuditLevel field on QAuditRules

Read vs write audit granularity are independent dimensions. A table may want FIELD-level write auditing but only GET-level read auditing. Mixing these into a single enum creates combinatorial explosion. Adding a separate readAuditLevel field keeps them orthogonal.

Rejected alternatives:

  • Adding to AuditLevel enum -- conflates two dimensions, breaks single-responsibility
  • Separate QReadAuditRules class -- fragments configuration, users expect one place for audit config

New Enum: ReadAuditLevel

NONE          -- No read auditing (default)
GET           -- Audit single-record views only
GET_AND_QUERY -- Audit both individual views and table queries

Extend QAuditRules

Add private ReadAuditLevel readAuditLevel; field. Defaults to null (treated as NONE).

new QAuditRules()
   .withAuditLevel(AuditLevel.FIELD)
   .withReadAuditLevel(ReadAuditLevel.GET)

New Action: ReadAuditAction

Static convenience method executeAsync() called from GetAction/QueryAction. Captures QContext, submits to AuditHandlerExecutor thread pool. Never blocks or fails the read operation.

For GET (single record view):

  • One audit record per viewed record
  • audit.message = "Record was Viewed"
  • audit.recordId = primary key of viewed record
  • No auditDetail records

For QUERY (table/list view):

  • One audit record per returned record
  • audit.message = "Record was included in Query result"
  • audit.recordId = primary key of each returned record
  • No auditDetail records

Performance (critical for queries):

  • executeAsync() is fire-and-forget: captures only primary keys + QContext, submits to thread pool, returns immediately. Query response goes back to UI before any audit I/O occurs.
  • Background thread builds all audit records in memory, then does a single bulk InsertAction (1 DB call, not N).
  • For piped/streaming queries: collect primary keys during pipe flow, audit once after pipe flushes.
  • Only primary keys are captured, not full QRecord objects, keeping memory footprint small.

New Handler Type: AuditHandlerType.READ

Add READ to the existing AuditHandlerType enum. New ReadAuditHandlerInterface with handleReadAudit(ReadAuditHandlerInput). Allows custom read audit processing (e.g., streaming to external compliance systems).

Integration Points

GetAction -- after postRecordActions(), fire async read audit if record found.

QueryAction -- after postRecordActions(), fire async read audit. For piped queries, audit fires once after pipe flushes.

InputSource guard: Read audits only fire when InputSource == QInputSource.USER. System/process reads are not audited. This prevents audit cascading and ensures only user-facing operations are tracked.

Recursion Prevention

Two layers: (1) InputSource guard -- system reads during audit processing have SYSTEM input source, (2) audit system tables have no readAuditLevel configured.

Storage

Reuses existing audit/auditDetail tables. No schema changes. Read vs write audits distinguished by message text ("Viewed" / "included in Query result" vs "Inserted/Edited/Deleted").

Files to Create

File Description
ReadAuditLevel.java Enum: NONE, GET, GET_AND_QUERY
ReadAuditAction.java Core action: checks rules, builds audit records, fires async
ReadAuditInput.java Input model for ReadAuditAction
ReadAuditHandlerInterface.java Handler interface for custom read audit processing
ReadAuditHandlerInput.java Input passed to read audit handlers
ReadAuditActionTest.java Unit tests

Files to Modify

File Change
QAuditRules.java Add readAuditLevel field
AuditHandlerType.java Add READ value
AuditHandlerExecutor.java Add executeReadHandlers()
GetAction.java Add async read audit call
QueryAction.java Add async read audit call
QInstanceValidator.java Validate READ handlers + readAuditLevel consistency

Backwards Compatibility

Fully backwards compatible. readAuditLevel defaults to null/NONE. No schema changes. No new dependencies.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

Status

Backlog

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions