diff --git a/README.md b/README.md index 50f5cb2..25aa685 100644 --- a/README.md +++ b/README.md @@ -609,6 +609,17 @@ CREATE TABLE sudo_users ( ); ``` +```sql +CREATE TABLE sudo_audit_logs ( + id SERIAL PRIMARY KEY, + user_id BIGINT REFERENCES sudo_users(id), + 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 new file mode 100644 index 0000000..f07e6d2 --- /dev/null +++ b/bundle/src/Entity/SudoAuditLog.php @@ -0,0 +1,102 @@ + $payload */ + #[ORM\Column(type: "json", options: ["jsonb" => true])] + 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/bundle/src/Testing/SudoAuditLogTestingTrait.php b/bundle/src/Testing/SudoAuditLogTestingTrait.php new file mode 100644 index 0000000..9ec20a8 --- /dev/null +++ b/bundle/src/Testing/SudoAuditLogTestingTrait.php @@ -0,0 +1,52 @@ + $payload + */ + public function assertSudoLogged(string $action, array $payload): void + { + /** @var EntityManagerInterface $em */ + $em = $this->getContainer()->get(EntityManagerInterface::class); + + /** @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; + + 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) + ) + ); + } +} 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/SudoAuditLogRepository.php b/src/Sudo/SudoAuditLogRepository.php new file mode 100644 index 0000000..ec6ad29 --- /dev/null +++ b/src/Sudo/SudoAuditLogRepository.php @@ -0,0 +1,85 @@ + + * @codeCoverageIgnore + */ +class SudoAuditLogRepository extends ServiceEntityRepository +{ + + 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 new file mode 100644 index 0000000..0d8f901 --- /dev/null +++ b/src/Sudo/SudoAuditLogService.php @@ -0,0 +1,76 @@ + $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() + ->setUserId($user->id) + ->setAction($action) + ->setPayload($payload) + ->setCreatedAt($this->now()) + ->setUpdatedAt($this->now()); + + $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 + ); + } +} 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..29f6e86 --- /dev/null +++ b/tests/Unit/Sudo/SudoAuditLogServiceTest.php @@ -0,0 +1,100 @@ +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 + ); + + $this->assertSudoLogged("cancel_subscription", ["reason" => "expired"]); + + $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); + } +}