Passed
Pull Request — 3.x (#201)
by Giso
02:52
created

AuditReader::hasCondition()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
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\Common\Collections\ArrayCollection;
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
     * @var ArrayCollection
46
     */
47
    private $conditions;
48
49
    /**
50
     * AuditReader constructor.
51
     *
52
     * @param AuditConfiguration     $configuration
53
     * @param EntityManagerInterface $entityManager
54
     */
55
    public function __construct(
56
        AuditConfiguration $configuration,
57
        EntityManagerInterface $entityManager
58
    ) {
59
        $this->configuration = $configuration;
60
        $this->entityManager = $entityManager;
61
62
        $this->conditions    = new ArrayCollection();
63
    }
64
65
    /**
66
     * @return AuditConfiguration
67
     */
68
    public function getConfiguration(): AuditConfiguration
69
    {
70
        return $this->configuration;
71
    }
72
73
    /**
74
     * Set the filter(s) for AuditEntry retrieving.
75
     *
76
     * @param array|string $filter
77
     *
78
     * @return AuditReader
79
     */
80
    public function filterBy($filter): self
81
    {
82
        $filters = \is_array($filter) ? $filter : [$filter];
83
84
        $this->filters = array_filter($filters, static function ($f) {
85
            return \in_array($f, [self::UPDATE, self::ASSOCIATE, self::DISSOCIATE, self::INSERT, self::REMOVE], true);
86
        });
87
88
        return $this;
89
    }
90
91
    /**
92
     * Add a condition (if it was not yet added)
93
     *
94
     * @param ConditionInterface $condition
95
     *
96
     * @return Reader
0 ignored issues
show
Bug introduced by
The type DH\DoctrineAuditBundle\Reader\Reader was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
97
     */
98
    public function addCondition(ConditionInterface $condition): self
99
    {
100
        if (!$this->conditions->contains($condition)) {
101
            $this->conditions->add($condition);
102
        }
103
104
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type DH\DoctrineAuditBundle\Reader\AuditReader which is incompatible with the documented return type DH\DoctrineAuditBundle\Reader\Reader.
Loading history...
105
    }
106
107
    /**
108
     * Check if a condition was set
109
     *
110
     * @param ConditionInterface $condition
111
     *
112
     * @return bool
113
     */
114
    public function hasCondition(ConditionInterface $condition): bool
115
    {
116
        return $this->conditions->contains($condition);
117
    }
118
119
    /**
120
     * Set all conditions
121
     *
122
     * @param ArrayCollection $conditions
123
     *
124
     * @return Reader
125
     */
126
    public function setConditions(ArrayCollection $conditions): self
127
    {
128
        $this->conditions = $conditions;
129
130
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type DH\DoctrineAuditBundle\Reader\AuditReader which is incompatible with the documented return type DH\DoctrineAuditBundle\Reader\Reader.
Loading history...
131
    }
132
133
    /**
134
     * Remove a Condition and returns the removed condition
135
     *
136
     * @param ConditionInterface $condition
137
     * @return ConditionInterface
138
     */
139
    public function removeCondition(ConditionInterface $condition): ConditionInterface
140
    {
141
        if (!$this->conditions->contains($condition)) {
142
            throw new \RuntimeException('Can not remove a condition that was not set');
143
        }
144
145
        $index = $this->conditions->indexOf($condition);
146
147
        return $this->conditions->remove($index);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->conditions->remove($index) could return the type null which is incompatible with the type-hinted return DH\DoctrineAuditBundle\Reader\ConditionInterface. Consider adding an additional type-check to rule them out.
Loading history...
148
    }
149
150
    /**
151
     * Returns current filter.
152
     *
153
     * @return array
154
     */
155
    public function getFilters(): array
156
    {
157
        return $this->filters;
158
    }
159
160
    /**
161
     * Returns an array of audit table names indexed by entity FQN.
162
     *
163
     * @throws \Doctrine\ORM\ORMException
164
     *
165
     * @return array
166
     */
167
    public function getEntities(): array
168
    {
169
        $metadataDriver = $this->entityManager->getConfiguration()->getMetadataDriverImpl();
170
        $entities = [];
171
        if (null !== $metadataDriver) {
172
            $entities = $metadataDriver->getAllClassNames();
173
        }
174
        $audited = [];
175
        foreach ($entities as $entity) {
176
            if ($this->configuration->isAuditable($entity)) {
177
                $audited[$entity] = $this->getEntityTableName($entity);
178
            }
179
        }
180
        ksort($audited);
181
182
        return $audited;
183
    }
184
185
    /**
186
     * Returns an array of audited entries/operations.
187
     *
188
     * @param string          $entity
189
     * @param null|int|string $id
190
     * @param null|int        $page
191
     * @param null|int        $pageSize
192
     * @param null|string     $transactionHash
193
     * @param bool            $strict
194
     *
195
     * @throws AccessDeniedException
196
     * @throws InvalidArgumentException
197
     *
198
     * @return array
199
     */
200
    public function getAudits(string $entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): array
201
    {
202
        $this->checkAuditable($entity);
203
        $this->checkRoles($entity, Security::VIEW_SCOPE);
204
205
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict);
206
207
        /** @var Statement $statement */
208
        $statement = $queryBuilder->execute();
209
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
210
211
        return $statement->fetchAll();
212
    }
213
214
    /**
215
     * Returns an array of all audited entries/operations for a given transaction hash
216
     * indexed by entity FQCN.
217
     *
218
     * @param string $transactionHash
219
     *
220
     * @throws InvalidArgumentException
221
     * @throws \Doctrine\ORM\ORMException
222
     *
223
     * @return array
224
     */
225
    public function getAuditsByTransactionHash(string $transactionHash): array
226
    {
227
        $results = [];
228
229
        $entities = $this->getEntities();
230
        foreach ($entities as $entity => $tablename) {
231
            try {
232
                $audits = $this->getAudits($entity, null, null, null, $transactionHash);
233
                if (\count($audits) > 0) {
234
                    $results[$entity] = $audits;
235
                }
236
            } catch (AccessDeniedException $e) {
237
                // acces denied
238
            }
239
        }
240
241
        return $results;
242
    }
243
244
    /**
245
     * Returns an array of audited entries/operations.
246
     *
247
     * @param string          $entity
248
     * @param null|int|string $id
249
     * @param null|DateTime   $startDate       - Expected in configured timezone
250
     * @param null|DateTime   $endDate         - Expected in configured timezone
251
     * @param null|int        $page
252
     * @param null|int        $pageSize
253
     * @param null|string     $transactionHash
254
     * @param bool            $strict
255
     *
256
     * @throws AccessDeniedException
257
     * @throws InvalidArgumentException
258
     *
259
     * @return array
260
     */
261
    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
262
    {
263
        $this->checkAuditable($entity);
264
        $this->checkRoles($entity, Security::VIEW_SCOPE);
265
266
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict, $startDate, $endDate);
267
268
        /** @var Statement $statement */
269
        $statement = $queryBuilder->execute();
270
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
271
272
        return $statement->fetchAll();
273
    }
274
275
    /**
276
     * Returns an array of audited entries/operations.
277
     *
278
     * @param string          $entity
279
     * @param null|int|string $id
280
     * @param int             $page
281
     * @param int             $pageSize
282
     *
283
     * @throws AccessDeniedException
284
     * @throws InvalidArgumentException
285
     *
286
     * @return array
287
     */
288
    public function getAuditsPager(string $entity, $id = null, int $page = 1, int $pageSize = self::PAGE_SIZE): array
289
    {
290
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize);
291
292
        $paginator = new Paginator($queryBuilder);
293
        $numResults = $paginator->count();
294
295
        $currentPage = $page < 1 ? 1 : $page;
296
        $hasPreviousPage = $currentPage > 1;
297
        $hasNextPage = ($currentPage * $pageSize) < $numResults;
298
299
        return [
300
            'results' => $paginator->getIterator(),
301
            'currentPage' => $currentPage,
302
            'hasPreviousPage' => $hasPreviousPage,
303
            'hasNextPage' => $hasNextPage,
304
            'previousPage' => $hasPreviousPage ? $currentPage - 1 : null,
305
            'nextPage' => $hasNextPage ? $currentPage + 1 : null,
306
            'numPages' => (int) ceil($numResults / $pageSize),
307
            'haveToPaginate' => $numResults > $pageSize,
308
        ];
309
    }
310
311
    /**
312
     * Returns the amount of audited entries/operations.
313
     *
314
     * @param string          $entity
315
     * @param null|int|string $id
316
     *
317
     * @throws AccessDeniedException
318
     * @throws InvalidArgumentException
319
     *
320
     * @return int
321
     */
322
    public function getAuditsCount(string $entity, $id = null): int
323
    {
324
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id);
325
326
        $result = $queryBuilder
327
            ->resetQueryPart('select')
328
            ->resetQueryPart('orderBy')
329
            ->select('COUNT(id)')
330
            ->execute()
331
            ->fetchColumn(0)
332
        ;
333
334
        return false === $result ? 0 : $result;
335
    }
336
337
    /**
338
     * @param string $entity
339
     * @param string $id
340
     *
341
     * @throws AccessDeniedException
342
     * @throws InvalidArgumentException
343
     *
344
     * @return mixed[]
345
     */
346
    public function getAudit(string $entity, $id): array
347
    {
348
        $this->checkAuditable($entity);
349
        $this->checkRoles($entity, Security::VIEW_SCOPE);
350
351
        $connection = $this->entityManager->getConnection();
352
353
        /**
354
         * @var \Doctrine\DBAL\Query\QueryBuilder
355
         */
356
        $queryBuilder = $connection->createQueryBuilder();
357
        $queryBuilder
358
            ->select('*')
359
            ->from($this->getEntityAuditTableName($entity))
360
            ->where('id = :id')
361
            ->setParameter('id', $id)
362
        ;
363
364
        $this->filterByType($queryBuilder, $this->filters);
365
366
        /** @var Statement $statement */
367
        $statement = $queryBuilder->execute();
368
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
369
370
        return $statement->fetchAll();
371
    }
372
373
    /**
374
     * Returns the table name of $entity.
375
     *
376
     * @param string $entity
377
     *
378
     * @return string
379
     */
380
    public function getEntityTableName(string $entity): string
381
    {
382
        return $this->entityManager->getClassMetadata($entity)->getTableName();
383
    }
384
385
    /**
386
     * Returns the audit table name for $entity.
387
     *
388
     * @param string $entity
389
     *
390
     * @return string
391
     */
392
    public function getEntityAuditTableName(string $entity): string
393
    {
394
        $schema = '';
395
        if ($this->entityManager->getClassMetadata($entity)->getSchemaName()) {
396
            $schema = $this->entityManager->getClassMetadata($entity)->getSchemaName().'.';
397
        }
398
399
        return sprintf('%s%s%s%s', $schema, $this->configuration->getTablePrefix(), $this->getEntityTableName($entity), $this->configuration->getTableSuffix());
400
    }
401
402
    /**
403
     * @return EntityManagerInterface
404
     */
405
    public function getEntityManager(): EntityManagerInterface
406
    {
407
        return $this->entityManager;
408
    }
409
410
    private function filterByType(QueryBuilder $queryBuilder, array $filters): QueryBuilder
411
    {
412
        (new TypesCondition($filters))->apply($queryBuilder);
413
414
        return $queryBuilder;
415
    }
416
417
    private function filterByTransaction(QueryBuilder $queryBuilder, ?string $transactionHash): QueryBuilder
418
    {
419
        (new TransactionCondition($transactionHash))->apply($queryBuilder);
420
421
        return $queryBuilder;
422
    }
423
424
    private function filterByDate(QueryBuilder $queryBuilder, ?DateTime $startDate, ?DateTime $endDate): QueryBuilder
425
    {
426
        (new DateRangeCondition($startDate, $endDate))->apply($queryBuilder);
427
428
        return $queryBuilder;
429
    }
430
431
    /**
432
     * @param QueryBuilder    $queryBuilder
433
     * @param null|int|string $id
434
     *
435
     * @return QueryBuilder
436
     */
437
    private function filterByObjectId(QueryBuilder $queryBuilder, $id): QueryBuilder
438
    {
439
        (new ObjectIdCondition($id))->apply($queryBuilder);
440
441
        return $queryBuilder;
442
    }
443
444
    /**
445
     * Returns an array of audited entries/operations.
446
     *
447
     * @param string          $entity
448
     * @param null|int|string $id
449
     * @param null|int        $page
450
     * @param null|int        $pageSize
451
     * @param null|string     $transactionHash
452
     * @param bool            $strict
453
     * @param null|DateTime   $startDate
454
     * @param null|DateTime   $endDate
455
     *
456
     * @throws AccessDeniedException
457
     * @throws InvalidArgumentException
458
     *
459
     * @return QueryBuilder
460
     */
461
    private function getAuditsQueryBuilder(string $entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true, ?DateTime $startDate = null, ?DateTime $endDate = null): QueryBuilder
462
    {
463
        $this->checkAuditable($entity);
464
        $this->checkRoles($entity, Security::VIEW_SCOPE);
465
466
        if (null !== $page && $page < 1) {
467
            throw new \InvalidArgumentException('$page must be greater or equal than 1.');
468
        }
469
470
        if (null !== $pageSize && $pageSize < 1) {
471
            throw new \InvalidArgumentException('$pageSize must be greater or equal than 1.');
472
        }
473
474
        $storage = $this->configuration->getEntityManager() ?? $this->entityManager;
475
        $connection = $storage->getConnection();
476
477
        $queryBuilder = $connection->createQueryBuilder();
478
        $queryBuilder
479
            ->select('*')
480
            ->from($this->getEntityAuditTableName($entity), 'at')
481
            ->orderBy('created_at', 'DESC')
482
            ->addOrderBy('id', 'DESC')
483
        ;
484
485
        $metadata = $this->entityManager->getClassMetadata($entity);
486
        if ($strict && $metadata instanceof ORMMetadata && ORMMetadata::INHERITANCE_TYPE_SINGLE_TABLE === $metadata->inheritanceType) {
487
            $queryBuilder
488
                ->andWhere('discriminator = :discriminator')
489
                ->setParameter('discriminator', $entity)
490
            ;
491
        }
492
493
        $this->filterByObjectId($queryBuilder, $id);
494
        $this->filterByType($queryBuilder, $this->filters);
495
        $this->filterByTransaction($queryBuilder, $transactionHash);
496
        $this->filterByDate($queryBuilder, $startDate, $endDate);
497
        $this->filterByConditions($queryBuilder);
498
499
        if (null !== $pageSize) {
500
            $queryBuilder
501
                ->setFirstResult(($page - 1) * $pageSize)
502
                ->setMaxResults($pageSize)
503
            ;
504
        }
505
506
        return $queryBuilder;
507
    }
508
509
    /**
510
     * Throws an InvalidArgumentException if given entity is not auditable.
511
     *
512
     * @param string $entity
513
     *
514
     * @throws InvalidArgumentException
515
     */
516
    private function checkAuditable(string $entity): void
517
    {
518
        if (!$this->configuration->isAuditable($entity)) {
519
            throw new InvalidArgumentException('Entity '.$entity.' is not auditable.');
520
        }
521
    }
522
523
    /**
524
     * Throws an AccessDeniedException if user not is granted to access audits for the given entity.
525
     *
526
     * @param string $entity
527
     * @param string $scope
528
     *
529
     * @throws AccessDeniedException
530
     */
531
    private function checkRoles(string $entity, string $scope): void
532
    {
533
        $userProvider = $this->configuration->getUserProvider();
534
        $user = null === $userProvider ? null : $userProvider->getUser();
535
        $security = null === $userProvider ? null : $userProvider->getSecurity();
536
537
        if (!($user instanceof UserInterface) || !($security instanceof CoreSecurity)) {
538
            // If no security defined or no user identified, consider access granted
539
            return;
540
        }
541
542
        $entities = $this->configuration->getEntities();
543
544
        $roles = $entities[$entity]['roles'] ?? null;
545
546
        if (null === $roles) {
547
            // If no roles are configured, consider access granted
548
            return;
549
        }
550
551
        $scope = $roles[$scope] ?? null;
552
553
        if (null === $scope) {
554
            // If no roles for the given scope are configured, consider access granted
555
            return;
556
        }
557
558
        // roles are defined for the give scope
559
        foreach ($scope as $role) {
560
            if ($security->isGranted($role)) {
561
                // role granted => access granted
562
                return;
563
            }
564
        }
565
566
        // access denied
567
        throw new AccessDeniedException('You are not allowed to access audits of '.$entity.' entity.');
568
    }
569
570
    private function filterByConditions(QueryBuilder $queryBuilder)
571
    {
572
        foreach ($this->conditions as $condition) {
573
            $condition->apply($queryBuilder);
574
        }
575
    }
576
}
577