From 49c33d5a608c0dc29288b802cb18e217ec42c0c4 Mon Sep 17 00:00:00 2001 From: "Sakith B." Date: Fri, 12 Dec 2025 15:04:58 +0530 Subject: [PATCH 1/5] add sudo audit log service --- bundle/src/Entity/SudoAuditLog.php | 102 ++++++++++++++++++++++++++++ src/Sudo/SudoAuditLogRepository.php | 21 ++++++ src/Sudo/SudoAuditLogService.php | 42 ++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 bundle/src/Entity/SudoAuditLog.php create mode 100644 src/Sudo/SudoAuditLogRepository.php create mode 100644 src/Sudo/SudoAuditLogService.php diff --git a/bundle/src/Entity/SudoAuditLog.php b/bundle/src/Entity/SudoAuditLog.php new file mode 100644 index 0000000..a205cf3 --- /dev/null +++ b/bundle/src/Entity/SudoAuditLog.php @@ -0,0 +1,102 @@ + $payload */ + #[ORM\Column(type: 'json')] + private array $payload; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $created_at; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $updated_at; + + public function getId(): int + { + return $this->id; + } + + public function setId(int $id): static + { + $this->id = $id; + return $this; + } + + public function getUserId(): int + { + return $this->user_id; + } + + public function setUserId(int $user_id): static + { + $this->user_id = $user_id; + return $this; + } + + public function getAction(): string + { + return $this->action; + } + + public function setAction(string $action): static + { + $this->action = $action; + return $this; + } + + /** @return array */ + public function getPayload(): array + { + return $this->payload; + } + + /** @param array $payload */ + public function setPayload(array $payload): static + { + $this->payload = $payload; + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->created_at; + } + + public function setCreatedAt(\DateTimeImmutable $created_at): static + { + $this->created_at = $created_at; + return $this; + } + + public function getUpdatedAt(): \DateTimeImmutable + { + return $this->updated_at; + } + + public function setUpdatedAt(\DateTimeImmutable $updated_at): static + { + $this->updated_at = $updated_at; + return $this; + } + +} diff --git a/src/Sudo/SudoAuditLogRepository.php b/src/Sudo/SudoAuditLogRepository.php new file mode 100644 index 0000000..64a0341 --- /dev/null +++ b/src/Sudo/SudoAuditLogRepository.php @@ -0,0 +1,21 @@ + + * @codeCoverageIgnore + */ +class SudoAuditLogRepository extends ServiceEntityRepository +{ + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, SudoAuditLog::class); + } + +} diff --git a/src/Sudo/SudoAuditLogService.php b/src/Sudo/SudoAuditLogService.php new file mode 100644 index 0000000..9f05de7 --- /dev/null +++ b/src/Sudo/SudoAuditLogService.php @@ -0,0 +1,42 @@ + $payload + * @param ?AuthUser $user + */ + public function log(string $action, array $payload, ?AuthUser $user = null): void { + if ($user == null) { + $user = $this->sudoUserService->userFromCurrentRequest(); + } + + $auditLog = new SudoAuditLog(); + $auditLog->setUserId($user->id); + $auditLog->setAction($action); + $auditLog->setPayload($payload); + $auditLog->setCreatedAt($this->now()); + $auditLog->setUpdatedAt($this->now()); + + $this->em->persist($auditLog); + $this->em->flush(); + } +} From e62dbe3e72fd01ab84e9447cc3a86c604385c214 Mon Sep 17 00:00:00 2001 From: "Sakith B." Date: Fri, 12 Dec 2025 15:14:08 +0530 Subject: [PATCH 2/5] add sudo audit log testing trait --- .../src/Testing/SudoAuditLogTestingTrait.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 bundle/src/Testing/SudoAuditLogTestingTrait.php diff --git a/bundle/src/Testing/SudoAuditLogTestingTrait.php b/bundle/src/Testing/SudoAuditLogTestingTrait.php new file mode 100644 index 0000000..5af77ad --- /dev/null +++ b/bundle/src/Testing/SudoAuditLogTestingTrait.php @@ -0,0 +1,41 @@ + $payload + */ + public function assertSudoLogged(string $action, array $payload): void + { + /** @var EntityManagerInterface $em */ + $em = $this->getContainer()->get(EntityManagerInterface::class); + + $logs = $em->getRepository(SudoAuditLog::class)->findBy([ + 'action' => $action, + ]); + + $found = false; + + foreach ($logs as $log) { + if ($log->getPayload() === $payload) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + sprintf( + 'Expected SudoAuditLog with action "%s" and payload %s.', + $action, + json_encode($payload) + ) + ); + } +} From 5625a8d5362952c58d79ebe1d2fed9bcbb225cb8 Mon Sep 17 00:00:00 2001 From: "Sakith B." Date: Fri, 12 Dec 2025 17:08:40 +0530 Subject: [PATCH 3/5] add sudo audit logs controller --- README.md | 11 +++ .../src/Controller/SudoAuditLogController.php | 86 +++++++++++++++++++ bundle/src/Entity/SudoAuditLog.php | 2 +- src/Sudo/Event/SudoAddedEvent.php | 2 +- src/Sudo/SudoAuditLogService.php | 36 +++++++- 5 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 bundle/src/Controller/SudoAuditLogController.php diff --git a/README.md b/README.md index 50f5cb2..99a46e2 100644 --- a/README.md +++ b/README.md @@ -609,6 +609,17 @@ CREATE TABLE sudo_users ( ); ``` +```sql +CREATE TABLE sudo_audit_logs ( + id BIGINT PRIMARY KEY, + user_id BIGINT, + action TEXT, + payload JSONB, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +``` + ## Logging ```php diff --git a/bundle/src/Controller/SudoAuditLogController.php b/bundle/src/Controller/SudoAuditLogController.php new file mode 100644 index 0000000..e9ccee5 --- /dev/null +++ b/bundle/src/Controller/SudoAuditLogController.php @@ -0,0 +1,86 @@ +query->getInt("limit", 50); + $offset = $request->query->getInt("offset", 0); + $userId = $request->query->getInt("user_id") ?: null; + $action = $request->query->getString("action") ?: null; + $dateStart = $request->query->getInt("date_start") ?: null; + $dateEnd = $request->query->getInt("date_end") ?: null; + $payloadParam = $request->query->getString("payload_param") ?: null; + $payloadValue = $request->query->getString("payload_value") ?: null; + + if ($limit > 100 || $limit < 1) { + throw new BadRequestException("limit should be between 1 and 100"); + } + + if ($offset < 0) { + throw new BadRequestException("offset should not be less than 0"); + } + + if (($payloadParam != null && $payloadValue == null) || ($payloadValue != null && $payloadParam == null)) { + throw new BadRequestException("payload_param and payload_value are both required"); + } + + $dateStart = $dateStart !== null ? (new \DateTimeImmutable())->setTimestamp($dateStart) : null; + $dateEnd = $dateEnd !== null ? (new \DateTimeImmutable())->setTimestamp($dateEnd) : null; + + if ($dateStart && $dateStart < new \DateTimeImmutable('@0')) { + throw new BadRequestException("date_start cannot be negative"); + } + + if ($dateEnd && $dateEnd < new \DateTimeImmutable('@0')) { + throw new BadRequestException("date_end cannot be negative"); + } + + if ($dateStart && $dateEnd && $dateStart >= $dateEnd) { + throw new BadRequestException("date_start must be before date_end"); + } + + $logs = $this->sudoAuditLogService->findLogs( + $userId, + $action, + $dateStart, + $dateEnd, + $payloadParam, + $payloadValue, + $limit, + $offset + ); + + $userIds = array_unique(array_map(fn($log) => $log->getUserId(), $logs)); + $users = $this->auth->fromIds($userIds); + + $data = array_map(fn($log) => [ + "id" => $log->getId(), + "user" => $users[$log->getUserId()] ?? null, + "action" => $log->getAction(), + "payload" => $log->getPayload(), + "created_at" => $log->getCreatedAt()->format("c"), + "updated_at" => $log->getUpdatedAt()->format("c"), + ], $logs); + + return $this->json($data); + } +} diff --git a/bundle/src/Entity/SudoAuditLog.php b/bundle/src/Entity/SudoAuditLog.php index a205cf3..4d71c95 100644 --- a/bundle/src/Entity/SudoAuditLog.php +++ b/bundle/src/Entity/SudoAuditLog.php @@ -22,7 +22,7 @@ class SudoAuditLog private string $action; /** @var array $payload */ - #[ORM\Column(type: 'json')] + #[ORM\Column(type: 'json', options: ['jsonb' => true])] private array $payload; #[ORM\Column(type: 'datetime_immutable')] diff --git a/src/Sudo/Event/SudoAddedEvent.php b/src/Sudo/Event/SudoAddedEvent.php index 8468bd3..ee83537 100644 --- a/src/Sudo/Event/SudoAddedEvent.php +++ b/src/Sudo/Event/SudoAddedEvent.php @@ -18,4 +18,4 @@ public function getSudoUser(): SudoUser return $this->sudoUser; } -} \ No newline at end of file +} diff --git a/src/Sudo/SudoAuditLogService.php b/src/Sudo/SudoAuditLogService.php index 9f05de7..7f683c6 100644 --- a/src/Sudo/SudoAuditLogService.php +++ b/src/Sudo/SudoAuditLogService.php @@ -14,7 +14,8 @@ class SudoAuditLogService public function __construct( private EntityManagerInterface $em, - private SudoUserService $sudoUserService + private SudoUserService $sudoUserService, + private SudoAuditLogRepository $sudoAuditLogRepository ) { } @@ -39,4 +40,37 @@ public function log(string $action, array $payload, ?AuthUser $user = null): voi $this->em->persist($auditLog); $this->em->flush(); } + + /** + * @param ?int $userId + * @param ?string $action + * @param ?\DateTimeImmutable $dateStart + * @param ?\DateTimeImmutable $dateEnd + * @param ?string $payloadParam + * @param ?scalar $payloadValue + * @param ?int $limit + * @param ?int $offset + * @return SudoAuditLog[] + */ + public function findLogs( + ?int $userId, + ?string $action, + ?\DateTimeImmutable $dateStart, + ?\DateTimeImmutable $dateEnd, + ?string $payloadParam, + mixed $payloadValue, + ?int $limit, + ?int $offset + ): array { + return $this->sudoAuditLogRepository->findLogs( + $userId, + $action, + $dateStart, + $dateEnd, + $payloadParam, + $payloadValue, + $limit, + $offset + ); + } } From 0ed750385517aef49d96507587ae1a657b5e3c31 Mon Sep 17 00:00:00 2001 From: "Sakith B." Date: Mon, 15 Dec 2025 16:52:11 +0530 Subject: [PATCH 4/5] add tests --- README.md | 4 +- bundle/src/Entity/SudoAuditLog.php | 14 +-- src/Sudo/SudoAuditLogRepository.php | 64 ++++++++++++++ src/Sudo/SudoAuditLogService.php | 12 +-- tests/SymfonyTestCase.php | 14 ++- tests/Unit/Sudo/SudoAuditLogServiceTest.php | 95 +++++++++++++++++++++ 6 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/Sudo/SudoAuditLogServiceTest.php diff --git a/README.md b/README.md index 99a46e2..25aa685 100644 --- a/README.md +++ b/README.md @@ -611,8 +611,8 @@ CREATE TABLE sudo_users ( ```sql CREATE TABLE sudo_audit_logs ( - id BIGINT PRIMARY KEY, - user_id BIGINT, + id SERIAL PRIMARY KEY, + user_id BIGINT REFERENCES sudo_users(id), action TEXT, payload JSONB, created_at TIMESTAMPTZ NOT NULL, diff --git a/bundle/src/Entity/SudoAuditLog.php b/bundle/src/Entity/SudoAuditLog.php index 4d71c95..f07e6d2 100644 --- a/bundle/src/Entity/SudoAuditLog.php +++ b/bundle/src/Entity/SudoAuditLog.php @@ -6,29 +6,29 @@ use Hyvor\Internal\Sudo\SudoAuditLogRepository; #[ORM\Entity(repositoryClass: SudoAuditLogRepository::class)] -#[ORM\Table(name: 'sudo_audit_logs')] +#[ORM\Table(name: "sudo_audit_logs")] class SudoAuditLog { #[ORM\Id] #[ORM\GeneratedValue] - #[ORM\Column(type: 'bigint')] + #[ORM\Column(type: "integer")] private int $id; - #[ORM\Column(type: 'bigint')] + #[ORM\Column(type: "bigint")] private int $user_id; - #[ORM\Column(type: 'text')] + #[ORM\Column(type: "text")] private string $action; /** @var array $payload */ - #[ORM\Column(type: 'json', options: ['jsonb' => true])] + #[ORM\Column(type: "json", options: ["jsonb" => true])] private array $payload; - #[ORM\Column(type: 'datetime_immutable')] + #[ORM\Column(type: "datetime_immutable")] private \DateTimeImmutable $created_at; - #[ORM\Column(type: 'datetime_immutable')] + #[ORM\Column(type: "datetime_immutable")] private \DateTimeImmutable $updated_at; public function getId(): int diff --git a/src/Sudo/SudoAuditLogRepository.php b/src/Sudo/SudoAuditLogRepository.php index 64a0341..ec6ad29 100644 --- a/src/Sudo/SudoAuditLogRepository.php +++ b/src/Sudo/SudoAuditLogRepository.php @@ -18,4 +18,68 @@ public function __construct(ManagerRegistry $registry) parent::__construct($registry, SudoAuditLog::class); } + /** + * @param ?int $userId + * @param ?string $action + * @param ?\DateTimeImmutable $dateStart + * @param ?\DateTimeImmutable $dateEnd + * @param ?string $payloadParam + * @param ?scalar $payloadValue + * @param ?int $limit + * @param ?int $offset + * @return SudoAuditLog[] + */ + public function findLogs( + ?int $userId, + ?string $action, + ?\DateTimeImmutable $dateStart, + ?\DateTimeImmutable $dateEnd, + ?string $payloadParam, + mixed $payloadValue, + ?int $limit, + ?int $offset + ): array { + $qb = $this->createQueryBuilder("s"); + + if ($userId !== null) { + $qb->andWhere("s.user_id = :userId") + ->setParameter("userId", $userId); + } + + if ($action !== null) { + $qb->andWhere("s.action = :action") + ->setParameter("action", $action); + } + + if ($dateStart !== null) { + $qb->andWhere("s.created_at >= :dateStart") + ->setParameter("dateStart", $dateStart); + } + + if ($dateEnd !== null) { + $qb->andWhere("s.created_at <= :dateEnd") + ->setParameter("dateEnd", $dateEnd); + } + + if ($payloadParam !== null && $payloadValue !== null) { + $qb->andWhere("s.payload @> :payloadFilter") + ->setParameter( + "payloadFilter", + json_encode([$payloadParam => $payloadValue]) + ); + } + + $qb->orderBy("s.created_at", "DESC"); + + if ($limit !== null) { + $qb->setMaxResults($limit); + } + + if ($offset !== null) { + $qb->setFirstResult($offset); + } + + return $qb->getQuery()->getResult(); + } + } diff --git a/src/Sudo/SudoAuditLogService.php b/src/Sudo/SudoAuditLogService.php index 7f683c6..0d8f901 100644 --- a/src/Sudo/SudoAuditLogService.php +++ b/src/Sudo/SudoAuditLogService.php @@ -30,12 +30,12 @@ public function log(string $action, array $payload, ?AuthUser $user = null): voi $user = $this->sudoUserService->userFromCurrentRequest(); } - $auditLog = new SudoAuditLog(); - $auditLog->setUserId($user->id); - $auditLog->setAction($action); - $auditLog->setPayload($payload); - $auditLog->setCreatedAt($this->now()); - $auditLog->setUpdatedAt($this->now()); + $auditLog = new SudoAuditLog() + ->setUserId($user->id) + ->setAction($action) + ->setPayload($payload) + ->setCreatedAt($this->now()) + ->setUpdatedAt($this->now()); $this->em->persist($auditLog); $this->em->flush(); diff --git a/tests/SymfonyTestCase.php b/tests/SymfonyTestCase.php index fc2a248..54a390d 100644 --- a/tests/SymfonyTestCase.php +++ b/tests/SymfonyTestCase.php @@ -110,6 +110,18 @@ protected function createTables(): void ); SQL ); + $connection->executeQuery( + <<container->set(HttpClientInterface::class, $client); } -} \ No newline at end of file +} diff --git a/tests/Unit/Sudo/SudoAuditLogServiceTest.php b/tests/Unit/Sudo/SudoAuditLogServiceTest.php new file mode 100644 index 0000000..062a230 --- /dev/null +++ b/tests/Unit/Sudo/SudoAuditLogServiceTest.php @@ -0,0 +1,95 @@ +createMock(AuthUser::class); + $authUser->id = 1; + $authUser->username = "user"; + $authUser->name = "User"; + $authUser->email = "user@hyvor.com"; + + $request = new Request(); + $request->attributes->set( + SudoAuthorizationListener::RESOLVED_USER_ATTRIBUTE_KEY, + $authUser + ); + + /** @var RequestStack $requestStack */ + $requestStack = $this->container->get(RequestStack::class); + $requestStack->push($request); + + /** @var SudoAuditLogService $service */ + $service = $this->container->get(SudoAuditLogService::class); + + $service->log( + "cancel_subscription", + ["reason" => "expired"], + null + ); + + $logs = $this->em + ->getRepository(SudoAuditLog::class) + ->findAll(); + + $this->assertCount(1, $logs); + + $log = $logs[0]; + + $this->assertSame(1, $log->getUserId()); + $this->assertSame("cancel_subscription", $log->getAction()); + $this->assertSame(["reason" => "expired"], $log->getPayload()); + } + + public function test_find_logs_filters_by_user_and_action(): void + { + $authUser = $this->createMock(AuthUser::class); + $authUser->id = 1; + $authUser->username = "user"; + $authUser->name = "User"; + $authUser->email = "user@hyvor.com"; + + $request = new Request(); + $request->attributes->set( + SudoAuthorizationListener::RESOLVED_USER_ATTRIBUTE_KEY, + $authUser + ); + + /** @var RequestStack $requestStack */ + $requestStack = $this->container->get(RequestStack::class); + $requestStack->push($request); + + /** @var SudoAuditLogService $service */ + $service = $this->container->get(SudoAuditLogService::class); + + $service->log("renew_trial", ["period_days" => 14], null); + $service->log("renew_trial", ["period_days" => 7], null); + $service->log("upgrade_plan", ["to" => "business"], null); + + $results = $service->findLogs( + userId: null, + action: "renew_trial", + dateStart: null, + dateEnd: null, + payloadParam: null, + payloadValue: null, + limit: null, + offset: null + ); + + $this->assertCount(2, $results); + } +} From 697346b1e6177cba467f75cea17df47d1344b0ad Mon Sep 17 00:00:00 2001 From: "Sakith B." Date: Mon, 15 Dec 2025 16:59:22 +0530 Subject: [PATCH 5/5] use trait in test --- bundle/src/Testing/SudoAuditLogTestingTrait.php | 17 ++++++++++++++--- tests/Unit/Sudo/SudoAuditLogServiceTest.php | 5 +++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bundle/src/Testing/SudoAuditLogTestingTrait.php b/bundle/src/Testing/SudoAuditLogTestingTrait.php index 5af77ad..9ec20a8 100644 --- a/bundle/src/Testing/SudoAuditLogTestingTrait.php +++ b/bundle/src/Testing/SudoAuditLogTestingTrait.php @@ -3,6 +3,7 @@ namespace Hyvor\Internal\Bundle\Testing; use Hyvor\Internal\Bundle\Entity\SudoAuditLog; +use Hyvor\Internal\Sudo\SudoAuditLogRepository; use Doctrine\ORM\EntityManagerInterface; trait SudoAuditLogTestingTrait @@ -16,9 +17,19 @@ public function assertSudoLogged(string $action, array $payload): void /** @var EntityManagerInterface $em */ $em = $this->getContainer()->get(EntityManagerInterface::class); - $logs = $em->getRepository(SudoAuditLog::class)->findBy([ - 'action' => $action, - ]); + /** @var SudoAuditLogRepository $repo */ + $repo = $em->getRepository(SudoAuditLog::class); + + $logs = $repo->findLogs( + userId: null, + action: $action, + dateStart: null, + dateEnd: null, + payloadParam: null, + payloadValue: null, + limit: null, + offset: null + ); $found = false; diff --git a/tests/Unit/Sudo/SudoAuditLogServiceTest.php b/tests/Unit/Sudo/SudoAuditLogServiceTest.php index 062a230..29f6e86 100644 --- a/tests/Unit/Sudo/SudoAuditLogServiceTest.php +++ b/tests/Unit/Sudo/SudoAuditLogServiceTest.php @@ -5,6 +5,7 @@ use Hyvor\Internal\Auth\AuthUser; use Hyvor\Internal\Bundle\Api\SudoAuthorizationListener; use Hyvor\Internal\Bundle\Entity\SudoAuditLog; +use Hyvor\Internal\Bundle\Testing\SudoAuditLogTestingTrait; use Hyvor\Internal\Sudo\SudoAuditLogService; use Hyvor\Internal\Tests\SymfonyTestCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -14,6 +15,8 @@ #[CoversClass(SudoAuditLogService::class)] class SudoAuditLogServiceTest extends SymfonyTestCase { + use SudoAuditLogTestingTrait; + public function test_log_persists_audit_log(): void { $authUser = $this->createMock(AuthUser::class); @@ -41,6 +44,8 @@ public function test_log_persists_audit_log(): void null ); + $this->assertSudoLogged("cancel_subscription", ["reason" => "expired"]); + $logs = $this->em ->getRepository(SudoAuditLog::class) ->findAll();