Files
michaelschiemer/src/Framework/ExceptionHandling/Audit/ExceptionAuditLogger.php
Michael Schiemer c93d3f07a2
All checks were successful
Test Runner / test-php (push) Successful in 31s
Deploy Application / deploy (push) Successful in 1m42s
Test Runner / test-basic (push) Successful in 7s
fix(Console): add void as valid return type for command methods
The MethodSignatureAnalyzer was rejecting command methods with void return
type, causing the schedule:run command to fail validation.
2025-11-26 06:16:09 +01:00

310 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\ExceptionHandling\Audit;
use App\Framework\Audit\AuditLogger;
use App\Framework\Audit\ValueObjects\AuditableAction;
use App\Framework\Audit\ValueObjects\AuditEntry;
use App\Framework\DateTime\Clock;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\Http\IpAddress;
use App\Framework\UserAgent\UserAgent;
use Throwable;
/**
* Exception Audit Logger
*
* Converts exceptions to audit entries for compliance and tracking.
* Only logs exceptions marked as auditable via ExceptionContextData.
*
* PHP 8.5+ with external context management via WeakMap.
*/
final readonly class ExceptionAuditLogger
{
public function __construct(
private AuditLogger $auditLogger,
private Clock $clock,
private ?ExceptionContextProvider $contextProvider = null
) {
}
/**
* Log exception as audit entry if auditable
*
* @param Throwable $exception Exception to log
* @param ExceptionContextData|null $context Optional context (if not provided, will be retrieved from provider)
*/
public function logIfAuditable(Throwable $exception, ?ExceptionContextData $context = null): void
{
try {
// Get context from provider if not provided
$context = $context ?? $this->getContext($exception);
// Skip if not auditable
if (! $this->isAuditable($context)) {
return;
}
// Convert exception to audit entry
$auditEntry = $this->convertToAuditEntry($exception, $context);
// Log to audit system
$this->auditLogger->log($auditEntry);
} catch (Throwable $e) {
// Audit logging should never break exception handling
// Log error but don't throw
error_log("Failed to log exception to audit: {$e->getMessage()}");
}
}
/**
* Check if exception is auditable
*
* @param ExceptionContextData|null $context Exception context
* @return bool True if exception should be audited
*/
private function isAuditable(?ExceptionContextData $context): bool
{
if ($context === null) {
// Default: log all exceptions if no context provided
return true;
}
// Check auditable flag (defaults to true)
return $context->auditable;
}
/**
* Convert exception to audit entry
*
* @param Throwable $exception Exception to convert
* @param ExceptionContextData $context Exception context
* @return AuditEntry Audit entry value object
*/
private function convertToAuditEntry(Throwable $exception, ExceptionContextData $context): AuditEntry
{
// Determine audit action from exception type and context
$action = $this->determineAuditAction($exception, $context);
// Determine entity type from exception class or context
$entityType = $this->determineEntityType($exception, $context);
// Extract entity ID from context data
$entityId = $this->extractEntityId($context);
// Build metadata from exception and context
$metadata = $this->buildMetadata($exception, $context);
// Extract IP address (already Value Object or string)
$ipAddress = $context->clientIp instanceof IpAddress
? $context->clientIp
: ($context->clientIp !== null && IpAddress::isValid($context->clientIp)
? IpAddress::from($context->clientIp)
: null);
// Extract user agent (already Value Object or string)
$userAgent = $context->userAgent instanceof UserAgent
? $context->userAgent
: ($context->userAgent !== null
? UserAgent::fromString($context->userAgent)
: null);
// Create audit entry as failed operation
return AuditEntry::failed(
clock: $this->clock,
action: $action,
entityType: $entityType,
entityId: $entityId,
errorMessage: $exception->getMessage(),
userId: $context->userId,
ipAddress: $ipAddress,
userAgent: $userAgent,
metadata: $metadata
);
}
/**
* Determine audit action from exception and context
*
* @param Throwable $exception Exception
* @param ExceptionContextData $context Context
* @return AuditableAction Audit action
*/
private function determineAuditAction(Throwable $exception, ExceptionContextData $context): AuditableAction
{
// Check if action is specified in metadata
if (isset($context->metadata['audit_action']) && is_string($context->metadata['audit_action'])) {
$actionValue = $context->metadata['audit_action'];
// Try to find matching enum case
foreach (AuditableAction::cases() as $case) {
if ($case->value === $actionValue) {
return $case;
}
}
}
// Determine from operation name
if ($context->operation !== null) {
$operation = $context->operation;
if (str_contains($operation, 'create')) {
return AuditableAction::CREATE;
}
if (str_contains($operation, 'update')) {
return AuditableAction::UPDATE;
}
if (str_contains($operation, 'delete')) {
return AuditableAction::DELETE;
}
if (str_contains($operation, 'read') || str_contains($operation, 'get')) {
return AuditableAction::READ;
}
}
// Determine from exception type
$exceptionClass = $exception::class;
if (str_contains($exceptionClass, 'Security') || str_contains($exceptionClass, 'Auth')) {
return AuditableAction::SECURITY_VIOLATION;
}
// Default: custom action
return AuditableAction::CUSTOM;
}
/**
* Determine entity type from exception and context
*
* @param Throwable $exception Exception
* @param ExceptionContextData $context Context
* @return string Entity type
*/
private function determineEntityType(Throwable $exception, ExceptionContextData $context): string
{
// Check if entity type is specified in metadata
if (isset($context->metadata['entity_type']) && is_string($context->metadata['entity_type'])) {
return $context->metadata['entity_type'];
}
// Try to extract from component
if ($context->component !== null) {
// Remove common suffixes
$component = $context->component;
$component = preg_replace('/Service$|Repository$|Manager$|Handler$/', '', $component);
return strtolower($component);
}
// Extract from exception class name
$className = $exception::class;
$parts = explode('\\', $className);
$shortName = end($parts);
// Remove "Exception" suffix
$entityType = preg_replace('/Exception$/', '', $shortName);
return strtolower($entityType);
}
/**
* Extract entity ID from context data
*
* @param ExceptionContextData $context Context
* @return string|null Entity ID
*/
private function extractEntityId(ExceptionContextData $context): ?string
{
// Check common entity ID keys
$commonKeys = ['entity_id', 'id', 'user_id', 'order_id', 'account_id', 'resource_id'];
foreach ($commonKeys as $key) {
if (isset($context->data[$key])) {
$value = $context->data[$key];
if (is_string($value) || is_numeric($value)) {
return (string) $value;
}
}
}
return null;
}
/**
* Build metadata from exception and context
*
* @param Throwable $exception Exception
* @param ExceptionContextData $context Context
* @return array<string, mixed> Metadata
*/
private function buildMetadata(Throwable $exception, ExceptionContextData $context): array
{
$metadata = [
'exception_class' => $exception::class,
'exception_code' => $exception->getCode(),
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
];
// Add context data
if (! empty($context->data)) {
$metadata['context_data'] = $context->data;
}
// Add operation and component
if ($context->operation !== null) {
$metadata['operation'] = $context->operation;
}
if ($context->component !== null) {
$metadata['component'] = $context->component;
}
// Add request/session IDs
if ($context->requestId !== null) {
$metadata['request_id'] = $context->requestId;
}
if ($context->sessionId !== null) {
$metadata['session_id'] = $context->sessionId;
}
// Add tags
if (! empty($context->tags)) {
$metadata['tags'] = $context->tags;
}
// Add previous exception if exists
if ($exception->getPrevious() !== null) {
$previous = $exception->getPrevious();
$metadata['previous_exception'] = [
'class' => $previous::class,
'message' => $previous->getMessage(),
];
}
// Merge with existing metadata (but exclude internal fields)
$excludeKeys = ['auditable', 'audit_action', 'entity_type'];
foreach ($context->metadata as $key => $value) {
if (! in_array($key, $excludeKeys, true)) {
$metadata[$key] = $value;
}
}
return $metadata;
}
/**
* Get context from provider
*
* @param Throwable $exception Exception
* @return ExceptionContextData|null Context or null if not found
*/
private function getContext(Throwable $exception): ?ExceptionContextData
{
if ($this->contextProvider === null) {
return null;
}
return $this->contextProvider->get($exception);
}
}