Skip to content
Merged
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
5 changes: 4 additions & 1 deletion src/Repository/AuditLogRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,15 @@ private function applyKeysetPagination(QueryBuilder $qb, array $filters): void
#[Override]
public function findOlderThan(DateTimeImmutable $before): array
{
return $this->createQueryBuilder('a')
/** @var array<AuditLog> $results */
$results = $this->createQueryBuilder('a')
->where('a.createdAt < :before')
->setParameter('before', $before)
->orderBy('a.createdAt', 'ASC')
->getQuery()
->getResult();

return $results;
}

/**
Expand Down
5 changes: 1 addition & 4 deletions src/Service/AuditAccessHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ class AuditAccessHandler implements ResetInterface
/** @var array<string, bool> */
private array $auditedEntities = [];

private ?bool $isGetRequest = null;

/** @var array<string, bool> */
private array $skipAccessCheck = [];

Expand Down Expand Up @@ -103,7 +101,6 @@ public function markAsAudited(string $requestKey): void
public function reset(): void
{
$this->auditedEntities = [];
$this->isGetRequest = null;
$this->skipAccessCheck = [];
}

Expand Down Expand Up @@ -143,7 +140,7 @@ private function isAuditedRequest(): bool
{
$method = $this->requestStack->getCurrentRequest()?->getMethod();

return $this->isGetRequest ??= ($method !== null && in_array($method, $this->auditedMethods, true));
return $method !== null && in_array($method, $this->auditedMethods, true);
}

private function shouldSkipAccessLog(string $requestKey, string $class, string $id, int $cooldown): bool
Expand Down
88 changes: 59 additions & 29 deletions src/Service/AuditIntegrityService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,24 @@
use Rcsofttech\AuditTrailBundle\Contract\AuditIntegrityServiceInterface;
use Rcsofttech\AuditTrailBundle\Entity\AuditLog;
use RuntimeException;
use Stringable;
use Throwable;

use function gettype;
use function hash_equals;
use function hash_hmac;
use function is_array;
use function is_bool;
use function is_float;
use function is_int;
use function is_object;
use function is_scalar;
use function is_string;
use function json_encode;
use function ksort;
use function method_exists;
use function preg_match;
use function sprintf;
use function strlen;

use const JSON_THROW_ON_ERROR;
Expand All @@ -32,10 +40,17 @@ final class AuditIntegrityService implements AuditIntegrityServiceInterface
{
private readonly DateTimeZone $utc;

/**
* @var bool Read-only property check for integrity status using PHP 8.4 hooks.
*/
public bool $isEnabled {
get => $this->enabled && $this->secret !== null;
}

public function __construct(
private readonly ?string $secret = null,
private readonly bool $enabled = false,
private readonly string $algorithm = 'sha256',
private(set) ?string $secret = null,
public private(set) bool $enabled = false,
public private(set) string $algorithm = 'sha256',
) {
$this->utc = new DateTimeZone('UTC');
}
Expand All @@ -45,7 +60,7 @@ public function __construct(
#[Override]
public function isEnabled(): bool
{
return $this->enabled && $this->secret !== null;
return $this->isEnabled;
}

#[Override]
Expand All @@ -64,7 +79,7 @@ public function generateSignature(AuditLog $log): string
#[Override]
public function verifySignature(AuditLog $log): bool
{
if (!$this->isEnabled()) {
if (!$this->isEnabled) {
return true;
}

Expand Down Expand Up @@ -141,31 +156,42 @@ private function normalizeValues(?array $values, int $depth = 0): ?array

private function normalizeValue(mixed $value, int $depth = 0): mixed
{
if ($value === null) {
return 'n:';
}
return match (true) {
$value === null => 'n:',
is_bool($value) => sprintf('b:%d', $value ? 1 : 0),
is_int($value) => sprintf('i:%d', $value),
is_float($value) => sprintf('f:%F', $value),
is_string($value) => $this->normalizeString($value),
$value instanceof DateTimeInterface => $this->normalizeDateTime($value),
is_array($value) => $this->normalizeArray($value, $depth),
is_object($value) => $this->normalizeObject($value),
default => sprintf('s:%s', gettype($value)),
};
}

if (is_bool($value)) {
return 'b:'.($value ? '1' : '0');
}
private function normalizeDateTime(DateTimeInterface $value): string
{
$dt = DateTimeImmutable::createFromInterface($value);

if (is_int($value)) {
return 'i:'.$value;
}
return sprintf('d:%s', $dt->setTimezone($this->utc)->format(DateTimeInterface::ATOM));
}

if (is_float($value)) {
return 'f:'.$value;
}
private function normalizeObject(object $value): string
{
if (method_exists($value, 'getId')) {
/** @var mixed $id */
$id = $value->getId();

if (is_string($value)) {
return $this->normalizeString($value);
}
if (is_scalar($id) || $id instanceof Stringable || (is_object($id) && method_exists($id, '__toString'))) {
return sprintf('s:%s', (string) $id);
}

if (is_array($value)) {
return $this->normalizeArray($value, $depth);
return sprintf('o:%s', $value::class);
}

return 's:'.(string) $value;
return $value instanceof Stringable || method_exists($value, '__toString')
? sprintf('s:%s', (string) $value)
: sprintf('o:%s', $value::class);
}

private function normalizeString(string $value): string
Expand All @@ -184,7 +210,7 @@ private function normalizeString(string $value): string
}

/**
* @param array<string, mixed>|array{date?: string, timezone?: string} $value
* @param array<mixed> $value
*/
private function normalizeArray(array $value, int $depth): mixed
{
Expand All @@ -193,11 +219,15 @@ private function normalizeArray(array $value, int $depth): mixed
}

if (isset($value['date'], $value['timezone'])) {
try {
$dt = new DateTimeImmutable($value['date'], new DateTimeZone($value['timezone']));

return 'd:'.$dt->setTimezone($this->utc)->format(DateTimeInterface::ATOM);
} catch (Throwable) {
$dateVal = $value['date'];
$tzVal = $value['timezone'];
if (is_string($dateVal) && is_string($tzVal)) {
try {
$dt = new DateTimeImmutable($dateVal, new DateTimeZone($tzVal));

return $this->normalizeDateTime($dt);
} catch (Throwable) {
}
}
}

Expand Down
9 changes: 8 additions & 1 deletion src/Service/AuditReverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Rcsofttech\AuditTrailBundle\Contract\AuditReverterInterface;
use Rcsofttech\AuditTrailBundle\Contract\AuditServiceInterface;
use Rcsofttech\AuditTrailBundle\Contract\SoftDeleteHandlerInterface;
use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface;
use Rcsofttech\AuditTrailBundle\Entity\AuditLog;
use RuntimeException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
Expand All @@ -30,6 +31,7 @@ public function __construct(
private SoftDeleteHandlerInterface $softDeleteHandler,
private AuditIntegrityServiceInterface $integrityService,
private AuditDispatcherInterface $dispatcher,
private ValueSerializerInterface $serializer,
) {
}

Expand Down Expand Up @@ -124,10 +126,15 @@ private function createRevertAuditLog(
'reverted_log_id' => $log->id?->toRfc4122(),
];

$serializedChanges = [];
foreach ($changes as $field => $value) {
$serializedChanges[$field] = $this->serializer->serialize($value);
}

$revertLog = $this->auditService->createAuditLog(
$entity,
AuditLogInterface::ACTION_REVERT,
$isDelete ? null : $changes,
$isDelete ? null : $serializedChanges,
null,
$revertContext
);
Expand Down
16 changes: 12 additions & 4 deletions src/Service/ContextResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Rcsofttech\AuditTrailBundle\Contract\ContextResolverInterface;
use Rcsofttech\AuditTrailBundle\Contract\DataMaskerInterface;
use Rcsofttech\AuditTrailBundle\Contract\UserResolverInterface;
use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface;
use Stringable;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
use Throwable;
Expand All @@ -24,6 +25,7 @@ final class ContextResolver implements ContextResolverInterface
public function __construct(
private readonly UserResolverInterface $userResolver,
private readonly DataMaskerInterface $dataMasker,
private readonly ValueSerializerInterface $serializer,
#[AutowireIterator('audit_trail.context_contributor')]
private readonly iterable $contributors = [],
private readonly ?LoggerInterface $logger = null,
Expand Down Expand Up @@ -103,10 +105,16 @@ private function buildContext(array $extraContext, object $entity, string $actio

// Add custom context from contributors
foreach ($this->contributors as $contributor) {
$context = [
...$context,
...$contributor->contribute($entity, $action, $newValues),
];
$contribution = $contributor->contribute($entity, $action, $newValues);

foreach ($contribution as $key => $val) {
$context[$key] = $val;
}
}

// This ensures extraContext and contributor data are both safe.
foreach ($context as $key => $value) {
$context[$key] = $this->serializer->serialize($value);
}

return $context;
Expand Down
1 change: 1 addition & 0 deletions src/Service/DataMasker.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public function redact(array $data): array
}

if (is_array($value)) {
/** @var array<string, mixed> $value */
$data[$key] = $this->redact($value);
}
}
Expand Down
27 changes: 22 additions & 5 deletions src/Service/ValueSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@

namespace Rcsofttech\AuditTrailBundle\Service;

use BackedEnum;
use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\PersistentCollection;
use Override;
use Psr\Log\LoggerInterface;
use Rcsofttech\AuditTrailBundle\Contract\ValueSerializerInterface;
use Stringable;
use UnitEnum;

use function is_array;
use function is_object;
use function is_resource;
use function method_exists;
use function sprintf;

final readonly class ValueSerializer implements ValueSerializerInterface
Expand All @@ -35,9 +39,11 @@ public function serialize(mixed $value, int $depth = 0): mixed
}

return match (true) {
$value === null => null,
$value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM),
$value instanceof UnitEnum => $this->serializeEnum($value),
$value instanceof Collection => $this->serializeCollection($value, $depth),
is_object($value) => $this->serializeObject($value),
is_object($value) => $this->serializeObject($value, $depth),
is_array($value) => array_map(
fn ($v) => $this->serialize($v, $depth + 1),
$value
Expand Down Expand Up @@ -110,23 +116,34 @@ private function serializeCollection(Collection $value, int $depth, bool $onlyId
);
}

private function serializeObject(object $value): mixed
private function serializeObject(object $value, int $depth = 0): mixed
{
if (method_exists($value, 'getId')) {
return $value->getId();
$id = $value->getId();

// Handle IDs that are themselves objects (e.g. UUID objects)
return is_object($id) ? $this->serialize($id, $depth + 1) : $id;
}

if (method_exists($value, '__toString')) {
if ($value instanceof Stringable || method_exists($value, '__toString')) {
return (string) $value;
}

return $value::class;
}

private function serializeEnum(UnitEnum $value): mixed
{
return $value instanceof BackedEnum ? $value->value : $value->name;
}

private function extractEntityIdentifier(object $entity): mixed
{
if (method_exists($entity, 'getId')) {
return $entity->getId();
$id = $entity->getId();

// Recurse for object identifiers (like Uuid)
return is_object($id) ? $this->serialize($id) : $id;
}

return $entity::class;
Expand Down
4 changes: 3 additions & 1 deletion tests/Functional/AbstractFunctionalTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ abstract class AbstractFunctionalTestCase extends KernelTestCase
protected function setUp(): void
{
parent::setUp();
// Reset static state on TestKernel to prevent cross-test contamination (DAMA protection)

TestKernel::$useThrowingTransport = false;

$this->clearTestCache();
}

protected function tearDown(): void
Expand Down
Loading