Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions app/Audit/AbstractAuditLogFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace App\Audit;

use App\Audit\Utils\DateFormatter;
use Doctrine\ORM\PersistentCollection;
use Illuminate\Support\Facades\Log;

/**
* Copyright 2025 OpenStack Foundation
Expand Down Expand Up @@ -32,6 +34,90 @@ final public function setContext(AuditContext $ctx): void
$this->ctx = $ctx;
}


protected function processCollection(
mixed $owner,
mixed $col,
mixed $uow,
bool $isDeletion = false
): ?array
{
if (!is_object($col) || !method_exists($col, 'getMapping') || !method_exists($col, 'getInsertDiff')) {
return null;
}

$mapping = $col->getMapping();

$addedEntities = $col->getInsertDiff();
$removedEntities = $col->getDeleteDiff();

$addedIds = $this->extractCollectionEntityIds($addedEntities);
$removedIds = $this->extractCollectionEntityIds($removedEntities);

if (empty($removedIds) && !empty($addedIds)) {
$this->recoverCollectionRemovalIds($uow, $owner, $mapping, $removedIds);
}

if (empty($addedIds) && empty($removedIds)) {
return null;
}

return [
'field' => $mapping['fieldName'] ?? 'unknown',
'target_entity' => $mapping['targetEntity'] ?? null,
'is_deletion' => $isDeletion,
'added_ids' => $addedIds,
'removed_ids' => $removedIds,
'join_table' => $mapping['joinTable']['name'] ?? null,
];
}

/**
* Recover removed IDs from original entity data
*/
protected function recoverCollectionRemovalIds($uow, $owner, $mapping, &$removedIds): void
{
try {
$originalData = $uow->getOriginalEntityData($owner);
$fieldName = $mapping['fieldName'] ?? null;

if ($fieldName && isset($originalData[$fieldName])) {
$originalCollection = $originalData[$fieldName];
if ($originalCollection instanceof PersistentCollection || is_array($originalCollection)) {
$originalEntities = is_array($originalCollection)
? $originalCollection
: $originalCollection->toArray();
$removedIds = $this->extractCollectionEntityIds($originalEntities);
}
}
} catch (\Exception $e) {
Log::warning('Failed to recover removed IDs from original entity data', [
'error' => $e->getMessage()
]);
}
}

/**
* Extract IDs from entity objects in collection
*/
protected function extractCollectionEntityIds(array $entities): array
{
$ids = [];
foreach ($entities as $entity) {
if (method_exists($entity, 'getId')) {
$id = $entity->getId();
if ($id !== null) {
$ids[] = $id;
}
}
}

$uniqueIds = array_unique($ids);
sort($uniqueIds);

return array_values($uniqueIds);
}

protected function getUserInfo(): string
{
if (app()->runningInConsole()) {
Expand Down
47 changes: 45 additions & 2 deletions app/Audit/AuditEventListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

use App\Audit\Interfaces\IAuditStrategy;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Expand Down Expand Up @@ -53,10 +55,14 @@ public function onFlush(OnFlushEventArgs $eventArgs): void
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$strategy->audit($entity, [], IAuditStrategy::EVENT_ENTITY_DELETION, $ctx);
}

foreach ($uow->getScheduledCollectionDeletions() as $col) {
$this->auditCollection($col, $strategy, $ctx, $uow, true);
}
foreach ($uow->getScheduledCollectionUpdates() as $col) {
$strategy->audit($col, [], IAuditStrategy::EVENT_COLLECTION_UPDATE, $ctx);
$this->auditCollection($col, $strategy, $ctx, $uow, false);
}


} catch (\Exception $e) {
Log::error('Audit event listener failed', [
'error' => $e->getMessage(),
Expand Down Expand Up @@ -127,4 +133,41 @@ private function buildAuditContext(): AuditContext
rawRoute: $rawRoute
);
}


/**
* Audit collection changes
* Only determines if it's ManyToMany and emits appropriate event
*/
private function auditCollection($subject, IAuditStrategy $strategy, AuditContext $ctx, $uow, bool $isDeletion = false): void
{
if (!$subject instanceof PersistentCollection) {
return;
}

$mapping = $subject->getMapping();
$isManyToMany = ($mapping['type'] ?? null) === ClassMetadata::MANY_TO_MANY;

if ($isManyToMany && empty($mapping['isOwningSide'])) {
return;
}

$owner = $subject->getOwner();
if ($owner === null) {
return;
}


$payload = [
'collection' => $subject,
'uow' => $uow,
'is_deletion' => $isDeletion,
];

$eventType = $isManyToMany
? ($isDeletion ? IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE : IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE)
: IAuditStrategy::EVENT_COLLECTION_UPDATE;

$strategy->audit($owner, $payload, $eventType, $ctx);
}
}
8 changes: 8 additions & 0 deletions app/Audit/AuditLogFormatterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ public function make(AuditContext $ctx, $subject, string $event_type): ?IAuditLo

$formatter = new EntityCollectionUpdateAuditLogFormatter($child_entity_formatter);
break;
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
if(is_null($formatter)) {
$child_entity_formatter = ChildEntityFormatterFactory::build($subject);
$formatter = $child_entity_formatter;
}
break;
case IAuditStrategy::EVENT_ENTITY_CREATION:
$formatter = $this->getFormatterByContext($subject, $event_type, $ctx);
if(is_null($formatter)) {
Expand Down
74 changes: 74 additions & 0 deletions app/Audit/ConcreteFormatters/SummitAttendeeAuditLogFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,85 @@ public function format($subject, array $change_set): ?string

case IAuditStrategy::EVENT_ENTITY_DELETION:
return sprintf("Attendee (%s) '%s' deleted by user %s", $id, $name, $this->getUserInfo());

case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_UPDATE:
return $this->handleManyToManyCollection($subject, $change_set, $id, $name, false);

case IAuditStrategy::EVENT_COLLECTION_MANYTOMANY_DELETE:
return $this->handleManyToManyCollection($subject, $change_set, $id, $name, true);
}
} catch (\Exception $ex) {
Log::warning("SummitAttendeeAuditLogFormatter error: " . $ex->getMessage());
}

return null;
}

private function handleManyToManyCollection(SummitAttendee $subject, array $change_set, $id, $name, bool $isDeletion): ?string
{
if (!isset($change_set['collection']) || !isset($change_set['uow'])) {
return null;
}

$col = $change_set['collection'];
$uow = $change_set['uow'];

$collectionData = $this->processCollection($subject, $col, $uow, $isDeletion);
if (!$collectionData) {
return null;
}

return $isDeletion
? $this->formatManyToManyDelete($subject, $collectionData, $id, $name)
: $this->formatManyToManyUpdate($subject, $collectionData, $id, $name);
}

private function formatManyToManyUpdate(SummitAttendee $subject, array $collectionData, $id, $name): ?string
{
try {
$field = $collectionData['field'] ?? 'unknown';
$targetEntity = $collectionData['target_entity'] ?? 'unknown';
$added_ids = $collectionData['added_ids'] ?? [];

$ownerId = $subject->getId();

$description = sprintf(
"Attendee (%s), Field: %s, Target: %s, Added IDs: %s, by user %s",
$ownerId,
$field,
class_basename($targetEntity),
json_encode($added_ids),
$this->getUserInfo()
);

return $description;

} catch (\Exception $ex) {
Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyUpdate error: " . $ex->getMessage());
return sprintf("Attendee (%s) '%s' association updated by user %s", $id, $name, $this->getUserInfo());
}
}

private function formatManyToManyDelete(SummitAttendee $subject, array $collectionData, $id, $name): ?string
{
try {
$field = $collectionData['field'] ?? 'unknown';
$targetEntity = $collectionData['target_entity'] ?? 'unknown';
$removed_ids = $collectionData['removed_ids'] ?? [];

$description = sprintf(
"Attendee Delete: Field: %s, Target: %s, Cleared IDs: %s, by user %s",
$field,
class_basename($targetEntity),
json_encode($removed_ids),
$this->getUserInfo()
);

return $description;

} catch (\Exception $ex) {
Log::warning("SummitAttendeeAuditLogFormatter::formatManyToManyDelete error: " . $ex->getMessage());
return sprintf("Attendee (%s) '%s' association deleted by user %s", $id, $name, $this->getUserInfo());
}
}
}
2 changes: 2 additions & 0 deletions app/Audit/Interfaces/IAuditStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public function audit($subject, array $change_set, string $event_type, AuditCont
public const EVENT_ENTITY_CREATION = 'event_entity_creation';
public const EVENT_ENTITY_DELETION = 'event_entity_deletion';
public const EVENT_ENTITY_UPDATE = 'event_entity_update';
public const EVENT_COLLECTION_MANYTOMANY_UPDATE = 'event_collection_manytomany_update';
public const EVENT_COLLECTION_MANYTOMANY_DELETE = 'event_collection_manytomany_delete';

public const ACTION_CREATE = 'create';
public const ACTION_UPDATE = 'update';
Expand Down
Loading