2019-08-14 23:50:53 +00:00
|
|
|
<?php
|
2020-10-14 22:19:31 +00:00
|
|
|
|
2021-07-19 05:53:45 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
2019-08-14 23:50:53 +00:00
|
|
|
namespace App\Doctrine\Event;
|
|
|
|
|
|
|
|
use App\Entity;
|
2021-05-31 01:15:34 +00:00
|
|
|
use App\Entity\Attributes\Auditable;
|
|
|
|
use App\Entity\Attributes\AuditIgnore;
|
2019-08-14 23:50:53 +00:00
|
|
|
use Doctrine\Common\EventSubscriber;
|
2020-06-26 20:22:53 +00:00
|
|
|
use Doctrine\ORM\EntityManagerInterface;
|
2019-08-14 23:50:53 +00:00
|
|
|
use Doctrine\ORM\Event\OnFlushEventArgs;
|
|
|
|
use Doctrine\ORM\Events;
|
|
|
|
use Doctrine\ORM\Mapping\ClassMetadataInfo;
|
|
|
|
use Doctrine\ORM\PersistentCollection;
|
2019-08-15 19:01:00 +00:00
|
|
|
use Doctrine\ORM\Proxy\Proxy;
|
2021-05-31 01:15:34 +00:00
|
|
|
use Doctrine\ORM\UnitOfWork;
|
2019-12-07 00:57:50 +00:00
|
|
|
use ProxyManager\Proxy\GhostObjectInterface;
|
2019-09-04 18:00:51 +00:00
|
|
|
use ReflectionClass;
|
|
|
|
use ReflectionObject;
|
2021-06-10 03:22:13 +00:00
|
|
|
use Stringable;
|
2019-08-14 23:50:53 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* A hook into Doctrine's event listener to write changes to "Auditable"
|
|
|
|
* entities to the audit log.
|
|
|
|
*
|
|
|
|
* Portions inspired by DataDog's audit bundle for Doctrine:
|
|
|
|
* https://github.com/DATA-DOG/DataDogAuditBundle/blob/master/src/DataDog/AuditBundle/EventSubscriber/AuditSubscriber.php
|
|
|
|
*/
|
|
|
|
class AuditLog implements EventSubscriber
|
|
|
|
{
|
2020-10-14 22:19:31 +00:00
|
|
|
/**
|
|
|
|
* @return string[]
|
|
|
|
*/
|
|
|
|
public function getSubscribedEvents(): array
|
2019-08-14 23:50:53 +00:00
|
|
|
{
|
|
|
|
return [
|
|
|
|
Events::onFlush,
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2020-07-08 07:03:50 +00:00
|
|
|
public function onFlush(OnFlushEventArgs $args): void
|
2019-08-14 23:50:53 +00:00
|
|
|
{
|
|
|
|
$em = $args->getEntityManager();
|
|
|
|
$uow = $em->getUnitOfWork();
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
$singleAuditLogs = $this->handleSingleUpdates($em, $uow);
|
|
|
|
$collectionAuditLogs = $this->handleCollectionUpdates($uow);
|
|
|
|
$newAuditLogs = array_merge($singleAuditLogs, $collectionAuditLogs);
|
|
|
|
|
|
|
|
if (!empty($newAuditLogs)) {
|
|
|
|
$auditLogMetadata = $em->getClassMetadata(Entity\AuditLog::class);
|
|
|
|
foreach ($newAuditLogs as $auditLog) {
|
|
|
|
$uow->persist($auditLog);
|
|
|
|
$uow->computeChangeSet($auditLogMetadata, $auditLog);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @return Entity\AuditLog[] */
|
|
|
|
protected function handleSingleUpdates(
|
|
|
|
EntityManagerInterface $em,
|
|
|
|
UnitOfWork $uow
|
|
|
|
): array {
|
|
|
|
$newRecords = [];
|
|
|
|
|
2019-08-14 23:50:53 +00:00
|
|
|
$collections = [
|
|
|
|
Entity\AuditLog::OPER_INSERT => $uow->getScheduledEntityInsertions(),
|
|
|
|
Entity\AuditLog::OPER_UPDATE => $uow->getScheduledEntityUpdates(),
|
|
|
|
Entity\AuditLog::OPER_DELETE => $uow->getScheduledEntityDeletions(),
|
|
|
|
];
|
|
|
|
|
2019-09-04 18:00:51 +00:00
|
|
|
foreach ($collections as $changeType => $collection) {
|
2019-08-14 23:50:53 +00:00
|
|
|
foreach ($collection as $entity) {
|
|
|
|
// Check that the entity being managed is "Auditable".
|
2019-09-04 18:00:51 +00:00
|
|
|
$reflectionClass = new ReflectionObject($entity);
|
2021-05-30 18:55:26 +00:00
|
|
|
if (!$this->isAuditable($reflectionClass)) {
|
2019-08-14 23:50:53 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the changes made to the entity.
|
2019-08-15 19:01:00 +00:00
|
|
|
$changesRaw = $uow->getEntityChangeSet($entity);
|
2019-08-14 23:50:53 +00:00
|
|
|
|
|
|
|
// Look for the @AuditIgnore annotation on properties.
|
2019-08-15 19:01:00 +00:00
|
|
|
$changes = [];
|
|
|
|
|
|
|
|
foreach ($changesRaw as $changeField => [$fieldPrev, $fieldNow]) {
|
|
|
|
// With new entity creation, fields left NULL are still included.
|
|
|
|
if ($fieldPrev === $fieldNow) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ensure the property isn't ignored.
|
2021-06-08 06:40:49 +00:00
|
|
|
$ignoreAttr = $reflectionClass->getProperty($changeField)->getAttributes(AuditIgnore::class);
|
2021-05-30 18:55:26 +00:00
|
|
|
if (!empty($ignoreAttr)) {
|
2019-08-15 19:01:00 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check if either field value is an object.
|
|
|
|
if ($this->isEntity($em, $fieldPrev)) {
|
2021-05-31 01:15:34 +00:00
|
|
|
$fieldPrev = $this->getIdentifier($fieldPrev);
|
2019-08-15 19:01:00 +00:00
|
|
|
}
|
|
|
|
if ($this->isEntity($em, $fieldNow)) {
|
2021-05-31 01:15:34 +00:00
|
|
|
$fieldNow = $this->getIdentifier($fieldNow);
|
2019-08-14 23:50:53 +00:00
|
|
|
}
|
2019-08-15 19:01:00 +00:00
|
|
|
|
|
|
|
$changes[$changeField] = [$fieldPrev, $fieldNow];
|
2019-08-14 23:50:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (Entity\AuditLog::OPER_UPDATE === $changeType && empty($changes)) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find the identifier method or property.
|
2021-05-31 01:15:34 +00:00
|
|
|
$identifier = $this->getIdentifier($entity);
|
2019-08-14 23:50:53 +00:00
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
$newRecords[] = new Entity\AuditLog(
|
2019-08-14 23:50:53 +00:00
|
|
|
$changeType,
|
|
|
|
get_class($entity),
|
|
|
|
$identifier,
|
|
|
|
null,
|
|
|
|
null,
|
|
|
|
$changes
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
return $newRecords;
|
|
|
|
}
|
|
|
|
|
|
|
|
/** @return Entity\AuditLog[] */
|
|
|
|
protected function handleCollectionUpdates(
|
|
|
|
UnitOfWork $uow
|
|
|
|
): array {
|
|
|
|
$newRecords = [];
|
2019-08-14 23:50:53 +00:00
|
|
|
$associated = [];
|
|
|
|
$disassociated = [];
|
|
|
|
|
2022-05-31 11:41:35 +00:00
|
|
|
/** @var PersistentCollection<int, object> $collection */
|
2019-08-14 23:50:53 +00:00
|
|
|
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
|
|
|
$owner = $collection->getOwner();
|
|
|
|
|
2021-07-19 05:53:45 +00:00
|
|
|
if (null === $owner) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2019-09-04 18:00:51 +00:00
|
|
|
$reflectionClass = new ReflectionObject($owner);
|
2021-05-30 18:55:26 +00:00
|
|
|
if (!$this->isAuditable($reflectionClass)) {
|
2019-08-14 23:50:53 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ignore inverse side or one to many relations
|
|
|
|
$mapping = $collection->getMapping();
|
2021-07-19 05:53:45 +00:00
|
|
|
if (null === $mapping) {
|
|
|
|
continue;
|
|
|
|
}
|
2019-08-14 23:50:53 +00:00
|
|
|
if (!$mapping['isOwningSide'] || $mapping['type'] !== ClassMetadataInfo::MANY_TO_MANY) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
$ownerIdentifier = $this->getIdentifier($owner);
|
2019-08-14 23:50:53 +00:00
|
|
|
|
|
|
|
foreach ($collection->getInsertDiff() as $entity) {
|
2019-09-04 18:00:51 +00:00
|
|
|
$targetReflectionClass = new ReflectionObject($entity);
|
2021-05-30 18:55:26 +00:00
|
|
|
if (!$this->isAuditable($targetReflectionClass)) {
|
2019-08-14 23:50:53 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
$entityIdentifier = $this->getIdentifier($entity);
|
2019-08-14 23:50:53 +00:00
|
|
|
$associated[] = [$owner, $ownerIdentifier, $entity, $entityIdentifier];
|
|
|
|
}
|
|
|
|
foreach ($collection->getDeleteDiff() as $entity) {
|
2019-09-04 18:00:51 +00:00
|
|
|
$targetReflectionClass = new ReflectionObject($entity);
|
2021-05-30 18:55:26 +00:00
|
|
|
if (!$this->isAuditable($targetReflectionClass)) {
|
2019-08-14 23:50:53 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
$entityIdentifier = $this->getIdentifier($entity);
|
2019-08-14 23:50:53 +00:00
|
|
|
$disassociated[] = [$owner, $ownerIdentifier, $entity, $entityIdentifier];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-31 11:41:35 +00:00
|
|
|
/** @var PersistentCollection<int, object> $collection */
|
2019-08-14 23:50:53 +00:00
|
|
|
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
|
|
|
$owner = $collection->getOwner();
|
|
|
|
|
2021-05-30 18:55:26 +00:00
|
|
|
if (null === $owner) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2019-09-04 18:00:51 +00:00
|
|
|
$reflectionClass = new ReflectionObject($owner);
|
2021-05-30 18:55:26 +00:00
|
|
|
if (!$this->isAuditable($reflectionClass)) {
|
2019-08-14 23:50:53 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ignore inverse side or one to many relations
|
|
|
|
$mapping = $collection->getMapping();
|
2021-07-19 05:53:45 +00:00
|
|
|
if (null === $mapping) {
|
|
|
|
continue;
|
|
|
|
}
|
2019-08-14 23:50:53 +00:00
|
|
|
if (!$mapping['isOwningSide'] || $mapping['type'] !== ClassMetadataInfo::MANY_TO_MANY) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
$ownerIdentifier = $this->getIdentifier($owner);
|
2019-08-14 23:50:53 +00:00
|
|
|
|
|
|
|
foreach ($collection->toArray() as $entity) {
|
2019-09-04 18:00:51 +00:00
|
|
|
$targetReflectionClass = new ReflectionObject($entity);
|
2021-05-30 18:55:26 +00:00
|
|
|
if (!$this->isAuditable($targetReflectionClass)) {
|
2019-08-14 23:50:53 +00:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
$entityIdentifier = $this->getIdentifier($entity);
|
2019-08-14 23:50:53 +00:00
|
|
|
$disassociated[] = [$owner, $ownerIdentifier, $entity, $entityIdentifier];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-09-04 18:00:51 +00:00
|
|
|
foreach ($associated as [$owner, $ownerIdentifier, $entity, $entityIdentifier]) {
|
2021-05-31 01:15:34 +00:00
|
|
|
$newRecords[] = new Entity\AuditLog(
|
2019-08-14 23:50:53 +00:00
|
|
|
Entity\AuditLog::OPER_INSERT,
|
|
|
|
get_class($owner),
|
|
|
|
$ownerIdentifier,
|
2021-11-03 04:12:02 +00:00
|
|
|
(string)get_class($entity),
|
2019-08-14 23:50:53 +00:00
|
|
|
$entityIdentifier,
|
|
|
|
[]
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-09-04 18:00:51 +00:00
|
|
|
foreach ($disassociated as [$owner, $ownerIdentifier, $entity, $entityIdentifier]) {
|
2021-05-31 01:15:34 +00:00
|
|
|
$newRecords[] = new Entity\AuditLog(
|
2019-08-14 23:50:53 +00:00
|
|
|
Entity\AuditLog::OPER_DELETE,
|
|
|
|
get_class($owner),
|
|
|
|
$ownerIdentifier,
|
2021-11-03 04:12:02 +00:00
|
|
|
(string)get_class($entity),
|
2019-08-14 23:50:53 +00:00
|
|
|
$entityIdentifier,
|
|
|
|
[]
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-05-31 01:15:34 +00:00
|
|
|
return $newRecords;
|
2019-08-14 23:50:53 +00:00
|
|
|
}
|
|
|
|
|
2021-04-23 22:12:47 +00:00
|
|
|
protected function isEntity(EntityManagerInterface $em, mixed $class): bool
|
2019-08-15 19:01:00 +00:00
|
|
|
{
|
|
|
|
if (is_object($class)) {
|
2019-12-07 00:57:50 +00:00
|
|
|
$class = ($class instanceof Proxy || $class instanceof GhostObjectInterface)
|
2019-08-15 19:01:00 +00:00
|
|
|
? get_parent_class($class)
|
|
|
|
: get_class($class);
|
2021-07-19 05:53:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!is_string($class)) {
|
2019-12-07 00:57:50 +00:00
|
|
|
return false;
|
2019-08-15 19:01:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!class_exists($class)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return !$em->getMetadataFactory()->isTransient($class);
|
|
|
|
}
|
|
|
|
|
2022-05-31 11:41:35 +00:00
|
|
|
/**
|
|
|
|
* @template TObject of object
|
|
|
|
* @param ReflectionClass<TObject> $refl
|
|
|
|
* @return bool
|
|
|
|
*/
|
2021-07-19 05:53:45 +00:00
|
|
|
protected function isAuditable(ReflectionClass $refl): bool
|
2021-05-30 18:55:26 +00:00
|
|
|
{
|
|
|
|
$auditable = $refl->getAttributes(Auditable::class);
|
|
|
|
return !empty($auditable);
|
|
|
|
}
|
|
|
|
|
2019-08-14 23:50:53 +00:00
|
|
|
/**
|
|
|
|
* Get the identifier string for an entity, if it's set or fetchable.
|
|
|
|
*
|
|
|
|
* @param object $entity
|
|
|
|
*/
|
2021-07-19 05:53:45 +00:00
|
|
|
protected function getIdentifier(object $entity): string
|
2019-08-14 23:50:53 +00:00
|
|
|
{
|
2021-06-10 03:22:13 +00:00
|
|
|
if ($entity instanceof Stringable) {
|
2021-05-30 18:55:26 +00:00
|
|
|
return (string)$entity;
|
2019-08-14 23:50:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if (method_exists($entity, 'getName')) {
|
|
|
|
return $entity->getName();
|
|
|
|
}
|
|
|
|
|
2021-07-19 05:53:45 +00:00
|
|
|
if ($entity instanceof Entity\Interfaces\IdentifiableEntityInterface) {
|
|
|
|
$entityId = $entity->getId();
|
|
|
|
if (null !== $entityId) {
|
|
|
|
return (string)$entityId;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return spl_object_hash($entity);
|
2019-08-14 23:50:53 +00:00
|
|
|
}
|
|
|
|
}
|