The MethodSignatureAnalyzer was rejecting command methods with void return type, causing the schedule:run command to fail validation.
310 lines
10 KiB
PHP
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);
|
|
}
|
|
}
|