Passed
Pull Request — 3.x (#199)
by
unknown
02:17
created

AuditReader::getBlame()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 12
c 0
b 0
f 0
nc 2
nop 2
dl 0
loc 21
rs 9.8666
1
<?php
2
3
namespace DH\DoctrineAuditBundle\Reader;
4
5
use DateTime;
6
use DH\DoctrineAuditBundle\Annotation\Security;
7
use DH\DoctrineAuditBundle\AuditConfiguration;
8
use DH\DoctrineAuditBundle\Exception\AccessDeniedException;
9
use DH\DoctrineAuditBundle\Exception\InvalidArgumentException;
10
use DH\DoctrineAuditBundle\User\UserInterface;
11
use Doctrine\DBAL\Connection;
12
use Doctrine\DBAL\Query\QueryBuilder;
13
use Doctrine\DBAL\Statement;
14
use Doctrine\ORM\EntityManagerInterface;
15
use Doctrine\ORM\Mapping\ClassMetadata as ORMMetadata;
16
use PDO;
17
use Symfony\Component\Security\Core\Security as CoreSecurity;
18
19
class AuditReader
20
{
21
    public const UPDATE = 'update';
22
    public const ASSOCIATE = 'associate';
23
    public const DISSOCIATE = 'dissociate';
24
    public const INSERT = 'insert';
25
    public const REMOVE = 'remove';
26
27
    public const PAGE_SIZE = 50;
28
29
    /**
30
     * @var AuditConfiguration
31
     */
32
    private $configuration;
33
34
    /**
35
     * @var EntityManagerInterface
36
     */
37
    private $entityManager;
38
39
    /**
40
     * @var array
41
     */
42
    private $filters = [];
43
44
    /**
45
     * AuditReader constructor.
46
     *
47
     * @param AuditConfiguration     $configuration
48
     * @param EntityManagerInterface $entityManager
49
     */
50
    public function __construct(
51
        AuditConfiguration $configuration,
52
        EntityManagerInterface $entityManager
53
    ) {
54
        $this->configuration = $configuration;
55
        $this->entityManager = $entityManager;
56
    }
57
58
    /**
59
     * @return AuditConfiguration
60
     */
61
    public function getConfiguration(): AuditConfiguration
62
    {
63
        return $this->configuration;
64
    }
65
66
    /**
67
     * Set the filter(s) for AuditEntry retrieving.
68
     *
69
     * @param array|string $filter
70
     *
71
     * @return AuditReader
72
     */
73
    public function filterBy($filter): self
74
    {
75
        $filters = \is_array($filter) ? $filter : [$filter];
76
77
        $this->filters = array_filter($filters, static function ($f) {
78
            return \in_array($f, [self::UPDATE, self::ASSOCIATE, self::DISSOCIATE, self::INSERT, self::REMOVE], true);
79
        });
80
81
        return $this;
82
    }
83
84
    /**
85
     * Returns current filter.
86
     *
87
     * @return array
88
     */
89
    public function getFilters(): array
90
    {
91
        return $this->filters;
92
    }
93
94
    /**
95
     * Returns an array of audit table names indexed by entity FQN.
96
     *
97
     * @param null|string $blameId
98
     *
99
     * @throws \Doctrine\ORM\ORMException
100
     *
101
     * @return array
102
     */
103
    public function getEntities(?string $blameId = null): array
104
    {
105
        $metadataDriver = $this->entityManager->getConfiguration()->getMetadataDriverImpl();
106
        $entities = [];
107
        if (null !== $metadataDriver) {
108
            $entities = $metadataDriver->getAllClassNames();
109
        }
110
        $audited = [];
111
112
        foreach ($entities as $entity) {
113
            if ($this->configuration->isAuditable($entity)
114
                && (!$blameId || !empty($this->getBlame($entity, $blameId)))) {
115
                $audited[$entity] = $this->getEntityTableName($entity);
116
            }
117
        }
118
        ksort($audited);
119
120
        return $audited;
121
    }
122
123
    /**
124
     * Returns an array of audited entries/operations.
125
     *
126
     * @param string          $entity
127
     * @param null|int|string $id
128
     * @param null|int        $page
129
     * @param null|int        $pageSize
130
     * @param null|string     $transactionHash
131
     * @param bool            $strict
132
     *
133
     * @throws AccessDeniedException
134
     * @throws InvalidArgumentException
135
     *
136
     * @return array
137
     */
138
    public function getAudits(string $entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): array
139
    {
140
        $this->checkAuditable($entity);
141
        $this->checkRoles($entity, Security::VIEW_SCOPE);
142
143
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict);
144
145
        /** @var Statement $statement */
146
        $statement = $queryBuilder->execute();
147
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
148
149
        return $statement->fetchAll();
150
    }
151
152
    /**
153
     * Returns an array of all audited entries/operations for a given transaction hash
154
     * indexed by entity FQCN.
155
     *
156
     * @param string $transactionHash
157
     *
158
     * @throws InvalidArgumentException
159
     * @throws \Doctrine\ORM\ORMException
160
     *
161
     * @return array
162
     */
163
    public function getAuditsByTransactionHash(string $transactionHash): array
164
    {
165
        $results = [];
166
167
        $entities = $this->getEntities();
168
        foreach ($entities as $entity => $tablename) {
169
            try {
170
                $audits = $this->getAudits($entity, null, null, null, $transactionHash);
171
                if (\count($audits) > 0) {
172
                    $results[$entity] = $audits;
173
                }
174
            } catch (AccessDeniedException $e) {
175
                // acces denied
176
            }
177
        }
178
179
        return $results;
180
    }
181
182
    /**
183
     * Returns an array of audited entries/operations.
184
     *
185
     * @param string          $entity
186
     * @param null|int|string $id
187
     * @param null|DateTime   $startDate       - Expected in configured timezone
188
     * @param null|DateTime   $endDate         - Expected in configured timezone
189
     * @param null|int        $page
190
     * @param null|int        $pageSize
191
     * @param null|string     $transactionHash
192
     * @param bool            $strict
193
     *
194
     * @throws AccessDeniedException
195
     * @throws InvalidArgumentException
196
     *
197
     * @return array
198
     */
199
    public function getAuditsByDate(string $entity, $id = null, ?DateTime $startDate = null, ?DateTime $endDate = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): array
200
    {
201
        $this->checkAuditable($entity);
202
        $this->checkRoles($entity, Security::VIEW_SCOPE);
203
204
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict, $startDate, $endDate);
205
206
        /** @var Statement $statement */
207
        $statement = $queryBuilder->execute();
208
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
209
210
        return $statement->fetchAll();
211
    }
212
213
    /**
214
     * Returns an array of audited entries/operations.
215
     *
216
     * @param string          $entity
217
     * @param null|int|string $id
218
     * @param int             $page
219
     * @param int             $pageSize
220
     * @param null|string     $transactionHash
221
     * @param bool            $strict
222
     * @param null|DateTime   $startDate
223
     * @param null|DateTime   $endDate
224
     * @param null|string     $userId
225
     *
226
     * @throws AccessDeniedException
227
     * @throws InvalidArgumentException
228
     *
229
     * @return array
230
     */
231
    public function getAuditsPager(
232
        string $entity,
233
        $id = null,
234
        ?int $page = 1,
235
        ?int $pageSize = self::PAGE_SIZE,
236
        ?string $transactionHash = null,
237
        bool $strict = true,
238
        ?DateTime $startDate = null,
239
        ?DateTime $endDate = null,
240
        ?string $userId = null
241
    ): array {
242
        $queryBuilder = $this->getAuditsQueryBuilder(
243
            $entity,
244
            $id,
245
            $page,
246
            $pageSize,
247
            $transactionHash,
248
            $strict,
249
            $startDate,
250
            $endDate,
251
            $userId
252
        );
253
254
        $paginator = new Paginator($queryBuilder);
255
        $numResults = $paginator->count();
256
257
        $currentPage = $page < 1 ? 1 : $page;
258
        $hasPreviousPage = $currentPage > 1;
259
        $hasNextPage = ($currentPage * $pageSize) < $numResults;
260
261
        return [
262
            'results' => $paginator->getIterator(),
263
            'currentPage' => $currentPage,
264
            'hasPreviousPage' => $hasPreviousPage,
265
            'hasNextPage' => $hasNextPage,
266
            'previousPage' => $hasPreviousPage ? $currentPage - 1 : null,
267
            'nextPage' => $hasNextPage ? $currentPage + 1 : null,
268
            'numPages' => (int) ceil($numResults / $pageSize),
269
            'haveToPaginate' => $numResults > $pageSize,
270
        ];
271
    }
272
273
    /**
274
     * Returns the amount of audited entries/operations.
275
     *
276
     * @param string          $entity
277
     * @param null|int|string $id
278
     * @param null|string     $blameId
279
     *
280
     * @throws AccessDeniedException
281
     * @throws InvalidArgumentException
282
     *
283
     * @return int
284
     */
285
    public function getAuditsCount(string $entity, $id = null, ?string $blameId = null): int
286
    {
287
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, null, null, null, true, null, null, $blameId);
288
289
        $result = $queryBuilder
290
            ->resetQueryPart('select')
291
            ->resetQueryPart('orderBy')
292
            ->select('COUNT(id)')
293
            ->execute()
294
            ->fetchColumn(0)
295
        ;
296
297
        return false === $result ? 0 : $result;
298
    }
299
300
    /**
301
     * @param string $entity
302
     * @param string $id
303
     *
304
     * @throws AccessDeniedException
305
     * @throws InvalidArgumentException
306
     *
307
     * @return mixed[]
308
     */
309
    public function getAudit(string $entity, $id): array
310
    {
311
        $this->checkAuditable($entity);
312
        $this->checkRoles($entity, Security::VIEW_SCOPE);
313
314
        $connection = $this->entityManager->getConnection();
315
316
        /**
317
         * @var \Doctrine\DBAL\Query\QueryBuilder
318
         */
319
        $queryBuilder = $connection->createQueryBuilder();
320
        $queryBuilder
321
            ->select('*')
322
            ->from($this->getEntityAuditTableName($entity))
323
            ->where('id = :id')
324
            ->setParameter('id', $id)
325
        ;
326
327
        $this->filterByType($queryBuilder, $this->filters);
328
329
        /** @var Statement $statement */
330
        $statement = $queryBuilder->execute();
331
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
332
333
        return $statement->fetchAll();
334
    }
335
336
    public function getBlames(): array
337
    {
338
        $blames = [];
339
340
        foreach ($this->getEntities() as $entity => $tablename) {
341
            $blames[] = $this->getBlame($entity);
342
        }
343
344
        $blames = array_unique(array_merge(...$blames));
345
346
        asort($blames);
347
348
        return $blames;
349
    }
350
351
    public function getBlame(string $entity, ?string $blameId = null): array
352
    {
353
        $blame = [];
354
355
        $results = $this->getAuditsQueryBuilder($entity, null, null, null, null, true, null, null, $blameId)
356
            ->resetQueryPart('orderBy')
357
            ->select('blame_id, blame_user')
358
            ->groupBy('blame_id')
359
            ->addGroupBy('blame_user')
360
            ->execute()
361
            ->fetchAll()
362
        ;
363
364
        foreach ($results as $result) {
365
            //set the key to null and the user to System/Anonymous if the blame id is null
366
            $blame[$result['blame_id'] ?? 'null'] = $result['blame_user'] ?? ' System/Anonymous';
367
        }
368
369
        asort($blame);
370
371
        return $blame;
372
    }
373
374
    /**
375
     * Returns the table name of $entity.
376
     *
377
     * @param string $entity
378
     *
379
     * @return string
380
     */
381
    public function getEntityTableName(string $entity): string
382
    {
383
        return $this->entityManager->getClassMetadata($entity)->getTableName();
384
    }
385
386
    /**
387
     * Returns the audit table name for $entity.
388
     *
389
     * @param string $entity
390
     *
391
     * @return string
392
     */
393
    public function getEntityAuditTableName(string $entity): string
394
    {
395
        $schema = '';
396
        if ($this->entityManager->getClassMetadata($entity)->getSchemaName()) {
397
            $schema = $this->entityManager->getClassMetadata($entity)->getSchemaName().'.';
398
        }
399
400
        return sprintf('%s%s%s%s', $schema, $this->configuration->getTablePrefix(), $this->getEntityTableName($entity), $this->configuration->getTableSuffix());
401
    }
402
403
    /**
404
     * @return EntityManagerInterface
405
     */
406
    public function getEntityManager(): EntityManagerInterface
407
    {
408
        return $this->entityManager;
409
    }
410
411
    private function filterByType(QueryBuilder $queryBuilder, array $filters): QueryBuilder
412
    {
413
        if (!empty($filters)) {
414
            $queryBuilder
415
                ->andWhere('type IN (:filters)')
416
                ->setParameter('filters', $filters, Connection::PARAM_STR_ARRAY)
417
            ;
418
        }
419
420
        return $queryBuilder;
421
    }
422
423
    private function filterByTransaction(QueryBuilder $queryBuilder, ?string $transactionHash): QueryBuilder
424
    {
425
        if (null !== $transactionHash) {
426
            $queryBuilder
427
                ->andWhere('transaction_hash = :transaction_hash')
428
                ->setParameter('transaction_hash', $transactionHash)
429
            ;
430
        }
431
432
        return $queryBuilder;
433
    }
434
435
    private function filterByDate(QueryBuilder $queryBuilder, ?DateTime $startDate, ?DateTime $endDate): QueryBuilder
436
    {
437
        if (null !== $startDate && null !== $endDate && $endDate < $startDate) {
438
            throw new \InvalidArgumentException('$endDate must be greater than $startDate.');
439
        }
440
441
        if (null !== $startDate) {
442
            $queryBuilder
443
                ->andWhere('created_at >= :start_date')
444
                ->setParameter('start_date', $startDate->format('Y-m-d H:i:s'))
445
            ;
446
        }
447
448
        if (null !== $endDate) {
449
            $queryBuilder
450
                ->andWhere('created_at <= :end_date')
451
                ->setParameter('end_date', $endDate->format('Y-m-d H:i:s'))
452
            ;
453
        }
454
455
        return $queryBuilder;
456
    }
457
458
    /**
459
     * @param QueryBuilder    $queryBuilder
460
     * @param null|int|string $id
461
     *
462
     * @return QueryBuilder
463
     */
464
    private function filterByObjectId(QueryBuilder $queryBuilder, $id): QueryBuilder
465
    {
466
        if (null !== $id) {
467
            $queryBuilder
468
                ->andWhere('object_id = :object_id')
469
                ->setParameter('object_id', $id)
470
            ;
471
        }
472
473
        return $queryBuilder;
474
    }
475
476
    /**
477
     * @param QueryBuilder    $queryBuilder
478
     * @param null|int|string $id
479
     *
480
     * @return QueryBuilder
481
     */
482
    private function filterByBlameId(QueryBuilder $queryBuilder, $id): QueryBuilder
483
    {
484
        switch ($id) {
485
            case null:
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $id of type integer|string against null; this is ambiguous if the integer can be zero. Consider using a strict comparison === instead.
Loading history...
486
                //no user selected
487
                break;
488
            case 'null':
489
                $queryBuilder
490
                    ->andWhere('blame_id IS NULL')
491
                ;
492
                break;
493
            default:
494
                //a specific user only
495
                $queryBuilder
496
                    ->andWhere('blame_id = :blame_id')
497
                    ->setParameter('blame_id', $id)
498
                ;
499
                break;
500
        }
501
502
        return $queryBuilder;
503
    }
504
505
    /**
506
     * Returns an array of audited entries/operations.
507
     *
508
     * @param string          $entity
509
     * @param null|int|string $id
510
     * @param null|int        $page
511
     * @param null|int        $pageSize
512
     * @param null|string     $transactionHash
513
     * @param bool            $strict
514
     * @param null|DateTime   $startDate
515
     * @param null|DateTime   $endDate
516
     * @param null|string     $blameId
517
     *
518
     * @throws AccessDeniedException
519
     * @throws InvalidArgumentException
520
     *
521
     * @return QueryBuilder
522
     */
523
    private function getAuditsQueryBuilder(
524
        string $entity,
525
        $id = null,
526
        ?int $page = null,
527
        ?int $pageSize = null,
528
        ?string $transactionHash = null,
529
        bool $strict = true,
530
        ?DateTime $startDate = null,
531
        ?DateTime $endDate = null,
532
        ?string $blameId = null
533
    ): QueryBuilder {
534
        $this->checkAuditable($entity);
535
        $this->checkRoles($entity, Security::VIEW_SCOPE);
536
537
        if (null !== $page && $page < 1) {
538
            throw new \InvalidArgumentException('$page must be greater or equal than 1.');
539
        }
540
541
        if (null !== $pageSize && $pageSize < 1) {
542
            throw new \InvalidArgumentException('$pageSize must be greater or equal than 1.');
543
        }
544
545
        $storage = $this->configuration->getEntityManager() ?? $this->entityManager;
546
        $connection = $storage->getConnection();
547
548
        $queryBuilder = $connection->createQueryBuilder();
549
        $queryBuilder
550
            ->select('*')
551
            ->from($this->getEntityAuditTableName($entity), 'at')
552
            ->orderBy('created_at', 'DESC')
553
            ->addOrderBy('id', 'DESC')
554
        ;
555
556
        $metadata = $this->entityManager->getClassMetadata($entity);
557
        if ($strict && $metadata instanceof ORMMetadata && ORMMetadata::INHERITANCE_TYPE_SINGLE_TABLE === $metadata->inheritanceType) {
558
            $queryBuilder
559
                ->andWhere('discriminator = :discriminator')
560
                ->setParameter('discriminator', $entity)
561
            ;
562
        }
563
564
        $this->filterByObjectId($queryBuilder, $id);
565
        $this->filterByType($queryBuilder, $this->filters);
566
        $this->filterByTransaction($queryBuilder, $transactionHash);
567
        $this->filterByDate($queryBuilder, $startDate, $endDate);
568
        $this->filterByBlameId($queryBuilder, $blameId);
569
570
        if (null !== $pageSize) {
571
            $queryBuilder
572
                ->setFirstResult(($page - 1) * $pageSize)
573
                ->setMaxResults($pageSize)
574
            ;
575
        }
576
577
        return $queryBuilder;
578
    }
579
580
    /**
581
     * Throws an InvalidArgumentException if given entity is not auditable.
582
     *
583
     * @param string $entity
584
     *
585
     * @throws InvalidArgumentException
586
     */
587
    private function checkAuditable(string $entity): void
588
    {
589
        if (!$this->configuration->isAuditable($entity)) {
590
            throw new InvalidArgumentException('Entity '.$entity.' is not auditable.');
591
        }
592
    }
593
594
    /**
595
     * Throws an AccessDeniedException if user not is granted to access audits for the given entity.
596
     *
597
     * @param string $entity
598
     * @param string $scope
599
     *
600
     * @throws AccessDeniedException
601
     */
602
    private function checkRoles(string $entity, string $scope): void
603
    {
604
        $userProvider = $this->configuration->getUserProvider();
605
        $user = null === $userProvider ? null : $userProvider->getUser();
606
        $security = null === $userProvider ? null : $userProvider->getSecurity();
607
608
        if (!($user instanceof UserInterface) || !($security instanceof CoreSecurity)) {
609
            // If no security defined or no user identified, consider access granted
610
            return;
611
        }
612
613
        $entities = $this->configuration->getEntities();
614
615
        $roles = $entities[$entity]['roles'] ?? null;
616
617
        if (null === $roles) {
618
            // If no roles are configured, consider access granted
619
            return;
620
        }
621
622
        $scope = $roles[$scope] ?? null;
623
624
        if (null === $scope) {
625
            // If no roles for the given scope are configured, consider access granted
626
            return;
627
        }
628
629
        // roles are defined for the give scope
630
        foreach ($scope as $role) {
631
            if ($security->isGranted($role)) {
632
                // role granted => access granted
633
                return;
634
            }
635
        }
636
637
        // access denied
638
        throw new AccessDeniedException('You are not allowed to access audits of '.$entity.' entity.');
639
    }
640
}
641