Skip to content
Open
45 changes: 45 additions & 0 deletions app/Audit/AbstractAuditLogFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Audit;

/**
* Copyright 2025 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

abstract class AbstractAuditLogFormatter implements IAuditLogFormatter
{
protected AuditContext $ctx;

final public function setContext(AuditContext $ctx): void
{
$this->ctx = $ctx;
}

protected function getUserInfo(): string
{
if (!$this->ctx) {
return 'Unknown (unknown)';
}

$user_name = 'Unknown';
if ($this->ctx->userFirstName || $this->ctx->userLastName) {
$user_name = trim(sprintf("%s %s", $this->ctx->userFirstName ?? '', $this->ctx->userLastName ?? '')) ?: 'Unknown';
} elseif ($this->ctx->userEmail) {
$user_name = $this->ctx->userEmail;
}

$user_id = $this->ctx->userId ?? 'unknown';
return sprintf("%s (%s)", $user_name, $user_id);
}

abstract public function format($subject, array $change_set): ?string;
}
31 changes: 31 additions & 0 deletions app/Audit/AuditContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php
namespace App\Audit;
/**
* Copyright 2025 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
class AuditContext
{
public function __construct(
public ?int $userId = null,
public ?string $userEmail = null,
public ?string $userFirstName = null,
public ?string $userLastName = null,
public ?string $uiApp = null,
public ?string $uiFlow = null,
public ?string $route = null,
public ?string $rawRoute = null,
public ?string $httpMethod = null,
public ?string $clientIp = null,
public ?string $userAgent = null,
) {
}
}
133 changes: 133 additions & 0 deletions app/Audit/AuditEventListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php
namespace App\Audit;
/**
* Copyright 2025 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\Audit\Interfaces\IAuditStrategy;
use Auth\Repositories\IUserRepository;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
use OAuth2\IResourceServerContext;
/**
* Class AuditEventListener
* @package App\Audit
*/
class AuditEventListener
{
private const ROUTE_METHOD_SEPARATOR = '|';

public function onFlush(OnFlushEventArgs $eventArgs): void
{
if (app()->environment('testing')) {
return;
}
$em = $eventArgs->getObjectManager();
$uow = $em->getUnitOfWork();
// Strategy selection based on environment configuration
$strategy = $this->getAuditStrategy($em);
if (!$strategy) {
return; // No audit strategy enabled
}

$ctx = $this->buildAuditContext();

try {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_CREATION, $ctx);
}

foreach ($uow->getScheduledEntityUpdates() as $entity) {
$strategy->audit($entity, $uow->getEntityChangeSet($entity), IAuditStrategy::EVENT_ENTITY_UPDATE, $ctx);
}

foreach ($uow->getScheduledEntityDeletions() as $entity) {
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx);
}

foreach ($uow->getScheduledCollectionUpdates() as $col) {
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
}
} catch (\Exception $e) {
Log::error('Audit event listener failed', [
'error' => $e->getMessage(),
'strategy_class' => get_class($strategy),
'trace' => $e->getTraceAsString(),
]);
}
}

/**
* Get the appropriate audit strategy based on environment configuration
*/
private function getAuditStrategy($em): ?IAuditStrategy
{
// Check if OTLP audit is enabled
if (config('opentelemetry.enabled', false)) {
try {
Log::debug("AuditEventListener::getAuditStrategy strategy AuditLogOtlpStrategy");
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)
Log::debug("AuditEventListener::getAuditStrategy strategy AuditLogStrategy");
return new AuditLogStrategy($em);
}

private function buildAuditContext(): AuditContext
{
$resourceCtx = app(IResourceServerContext::class);
$userId = $resourceCtx->getCurrentUserId();
$user = null;
if ($userId) {
$userRepo = app(IUserRepository::class);
$user = $userRepo->getById($userId);
}

//$ui = app()->bound('ui.context') ? app('ui.context') : [];

$req = request();
$rawRoute = null;
// does not resolve the route when app is running in console mode
if ($req instanceof Request && !app()->runningInConsole()) {
try {
$route = Route::getRoutes()->match($req);
$method = $route->methods[0] ?? 'UNKNOWN';
$rawRoute = $method . self::ROUTE_METHOD_SEPARATOR . $route->uri;
} catch (\Exception $e) {
Log::warning($e);
}
}

return new AuditContext(
userId: $user?->getId(),
userEmail: $user?->getEmail(),
userFirstName: $user?->getFirstName(),
userLastName: $user?->getLastName(),
uiApp: $ui['app'] ?? null,
uiFlow: $ui['flow'] ?? null,
route: $req?->path(),
httpMethod: $req?->method(),
clientIp: $req?->ip(),
userAgent: $req?->userAgent(),
rawRoute: $rawRoute
);
}
}
170 changes: 170 additions & 0 deletions app/Audit/AuditLogFormatterFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php namespace App\Audit;
/**
* Copyright 2025 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
use App\Audit\ConcreteFormatters\ChildEntityFormatters\ChildEntityFormatterFactory;
use App\Audit\ConcreteFormatters\EntityCollectionUpdateAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityCreationAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityDeletionAuditLogFormatter;
use App\Audit\ConcreteFormatters\EntityUpdateAuditLogFormatter;
use App\Audit\Interfaces\IAuditStrategy;
use Doctrine\ORM\PersistentCollection;
use Illuminate\Support\Facades\Log;
use Doctrine\ORM\Mapping\ClassMetadata;
class AuditLogFormatterFactory implements IAuditLogFormatterFactory
{

private array $config;

public function __construct()
{
// cache the config so we don't hit config() repeatedly
$this->config = config('audit_log', []);
}

public function make(AuditContext $ctx, $subject, $eventType): ?IAuditLogFormatter
{
$formatter = null;
switch ($eventType) {
case IAuditStrategy::EVENT_COLLECTION_UPDATE:
$child_entity_formatter = null;

if ($subject instanceof PersistentCollection) {
$targetEntity = null;
Log::debug
(
sprintf
(
"AuditLogFormatterFactory::make subject is a PersistentCollection isInitialized %b ?",
$subject->isInitialized()
)
);
if (method_exists($subject, 'getTypeClass')) {
$type = $subject->getTypeClass();
// Your log shows this is ClassMetadata
if ($type instanceof ClassMetadata) {
// Doctrine supports either getName() or public $name
$targetEntity = method_exists($type, 'getName') ? $type->getName() : ($type->name ?? null);
} elseif (is_string($type)) {
$targetEntity = $type;
}
Log::debug("AuditLogFormatterFactory::make getTypeClass targetEntity {$targetEntity}");
}
elseif (method_exists($subject, 'getMapping')) {
$mapping = $subject->getMapping();
$targetEntity = $mapping['targetEntity'] ?? null;
Log::debug("AuditLogFormatterFactory::make getMapping targetEntity {$targetEntity}");
} else {
// last-resort: read private association metadata (still no hydration)
$ref = new \ReflectionObject($subject);
foreach (['association', 'mapping', 'associationMapping'] as $propName) {
if ($ref->hasProperty($propName)) {
$prop = $ref->getProperty($propName);
$prop->setAccessible(true);
$mapping = $prop->getValue($subject);
$targetEntity = $mapping['targetEntity'] ?? null;
if ($targetEntity) break;
}
}
}

if ($targetEntity) {
// IMPORTANT: build formatter WITHOUT touching collection items
$child_entity_formatter = ChildEntityFormatterFactory::build($targetEntity);
}
Log::debug
(
sprintf
(
"AuditLogFormatterFactory::make subject is a PersistentCollection isInitialized %b ? ( final )",
$subject->isInitialized()
)
);
} elseif (is_array($subject)) {
$child_entity = $subject[0] ?? null;
$child_entity_formatter = $child_entity ? ChildEntityFormatterFactory::build($child_entity) : null;
} elseif (is_object($subject) && method_exists($subject, 'getSnapshot')) {
$snap = $subject->getSnapshot(); // only once
$child_entity = $snap[0] ?? null;
$child_entity_formatter = $child_entity ? ChildEntityFormatterFactory::build($child_entity) : null;
}

$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
break;
case IAuditStrategy::EVENT_ENTITY_CREATION:
$formatter = $this->getFormatterByContext($subject, $eventType, $ctx);
if(is_null($formatter)) {
$formatter = new EntityCreationAuditLogFormatter();
}
break;
case IAuditStrategy::EVENT_ENTITY_DELETION:
$formatter = $this->getFormatterByContext($subject, $eventType, $ctx);
if(is_null($formatter)) {
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
$formatter = new EntityDeletionAuditLogFormatter($child_entity_formatter);
}
break;
case IAuditStrategy::EVENT_ENTITY_UPDATE:
$formatter = $this->getFormatterByContext($subject, $eventType, $ctx);
if(is_null($formatter)) {
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
$formatter = new EntityUpdateAuditLogFormatter($child_entity_formatter);
}
break;
}
if ($formatter === null) return null;
$formatter->setContext($ctx);
return $formatter;
}

private function getFormatterByContext(object $subject, string $event_type, AuditContext $ctx): ?IAuditLogFormatter
{
$class = get_class($subject);
$entity_config = $this->config['entities'][$class] ?? null;

if (!$entity_config) {
return null;
}

if (isset($entity_config['strategies'])) {
foreach ($entity_config['strategies'] as $strategy) {
if (!$this->matchesStrategy($strategy, $ctx)) {
continue;
}

$formatter_class = $strategy['formatter'] ?? null;
return $formatter_class ? new $formatter_class($event_type) : null;
}
}

if (isset($entity_config['strategy'])) {
$strategy_class = $entity_config['strategy'];
return new $strategy_class($event_type);
}

return null;
}

private function matchesStrategy(array $strategy, AuditContext $ctx): bool
{
if (isset($strategy['route']) && !$this->routeMatches($strategy['route'], $ctx->rawRoute)) {
return false;
}

return true;
}

private function routeMatches(string $route, string $actual_route): bool
{
return strcmp($actual_route, $route) === 0;
}
}
Loading
Loading