Add advanced audit APIs and tamper-evident hash chain - #8
Conversation
There was a problem hiding this comment.
Code Review
This pull request significantly expands the audit logging capabilities by introducing a fluent search API, analytics, model timelines with field-level diffs, and restoration/rollback workflows. It also adds a tamper-evident hash chain for log integrity and a field transformation system for data redaction and masking. Key feedback items include addressing PostgreSQL compatibility issues when searching jsonb columns, ensuring deterministic hashing by sorting JSON keys, optimizing memory usage during large-scale hash verification, and refining the configuration logic to better support fully custom table names.
| ->orWhere('old_values', 'like', $like) | ||
| ->orWhere('new_values', 'like', $like) | ||
| ->orWhere('metadata', 'like', $like); |
There was a problem hiding this comment.
The LIKE operator is not compatible with jsonb columns in PostgreSQL. Since this package supports PostgreSQL, searching through old_values, new_values, and metadata will result in a database error (e.g., operator does not exist: jsonb ~~ unknown).
You should detect the connection driver and cast these columns to TEXT when using PostgreSQL.
| 'created_at' => $log->getCreatedAt()->format(DATE_ATOM), | ||
| ]; | ||
|
|
||
| $serialized = json_encode($payload, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); |
There was a problem hiding this comment.
To ensure the tamper-evident hash is deterministic across different database systems, all arrays in the payload (like old_values and new_values) should be recursively sorted by key before serialization. PostgreSQL's jsonb type does not guarantee key order preservation, which can lead to hash mismatches when verifying logs retrieved from the database.
| $query->where('entity_id', (string) $entityId); | ||
| } | ||
|
|
||
| $logs = $query->get(); |
There was a problem hiding this comment.
Loading all audit logs into memory using get() can cause memory exhaustion if an entity has a large history. Use lazy() or chunk() to process records in a memory-efficient manner.
Note: If you switch to lazy(), you should also replace $logs->count() at the end of the method with a manual counter incremented inside the loop to avoid re-running the query.
$logs = $query->lazy();| ->orWhere('old_values', 'like', $like) | ||
| ->orWhere('new_values', 'like', $like) | ||
| ->orWhere('metadata', 'like', $like); |
| $tableName = $entityConfig['audit_table'] ?? $entityConfig['table'] ?? Str::plural($className); | ||
| $tablePrefix = $driverConfig['table_prefix'] ?? 'audit_'; | ||
| $tableSuffix = $driverConfig['table_suffix'] ?? '_logs'; | ||
|
|
||
| // Handle pluralization | ||
| $tableName = Str::plural($className); | ||
|
|
||
| $config = self::getConfigCache(); | ||
| $tablePrefix = $config['drivers']['mysql']['table_prefix'] ?? 'audit_'; | ||
| $tableSuffix = $config['drivers']['mysql']['table_suffix'] ?? '_logs'; | ||
| if (! str_starts_with($tableName, $tablePrefix)) { | ||
| $tableName = "{$tablePrefix}{$tableName}"; | ||
| } | ||
|
|
||
| $table = "{$tablePrefix}{$tableName}{$tableSuffix}"; | ||
| if (! str_ends_with($tableName, $tableSuffix)) { | ||
| $tableName = "{$tableName}{$tableSuffix}"; | ||
| } |
There was a problem hiding this comment.
The current logic forces the table_prefix and table_suffix even when a specific audit_table or table name is provided in the entity configuration. This prevents users from using completely custom table names.
It is better to only apply the prefix and suffix when generating the default table name.
$tablePrefix = $driverConfig['table_prefix'] ?? 'audit_';
$tableSuffix = $driverConfig['table_suffix'] ?? '_logs';
$tableName = $entityConfig['audit_table'] ?? $entityConfig['table'] ?? null;
if ($tableName === null) {
$tableName = $tablePrefix . Str::plural($className) . $tableSuffix;
}|
|
||
| $serialized = json_encode($payload, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); | ||
| $algorithm = (string) config('audit-logger.security.hashing.algorithm', 'sha256'); | ||
| $key = (string) config('audit-logger.security.hashing.key', config('app.key', '')); |
There was a problem hiding this comment.
Laravel's APP_KEY often starts with a base64: prefix. hash_hmac expects the raw binary key. If the prefix is present, you should decode the key to ensure the HMAC is computed correctly.
$key = (string) config('audit-logger.security.hashing.key', config('app.key', ''));
if (str_starts_with($key, 'base64:')) {
$key = base64_decode(substr($key, 7));
}| $tableName = $entityConfig['audit_table'] ?? $entityConfig['table'] ?? Str::plural(Str::snake(class_basename($entityType))); | ||
| if (! str_starts_with($tableName, $this->tablePrefix)) { | ||
| $tableName = "{$this->tablePrefix}{$tableName}"; | ||
| } | ||
| if (! str_ends_with($tableName, $this->tableSuffix)) { | ||
| $tableName = "{$tableName}{$this->tableSuffix}"; | ||
| } |
There was a problem hiding this comment.
The logic for custom table names forces the prefix and suffix even if a full table name is provided in the config. This should be refactored to respect custom names as-is.
$tableName = $entityConfig['audit_table'] ?? $entityConfig['table'] ?? null;
if ($tableName === null) {
$tableName = $this->tablePrefix . Str::plural(Str::snake(class_basename($entityType))) . $this->tableSuffix;
}| $tableName = $entityConfig['audit_table'] ?? $entityConfig['table'] ?? Str::plural(Str::snake(class_basename($entityType))); | ||
| if (! str_starts_with($tableName, $this->tablePrefix)) { | ||
| $tableName = "{$this->tablePrefix}{$tableName}"; | ||
| } | ||
| if (! str_ends_with($tableName, $this->tableSuffix)) { | ||
| $tableName = "{$tableName}{$this->tableSuffix}"; | ||
| } |
There was a problem hiding this comment.
The logic for custom table names forces the prefix and suffix even if a full table name is provided in the config. This should be refactored to respect custom names as-is.
$tableName = $entityConfig['audit_table'] ?? $entityConfig['table'] ?? null;
if ($tableName === null) {
$tableName = $this->tablePrefix . Str::plural(Str::snake(class_basename($entityType))) . $this->tableSuffix;
}
Summary
This PR implements the requested advanced audit features:
AuditLogger::query()andAuditLogger::search()auditTimeline(),auditDiff(), andauditHistory()restoreFromAudit(),rollbackToAudit(), andpreviewRestore()audit_hashandprevious_hashAuditLogger::verifyHashChain()AuditLogger::getDriver()and service provider wiringREADME.mdwith advanced feature examples, security guidance, and comparison tabledocs/advanced-audit-features.mdwith extended examples and hash-chain upgrade guidanceBackward compatibility / upgrade notes
AUDIT_HASH_CHAIN_ENABLED=false.audit_hashandprevious_hashcolumns to every existing audit table. The upgrade guide includes a sample migration.verifyHashChain()returns structured failures for missing tables/columns instead of throwing database exceptions.audit_table/tableconfig values are respected as-is; prefix/suffix generation is only used for default audit table names.Review fixes
Addressed Gemini Code Assist feedback and follow-up maintainer review issues:
AuditQueryandEloquentAuditLog::scopeSearch().base64:app keys before HMAC calculation.lazy()with a manual checked counter.getKey()method from theAuditabletrait.Verification
Latest checks on head
9187616ea956e70c425b8eb5c1e0ab467c863ad9:Coding Standards: passedrun-tests: passed across the full PHP/Laravel matrix