ADR-006: Audit logging
Table of contents
Status
Accepted
Date
2026-01-03
Context
Secret management systems require comprehensive audit trails for:
- Security incident investigation
- Compliance requirements (SOC 2, ISO 27001, GDPR)
- Debugging access issues
- Detecting unauthorized access attempts
The audit system must capture who accessed what, when, and from where, while being tamper-evident to ensure log integrity.
Problem statement
How should vault operations be logged to provide complete auditability while preventing log tampering?
Decision drivers
- Completeness: All operations must be logged
- Tamper evidence: Modifications to logs must be detectable
- Performance: Logging should not significantly impact operations
- Queryability: Logs must be filterable and searchable
- Extensibility: External systems should be able to react to events
Considered options
Option 1: TYPO3 sys_log
Use TYPO3's built-in logging system.
Pros:
- Already integrated
- Familiar to TYPO3 administrators
Cons:
- No tamper detection
- Limited structure for vault-specific data
- Mixed with other system logs
Option 2: External logging service
Send logs to external SIEM (Splunk, ELK, etc.).
Pros:
- Enterprise-grade features
- Centralized logging
Cons:
- Requires external infrastructure
- Network dependency
- Complex configuration
Option 3: Dedicated audit table with hash chain
Custom table with tamper-evident hash chain linking entries.
Pros:
- Self-contained, no external dependencies
- Cryptographic tamper evidence
- Structured for vault operations
- Combined with PSR-14 events for extensibility
Cons:
- Additional storage
- Hash chain verification overhead
Decision
We chose dedicated audit table with hash chain combined with PSR-14 events because:
- Self-contained: No external dependencies required
- Tamper-evident: SHA-256 hash chain detects modifications
- Extensible: PSR-14 events allow external system integration
- Structured: Purpose-built schema for vault operations
Implementation
Audit log entry structure
final readonly class AuditLogEntry implements JsonSerializable
{
public function __construct(
public ?int $uid,
public string $secretIdentifier,
public string $action, // create, read, update, delete, rotate
public bool $success,
public ?string $errorMessage,
public ?string $reason,
public int $actorUid,
public string $actorType, // backend, cli, api, scheduler
public string $actorUsername,
public string $actorRole,
public string $ipAddress,
public string $userAgent,
public string $requestId,
public string $previousHash, // Links to prior entry
public string $entryHash, // SHA-256 of this entry
public string $hashBefore, // Value checksum before
public string $hashAfter, // Value checksum after
public int $crdate,
public array $context, // Structured JSON metadata
) {}
}
Hash chain algorithm
Each entry's hash includes the previous entry's hash, creating an unbroken chain:
private function calculateEntryHash(AuditLogEntry $entry): string
{
$data = implode('|', [
$entry->uid,
$entry->secretIdentifier,
$entry->action,
$entry->actorUid,
$entry->crdate,
$entry->previousHash,
]);
return hash('sha256', $data);
}
public function verifyHashChain(?int $fromUid = null, ?int $toUid = null): array
{
$entries = $this->getEntriesInRange($fromUid, $toUid);
$errors = [];
foreach ($entries as $i => $entry) {
// Verify entry hash
$expectedHash = $this->calculateEntryHash($entry);
if ($entry->entryHash !== $expectedHash) {
$errors[$entry->uid] = 'Hash mismatch';
}
// Verify chain link
if ($i > 0 && $entry->previousHash !== $entries[$i - 1]->entryHash) {
$errors[$entry->uid] = 'Chain break';
}
}
return ['valid' => empty($errors), 'errors' => $errors];
}
Database schema
CREATE TABLE tx_nrvault_audit_log (
uid int(11) unsigned NOT NULL auto_increment,
-- What happened
secret_identifier varchar(255) NOT NULL,
action varchar(50) NOT NULL,
success tinyint(1) unsigned DEFAULT 1 NOT NULL,
error_message text,
reason text,
-- Who did it
actor_uid int(11) unsigned DEFAULT 0 NOT NULL,
actor_type varchar(50) NOT NULL,
actor_username varchar(255) NOT NULL,
actor_role varchar(100) NOT NULL,
-- Context
ip_address varchar(45) NOT NULL,
user_agent varchar(500) NOT NULL,
request_id varchar(100) NOT NULL,
-- Tamper detection
previous_hash varchar(64) NOT NULL,
entry_hash varchar(64) NOT NULL,
-- Change tracking
hash_before char(64) NOT NULL,
hash_after char(64) NOT NULL,
-- Metadata
crdate int(11) unsigned NOT NULL,
context text,
PRIMARY KEY (uid),
KEY secret_identifier (secret_identifier),
KEY action (action),
KEY actor_uid (actor_uid),
KEY crdate (crdate)
);
Logged operations
// All vault operations:
'create' // New secret stored
'read' // Secret retrieved/decrypted
'update' // Secret value changed
'delete' // Secret removed
'rotate' // Secret rotated with new value
'access_denied' // Permission check failed
'http_call' // VaultHttpClient API call
AuditLogService
final readonly class AuditLogService implements AuditLogServiceInterface
{
public function log(
string $identifier,
string $action,
bool $success,
?string $errorMessage = null,
?string $reason = null,
?string $hashBefore = null,
?string $hashAfter = null,
?AuditContextInterface $context = null,
): void;
public function query(
?AuditLogFilter $filter = null,
int $limit = 100,
int $offset = 0,
): array;
public function count(?AuditLogFilter $filter = null): int;
public function verifyHashChain(?int $fromUid = null, ?int $toUid = null): array;
public function export(?AuditLogFilter $filter = null): array;
}
Filtering and querying
$filter = AuditLogFilter::forSecret('my_api_key')
->withAction('read')
->withDateRange($startTime, $endTime)
->withSuccess(true);
$entries = $auditService->query($filter, limit: 50);
PSR-14 events
Events dispatched after logging for external integration:
SecretCreatedEvent // identifier, secret, actorUid
SecretAccessedEvent // identifier, actorUid, context
SecretUpdatedEvent // identifier, version, actorUid
SecretDeletedEvent // identifier, actorUid, reason
SecretRotatedEvent // identifier, newVersion, actorUid, reason
MasterKeyRotatedEvent // secretsReEncrypted, actorUid, rotatedAt
Example listener:
final class SlackNotifier
{
public function __invoke(SecretAccessedEvent $event): void
{
if ($event->getContext() === 'production') {
$this->slack->notify("Secret accessed: {$event->getIdentifier()}");
}
}
}
Context objects
Type-safe context for structured metadata:
final readonly class HttpCallContext implements AuditContextInterface
{
public function __construct(
public string $method,
public string $host,
public string $path,
public int $statusCode,
) {}
public static function fromRequest(
string $method,
string $url,
int $statusCode,
): self;
}
Consequences
Positive
- Tamper-evident: Hash chain detects any modifications
- Complete trail: All operations logged with full context
- Queryable: Efficient filtering by secret, action, actor, time
- Extensible: PSR-14 events enable SIEM integration
- Self-contained: No external dependencies required
- Verifiable: Chain integrity can be validated on demand
Negative
- Storage growth: Each operation creates a log entry
- Chain dependency: Corrupted entry affects chain verification
- No real-time alerts: Events are post-hoc (listeners can add alerts)
Risks
- Log table growth in high-volume environments
- Database access required for verification
Mitigation
- Provide log rotation/archival commands
- Index optimization for common queries
- Background verification jobs