Skip to content

Add advanced audit APIs and tamper-evident hash chain - #8

Open
iamfarhad wants to merge 41 commits into
mainfrom
feature/audit-advanced-apis
Open

Add advanced audit APIs and tamper-evident hash chain#8
iamfarhad wants to merge 41 commits into
mainfrom
feature/audit-advanced-apis

Conversation

@iamfarhad

@iamfarhad iamfarhad commented May 17, 2026

Copy link
Copy Markdown
Owner

Summary

This PR implements the requested advanced audit features:

  • Add audit search query builder via AuditLogger::query() and AuditLogger::search()
  • Add analytics service for summaries, top actions, top causers, top changed entities, and changes per day
  • Add model-facing timeline and diff APIs: auditTimeline(), auditDiff(), and auditHistory()
  • Add restore/replay/rollback APIs: restoreFromAudit(), rollbackToAudit(), and previewRestore()
  • Add tamper-evident HMAC hash chain support with audit_hash and previous_hash
  • Add hash-chain verification via AuditLogger::verifyHashChain()
  • Add field redaction and transformer support with default mask/hash transformers
  • Fix PostgreSQL driver resolution in AuditLogger::getDriver() and service provider wiring
  • Align MySQL and PostgreSQL storage with hash-chain columns
  • Add feature tests for search, analytics, timeline/diff, restore/rollback, redaction/transformers, hash verification, and PostgreSQL driver resolution
  • Update README.md with advanced feature examples, security guidance, and comparison table
  • Add docs/advanced-audit-features.md with extended examples and hash-chain upgrade guidance

Backward compatibility / upgrade notes

  • Hash-chain mode remains disabled by default via AUDIT_HASH_CHAIN_ENABLED=false.
  • Existing audit tables continue to work with hashing disabled.
  • Existing projects that want hash-chain verification must first add nullable audit_hash and previous_hash columns 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.
  • Custom audit_table / table config 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:

  • Cast JSON/JSONB columns to text for PostgreSQL audit search in both AuditQuery and EloquentAuditLog::scopeSearch().
  • Use the audited model connection grammar when wrapping JSON columns, instead of the default DB connection grammar.
  • Recursively sort hash payload arrays before serialization for deterministic hashes across JSON and JSONB storage.
  • Decode Laravel base64: app keys before HMAC calculation.
  • Stream hash verification rows using lazy() with a manual checked counter.
  • Respect fully custom audit table names in model and driver table resolution.
  • Avoid overriding Eloquent's getKey() method from the Auditable trait.
  • Validate object-form audit logs before restore/rollback so a log from another model/table/entity cannot be applied accidentally.
  • Remove stale driver config caching while keeping table-existence caching.

Verification

Latest checks on head 9187616ea956e70c425b8eb5c1e0ab467c863ad9:

  • Coding Standards: passed
  • run-tests: passed across the full PHP/Laravel matrix

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/Models/EloquentAuditLog.php Outdated
Comment on lines +119 to +121
->orWhere('old_values', 'like', $like)
->orWhere('new_values', 'like', $like)
->orWhere('metadata', 'like', $like);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Comment thread src/Services/AuditHashVerifier.php Outdated
$query->where('entity_id', (string) $entityId);
}

$logs = $query->get();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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();

Comment thread src/Services/AuditQuery.php Outdated
Comment on lines +57 to +59
->orWhere('old_values', 'like', $like)
->orWhere('new_values', 'like', $like)
->orWhere('metadata', 'like', $like);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

As noted in the model scope, the LIKE operator cannot be used directly on jsonb columns in PostgreSQL. These columns must be cast to TEXT for the search to work on PostgreSQL databases.

Comment thread src/Models/EloquentAuditLog.php Outdated
Comment on lines +178 to +188
$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}";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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', ''));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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));
        }

Comment thread src/Drivers/MySQLDriver.php Outdated
Comment on lines +168 to +174
$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}";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
        }

Comment thread src/Drivers/PostgreSQLDriver.php Outdated
Comment on lines +168 to +174
$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}";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
        }

@iamfarhad
iamfarhad marked this pull request as ready for review May 17, 2026 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant