diff --git a/.env.example b/.env.example index 81c62d235..09fd3d1d3 100644 --- a/.env.example +++ b/.env.example @@ -246,7 +246,7 @@ OTEL_TRACES_SAMPLER_PARENT=false # OTEL_INSTRUMENTATION_VIEW=true # OTEL_INSTRUMENTATION_LIVEWIRE=true # OTEL_INSTRUMENTATION_CONSOLE=true - +OTEL_AUDIT_ELASTICSEARCH_INDEX=logs-audit # SWAGGER CONFIG L5_SWAGGER_CONST_HOST=${APP_URL} diff --git a/app/Audit/AuditEventListener.php b/app/Audit/AuditEventListener.php index 0410f51e7..85f082ac4 100644 --- a/app/Audit/AuditEventListener.php +++ b/app/Audit/AuditEventListener.php @@ -15,36 +15,72 @@ * limitations under the License. **/ +use App\Audit\AuditLogOtlpStrategy; use Doctrine\ORM\Event\OnFlushEventArgs; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Log; /** * Class AuditEventListener * @package App\Audit */ -class AuditEventListener { +class AuditEventListener +{ public function onFlush(OnFlushEventArgs $eventArgs): void { $em = $eventArgs->getObjectManager(); $uow = $em->getUnitOfWork(); + // Strategy selection based on environment configuration + $strategy = $this->getAuditStrategy($em); - $strategy = new AuditLogStrategy($em); - - foreach ($uow->getScheduledEntityInsertions() as $entity) { - $strategy->audit($entity, null, $strategy::EVENT_ENTITY_CREATION); + if (!$strategy) { + return; // No audit strategy enabled } - foreach ($uow->getScheduledEntityUpdates() as $entity) { - $change_set = $uow->getEntityChangeSet($entity); - $strategy->audit($entity, $change_set, $strategy::EVENT_ENTITY_UPDATE); - } + try { + foreach ($uow->getScheduledEntityInsertions() as $entity) { + $strategy->audit($entity, [], AuditLogOtlpStrategy::EVENT_ENTITY_CREATION); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + $strategy->audit($entity, $uow->getEntityChangeSet($entity), AuditLogOtlpStrategy::EVENT_ENTITY_UPDATE); + } + + foreach ($uow->getScheduledEntityDeletions() as $entity) { + $strategy->audit($entity, [], AuditLogOtlpStrategy::EVENT_ENTITY_DELETION); + } - foreach ($uow->getScheduledEntityDeletions() as $entity) { - $strategy->audit($entity, null, $strategy::EVENT_ENTITY_DELETION); + foreach ($uow->getScheduledCollectionUpdates() as $col) { + $strategy->audit($col, [], AuditLogOtlpStrategy::EVENT_COLLECTION_UPDATE); + } + } catch (\Exception $e) { + Log::error('Audit event listener failed', [ + 'error' => $e->getMessage(), + 'strategy_class' => get_class($strategy), + 'trace' => $e->getTraceAsString(), + ]); } + } - foreach ($uow->getScheduledCollectionUpdates() as $col) { - $strategy->audit($col, null, $strategy::EVENT_COLLECTION_UPDATE); + /** + * Get the appropriate audit strategy based on environment configuration + */ + private function getAuditStrategy($em) + { + // Check if OTLP audit is enabled + if (config('opentelemetry.enabled', false)) { + try { + return App::make(AuditLogOtlpStrategy::class); + } catch (\Exception $e) { + Log::warning('Failed to create OTLP audit strategy, falling back to database', [ + 'error' => $e->getMessage() + ]); + } } + + // Use database strategy (either as default or fallback) + return new AuditLogStrategy($em); + } -} \ No newline at end of file +} diff --git a/app/Audit/AuditLogOtlpStrategy.php b/app/Audit/AuditLogOtlpStrategy.php new file mode 100644 index 000000000..0142e8aca --- /dev/null +++ b/app/Audit/AuditLogOtlpStrategy.php @@ -0,0 +1,200 @@ +enabled = config('opentelemetry.enabled', false); + $this->elasticIndex = config('opentelemetry.logs.elasticsearch_index', 'logs-audit'); + } + + public function audit($subject, array $change_set, string $event_type): void + { + if (!$this->enabled) { + return; + } + + try { + $entity = $this->resolveAuditableEntity($subject); + if (is_null($entity)) { + return; + } + + $resource_server_ctx = App::make(\models\oauth2\IResourceServerContext::class); + $user_id = $resource_server_ctx->getCurrentUserId(); + $user_email = $resource_server_ctx->getCurrentUserEmail(); + + $formatter = null; + switch ($event_type) { + case self::EVENT_COLLECTION_UPDATE: + $child_entity = null; + if (count($subject) > 0) { + $child_entity = $subject[0]; + } + if (is_null($child_entity) && count($subject->getSnapshot()) > 0) { + $child_entity = $subject->getSnapshot()[0]; + } + $child_entity_formatter = $child_entity != null ? ChildEntityFormatterFactory::build($child_entity) : null; + $formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter); + break; + case self::EVENT_ENTITY_CREATION: + $formatter = new EntityCreationAuditLogFormatter(); + break; + case self::EVENT_ENTITY_DELETION: + $child_entity_formatter = ChildEntityFormatterFactory::build($subject); + $formatter = new EntityDeletionAuditLogFormatter($child_entity_formatter); + break; + case self::EVENT_ENTITY_UPDATE: + $child_entity_formatter = ChildEntityFormatterFactory::build($subject); + $formatter = new EntityUpdateAuditLogFormatter($child_entity_formatter); + break; + } + + $description = null; + if ($formatter) { + $description = $formatter->format($subject, $change_set); + } + + $auditData = $this->buildAuditLogData($entity, $subject, $change_set, $event_type, $user_id, $user_email); + if (!empty($description)) { + $auditData['audit.description'] = $description; + } + Logger::info($this->getLogMessage($event_type), $auditData); + + } catch (\Exception $ex) { + Logger::warning('OTEL audit logging error: ' . $ex->getMessage(), [ + 'exception' => $ex, + 'subject_class' => get_class($subject), + 'event_type' => $event_type, + ]); + } + } + + private function resolveAuditableEntity($subject) + { + if ($subject instanceof SummitEvent) return $subject; + + if ($subject instanceof PersistentCollection && $subject->getOwner() instanceof SummitEvent) { + return $subject->getOwner(); + } + + if ($subject instanceof PresentationAction || $subject instanceof PresentationExtraQuestionAnswer) { + return $subject->getPresentation(); + } + + if ($subject instanceof SummitAttendeeBadgePrint) { + return $subject->getBadge(); + } + + return null; + } + + private function buildAuditLogData($entity, $subject, array $change_set, string $event_type, ?string $user_id, ?string $user_email): array + { + $auditData = [ + 'audit.action' => $this->mapEventTypeToAction($event_type), + 'audit.entity' => class_basename($entity), + 'audit.entity_id' => (string) (method_exists($entity, 'getId') ? $entity->getId() : 'unknown'), + 'audit.entity_class' => get_class($entity), + 'audit.timestamp' => now()->toISOString(), + 'audit.event_type' => $event_type, + 'auth.user.id' => $user_id, + 'auth.user.email' => $user_email, + 'elasticsearch.index' => $this->elasticIndex, + ]; + + switch ($event_type) { + case self::EVENT_COLLECTION_UPDATE: + if ($subject instanceof PersistentCollection) { + $auditData['audit.collection_type'] = $this->getCollectionType($subject); + $auditData['audit.collection_count'] = count($subject); + $auditData['audit.collection_changes'] = $this->getCollectionChanges($subject, $change_set); + } + break; + } + + return $auditData; + } + + private function getCollectionType(PersistentCollection $collection): string + { + if (empty($collection) && empty($collection->getSnapshot())) { + return 'unknown'; + } + + $item = !empty($collection) ? $collection->first() : $collection->getSnapshot()[0]; + return class_basename($item); + } + + private function getCollectionChanges(PersistentCollection $collection, array $change_set): array + { + return [ + 'current_count' => count($collection), + 'snapshot_count' => count($collection->getSnapshot()), + 'is_dirty' => $collection->isDirty(), + ]; + } + + private function mapEventTypeToAction(string $event_type): string + { + return match($event_type) { + self::EVENT_ENTITY_CREATION => self::ACTION_CREATE, + self::EVENT_ENTITY_UPDATE => self::ACTION_UPDATE, + self::EVENT_ENTITY_DELETION => self::ACTION_DELETE, + self::EVENT_COLLECTION_UPDATE => self::ACTION_COLLECTION_UPDATE, + default => self::ACTION_UNKNOWN + }; + } + + private function getLogMessage(string $event_type): string + { + return match($event_type) { + self::EVENT_ENTITY_CREATION => self::LOG_MESSAGE_CREATED, + self::EVENT_ENTITY_UPDATE => self::LOG_MESSAGE_UPDATED, + self::EVENT_ENTITY_DELETION => self::LOG_MESSAGE_DELETED, + self::EVENT_COLLECTION_UPDATE => self::LOG_MESSAGE_COLLECTION_UPDATED, + default => self::LOG_MESSAGE_CHANGED + }; + } + + +} \ No newline at end of file diff --git a/app/Audit/Interfaces/IAuditStrategy.php b/app/Audit/Interfaces/IAuditStrategy.php new file mode 100644 index 000000000..78b018bce --- /dev/null +++ b/app/Audit/Interfaces/IAuditStrategy.php @@ -0,0 +1,21 @@ + [old, new]) + * @param string $event_type Type of audit event (create, update, delete, collection_update) + * @return void + */ + public function audit($subject, array $change_set, string $event_type): void; + +} \ No newline at end of file diff --git a/config/opentelemetry.php b/config/opentelemetry.php index d79d40af2..d64d227ac 100644 --- a/config/opentelemetry.php +++ b/config/opentelemetry.php @@ -85,6 +85,8 @@ * Context field name for trace id */ 'trace_id_field' => 'traceid', + + 'elasticsearch_index' => env('OTEL_LOGS_ELASTICSEARCH_INDEX', 'logs-audit'), ], /** diff --git a/docker-compose/opentelemetry/otel-collector-config.yaml b/docker-compose/opentelemetry/otel-collector-config.yaml index 74d050717..9b277012e 100755 --- a/docker-compose/opentelemetry/otel-collector-config.yaml +++ b/docker-compose/opentelemetry/otel-collector-config.yaml @@ -130,7 +130,7 @@ service: logs: receivers: [otlp] processors: [memory_limiter, resource, attributes, batch] - exporters: [debug, file] + exporters: [debug, file, elasticsearch] # Telemetry configuration for the collector itself telemetry: diff --git a/tests/OpenTelemetry/AuditEventTypesTest.php b/tests/OpenTelemetry/AuditEventTypesTest.php new file mode 100644 index 000000000..d7886ac2f --- /dev/null +++ b/tests/OpenTelemetry/AuditEventTypesTest.php @@ -0,0 +1,119 @@ +auditStrategy = $this->app->make(AuditLogOtlpStrategy::class); + } + + public function testEntityCreationAudit(): void + { + if (!$this->isOpenTelemetryEnabled()) { + $this->markTestSkipped('OpenTelemetry is disabled'); + } + + $tracer = $this->app->make(\OpenTelemetry\API\Trace\TracerInterface::class); + $span = $tracer->spanBuilder('test.audit.creation')->startSpan(); + $spanScope = $span->activate(); + + try { + $mockEntity = (object) ['id' => 999, 'title' => 'Test Entity']; + $data = ['title' => 'New Entity', 'type' => 'test']; + + $this->auditStrategy->audit( + $mockEntity, + $data, + AuditLogOtlpStrategy::EVENT_ENTITY_CREATION + ); + + $span->setStatus(StatusCode::STATUS_OK, 'Creation audit completed'); + $this->assertTrue(true); + } catch (\Exception $e) { + $span->recordException($e); + throw $e; + } finally { + $span->end(); + $spanScope->detach(); + } + } + + public function testEntityUpdateAudit(): void + { + if (!$this->isOpenTelemetryEnabled()) { + $this->markTestSkipped('OpenTelemetry is disabled'); + } + + $tracer = $this->app->make(\OpenTelemetry\API\Trace\TracerInterface::class); + $span = $tracer->spanBuilder('test.audit.update')->startSpan(); + $spanScope = $span->activate(); + + try { + $mockEntity = (object) ['id' => 999, 'title' => 'Test Entity']; + $data = ['title' => ['Old Title', 'New Title']]; + + $this->auditStrategy->audit( + $mockEntity, + $data, + AuditLogOtlpStrategy::EVENT_ENTITY_UPDATE + ); + + $span->setStatus(StatusCode::STATUS_OK, 'Update audit completed'); + $this->assertTrue(true); + } catch (\Exception $e) { + $span->recordException($e); + throw $e; + } finally { + $span->end(); + $spanScope->detach(); + } + } + + public function testEntityDeletionAudit(): void + { + if (!$this->isOpenTelemetryEnabled()) { + $this->markTestSkipped('OpenTelemetry is disabled'); + } + + $tracer = $this->app->make(\OpenTelemetry\API\Trace\TracerInterface::class); + $span = $tracer->spanBuilder('test.audit.deletion')->startSpan(); + $spanScope = $span->activate(); + + try { + $mockEntity = (object) ['id' => 999, 'title' => 'Test Entity']; + $data = ['deleted_id' => 999, 'reason' => 'Test cleanup']; + + $this->auditStrategy->audit( + $mockEntity, + $data, + AuditLogOtlpStrategy::EVENT_ENTITY_DELETION + ); + + $span->setStatus(StatusCode::STATUS_OK, 'Deletion audit completed'); + $this->assertTrue(true); + } catch (\Exception $e) { + $span->recordException($e); + throw $e; + } finally { + $span->end(); + $spanScope->detach(); + } + } + + /** + * Check if OpenTelemetry is enabled + */ + private function isOpenTelemetryEnabled(): bool + { + return getenv('OTEL_SERVICE_ENABLED') === 'true'; + } +} diff --git a/tests/OpenTelemetry/AuditOtlpStrategyTest.php b/tests/OpenTelemetry/AuditOtlpStrategyTest.php new file mode 100644 index 000000000..9d6d98041 --- /dev/null +++ b/tests/OpenTelemetry/AuditOtlpStrategyTest.php @@ -0,0 +1,181 @@ +auditStrategy = $this->app->make(AuditLogOtlpStrategy::class); + } + + protected function tearDown(): void + { + self::clearSummitTestData(); + self::clearMemberTestData(); + parent::tearDown(); + } + + public function testAuditSummitChangeWithOtlp(): void + { + $this->skipIfOpenTelemetryDisabled(); + + $tracer = $this->app->make(TracerInterface::class); + $span = $tracer->spanBuilder('test.audit.summit_change')->startSpan(); + $spanScope = $span->activate(); + + try { + $span->addEvent('test.started', [ + 'summit_id' => self::$summit->getId(), + 'summit_name' => self::$summit->getName() + ]); + + $simulatedChangeSet = $this->createSummitChangeSet(); + + $this->auditStrategy->audit( + self::$summit, + $simulatedChangeSet, + AuditLogOtlpStrategy::EVENT_ENTITY_UPDATE + ); + + $span->setStatus(StatusCode::STATUS_OK, 'Summit audit completed'); + $this->assertTrue(true); + + } catch (\Exception $e) { + $span->recordException($e); + $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage()); + throw $e; + } finally { + $span->end(); + $spanScope->detach(); + } + } + + public function testAuditSummitEventChangeWithOtlp(): void + { + $this->skipIfOpenTelemetryDisabled(); + + $tracer = $this->app->make(TracerInterface::class); + $span = $tracer->spanBuilder('test.audit.summit_event_change')->startSpan(); + $spanScope = $span->activate(); + + try { + $summitEvent = self::$summit->getEvents()[0]; + $simulatedChangeSet = $this->createSummitEventChangeSet($summitEvent); + + $this->auditStrategy->audit( + $summitEvent, + $simulatedChangeSet, + AuditLogOtlpStrategy::EVENT_ENTITY_UPDATE + ); + + $span->setStatus(StatusCode::STATUS_OK, 'SummitEvent audit completed'); + $this->assertTrue(true); + + } catch (\Exception $e) { + $span->recordException($e); + $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage()); + throw $e; + } finally { + $span->end(); + $spanScope->detach(); + } + } + + public function testAuditStrategyWithoutActiveSpan(): void + { + $this->skipIfOpenTelemetryDisabled(); + + $simulatedChangeSet = ['name' => ['Old Name', 'New Name']]; + + $this->auditStrategy->audit( + self::$summit, + $simulatedChangeSet, + AuditLogOtlpStrategy::EVENT_ENTITY_UPDATE + ); + + $this->assertTrue(true); + } + + public function testAuditStrategyWithEmptyChangeSet(): void + { + $this->skipIfOpenTelemetryDisabled(); + + $tracer = $this->app->make(TracerInterface::class); + $span = $tracer->spanBuilder('test.audit.empty_changeset')->startSpan(); + $spanScope = $span->activate(); + + try { + $this->auditStrategy->audit( + self::$summit, + [], + AuditLogOtlpStrategy::EVENT_ENTITY_UPDATE + ); + + $span->setStatus(StatusCode::STATUS_OK, 'Empty changeset audit completed'); + $this->assertTrue(true); + + } catch (\Exception $e) { + $span->recordException($e); + $span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage()); + throw $e; + } finally { + $span->end(); + $spanScope->detach(); + } + } + + private function skipIfOpenTelemetryDisabled(): void + { + if (!$this->isOpenTelemetryEnabled()) { + $this->markTestSkipped('OpenTelemetry is disabled'); + } + } + + private function createSummitChangeSet(): array + { + return [ + 'name' => [self::$summit->getName(), self::$summit->getName() . ' [TEST]'], + 'description' => ['Original', 'Updated for test'] + ]; + } + + private function createSummitEventChangeSet(object $summitEvent): array + { + return [ + 'title' => [$summitEvent->getTitle(), $summitEvent->getTitle() . ' [TEST]'] + ]; + } + + private function isOpenTelemetryEnabled(): bool + { + return getenv('OTEL_SERVICE_ENABLED') === 'true'; + } +} diff --git a/tests/OpenTelemetry/OpenTelemetryTestCase.php b/tests/OpenTelemetry/OpenTelemetryTestCase.php index bf9aff8ad..ab3809ab1 100644 --- a/tests/OpenTelemetry/OpenTelemetryTestCase.php +++ b/tests/OpenTelemetry/OpenTelemetryTestCase.php @@ -55,6 +55,8 @@ protected function setOpenTelemetryEnvironmentVariables(): void putenv('OTEL_INSTRUMENTATION_CONSOLE=false'); putenv('OTEL_TRACES_EXPORTER=none'); putenv('OTEL_METRICS_EXPORTER=none'); + putenv('OTEL_LOGS_EXPORTER=none'); + } protected function tearDown(): void