Passed
Pull Request — master (#127)
by Damien
03:56
created

AuditReader::getFilters()   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
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace DH\DoctrineAuditBundle\Reader;
4
5
use DH\DoctrineAuditBundle\Annotation\Security;
6
use DH\DoctrineAuditBundle\AuditConfiguration;
7
use DH\DoctrineAuditBundle\Exception\AccessDeniedException;
8
use DH\DoctrineAuditBundle\Exception\InvalidArgumentException;
9
use DH\DoctrineAuditBundle\User\UserInterface;
10
use Doctrine\DBAL\Connection;
11
use Doctrine\DBAL\Query\QueryBuilder;
12
use Doctrine\DBAL\Statement;
13
use Doctrine\ORM\EntityManagerInterface;
14
use Doctrine\ORM\Mapping\ClassMetadata as ORMMetadata;
15
use Pagerfanta\Adapter\DoctrineDbalSingleTableAdapter;
16
use Pagerfanta\Pagerfanta;
17
use PDO;
18
use Symfony\Component\Security\Core\Security as CoreSecurity;
19
20
class AuditReader
21
{
22
    public const UPDATE = 'update';
23
    public const ASSOCIATE = 'associate';
24
    public const DISSOCIATE = 'dissociate';
25
    public const INSERT = 'insert';
26
    public const REMOVE = 'remove';
27
28
    public const PAGE_SIZE = 50;
29
30
    /**
31
     * @var AuditConfiguration
32
     */
33
    private $configuration;
34
35
    /**
36
     * @var EntityManagerInterface
37
     */
38
    private $entityManager;
39
40
    /**
41
     * @var array
42
     */
43
    private $filters = [];
44
45
    /**
46
     * AuditReader constructor.
47
     *
48
     * @param AuditConfiguration     $configuration
49
     * @param EntityManagerInterface $entityManager
50
     */
51
    public function __construct(
52
        AuditConfiguration $configuration,
53
        EntityManagerInterface $entityManager
54
    ) {
55
        $this->configuration = $configuration;
56
        $this->entityManager = $entityManager;
57
    }
58
59
    /**
60
     * @return AuditConfiguration
61
     */
62
    public function getConfiguration(): AuditConfiguration
63
    {
64
        return $this->configuration;
65
    }
66
67
    /**
68
     * Set the filter(s) for AuditEntry retrieving.
69
     *
70
     * @param array|string $filter
71
     *
72
     * @return AuditReader
73
     */
74
    public function filterBy($filter): self
75
    {
76
        $filters = [];
77
78
        if (!\is_array($filter)) {
79
            $filter = [$filter];
80
        }
81
82
        foreach ($filter as $f) {
83
            if ($this->isAllowedFilter($f)) {
84
                $filters[] = $f;
85
            }
86
        }
87
88
        $this->filters = $filters;
89
90
        return $this;
91
    }
92
93
    /**
94
     * Returns current filter.
95
     *
96
     * @return array
97
     */
98
    public function getFilters(): array
99
    {
100
        return $this->filters;
101
    }
102
103
    /**
104
     * Returns an array of audit table names indexed by entity FQN.
105
     *
106
     * @throws \Doctrine\ORM\ORMException
107
     *
108
     * @return array
109
     */
110
    public function getEntities(): array
111
    {
112
        $metadataDriver = $this->entityManager->getConfiguration()->getMetadataDriverImpl();
113
        $entities = [];
114
        if (null !== $metadataDriver) {
115
            $entities = $metadataDriver->getAllClassNames();
116
        }
117
        $audited = [];
118
        foreach ($entities as $entity) {
119
            if ($this->configuration->isAuditable($entity)) {
120
                $audited[$entity] = $this->getEntityTableName($entity);
121
            }
122
        }
123
        ksort($audited);
124
125
        return $audited;
126
    }
127
128
    /**
129
     * Returns an array of audited entries/operations.
130
     *
131
     * @param object|string   $entity
132
     * @param null|int|string $id
133
     * @param null|int        $page
134
     * @param null|int        $pageSize
135
     * @param null|string     $transactionHash
136
     * @param bool            $strict
137
     *
138
     * @throws AccessDeniedException
139
     * @throws InvalidArgumentException
140
     *
141
     * @return array
142
     */
143
    public function getAudits($entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): array
144
    {
145
        $this->checkAuditable($entity);
146
        $this->checkRoles($entity, Security::VIEW_SCOPE);
147
148
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict);
149
150
        /** @var Statement $statement */
151
        $statement = $queryBuilder->execute();
152
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
153
154
        return $statement->fetchAll();
155
    }
156
157
    /**
158
     * Returns an array of all audited entries/operations for a given transaction hash
159
     * indexed by entity FQCN.
160
     *
161
     * @param string $transactionHash
162
     *
163
     * @throws InvalidArgumentException
164
     * @throws \Doctrine\ORM\ORMException
165
     *
166
     * @return array
167
     */
168
    public function getAuditsByTransactionHash(string $transactionHash): array
169
    {
170
        $results = [];
171
172
        $entities = $this->getEntities();
173
        foreach ($entities as $entity => $tablename) {
174
            try {
175
                $audits = $this->getAudits($entity, null, null, null, $transactionHash);
176
                if (\count($audits) > 0) {
177
                    $results[$entity] = $audits;
178
                }
179
            } catch (AccessDeniedException $e) {
180
                // acces denied
181
            }
182
        }
183
184
        return $results;
185
    }
186
187
    /**
188
     * Returns an array of audited entries/operations.
189
     *
190
     * @param object|string   $entity
191
     * @param null|int|string $id
192
     * @param int             $page
193
     * @param int             $pageSize
194
     *
195
     * @throws AccessDeniedException
196
     * @throws InvalidArgumentException
197
     *
198
     * @return Pagerfanta
199
     */
200
    public function getAuditsPager($entity, $id = null, int $page = 1, int $pageSize = self::PAGE_SIZE): Pagerfanta
201
    {
202
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id);
203
204
        $adapter = new DoctrineDbalSingleTableAdapter($queryBuilder, 'at.id');
205
206
        $pagerfanta = new Pagerfanta($adapter);
207
        $pagerfanta
208
            ->setMaxPerPage($pageSize)
209
            ->setCurrentPage($page)
210
        ;
211
212
        return $pagerfanta;
213
    }
214
215
    /**
216
     * Returns the amount of audited entries/operations.
217
     *
218
     * @param object|string   $entity
219
     * @param null|int|string $id
220
     *
221
     * @throws AccessDeniedException
222
     * @throws InvalidArgumentException
223
     *
224
     * @return int
225
     */
226
    public function getAuditsCount($entity, $id = null): int
227
    {
228
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id);
229
230
        $result = $queryBuilder
231
            ->resetQueryPart('select')
232
            ->resetQueryPart('orderBy')
233
            ->select('COUNT(id)')
234
            ->execute()
235
            ->fetchColumn(0)
236
        ;
237
238
        return false === $result ? 0 : $result;
239
    }
240
241
    /**
242
     * @param object|string $entity
243
     * @param string        $id
244
     *
245
     * @throws AccessDeniedException
246
     * @throws InvalidArgumentException
247
     *
248
     * @return mixed[]
249
     */
250
    public function getAudit($entity, $id)
251
    {
252
        $this->checkAuditable($entity);
253
        $this->checkRoles($entity, Security::VIEW_SCOPE);
254
255
        $connection = $this->entityManager->getConnection();
256
257
        /**
258
         * @var \Doctrine\DBAL\Query\QueryBuilder
259
         */
260
        $queryBuilder = $connection->createQueryBuilder();
261
        $queryBuilder
262
            ->select('*')
263
            ->from($this->getEntityAuditTableName($entity))
264
            ->where('id = :id')
265
            ->setParameter('id', $id)
266
        ;
267
268
        $this->applyTypeFilters($queryBuilder, $this->filters);
269
270
        /** @var Statement $statement */
271
        $statement = $queryBuilder->execute();
272
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
273
274
        return $statement->fetchAll();
275
    }
276
277
    /**
278
     * Returns the table name of $entity.
279
     *
280
     * @param object|string $entity
281
     *
282
     * @return string
283
     */
284
    public function getEntityTableName($entity): string
285
    {
286
        return $this->entityManager->getClassMetadata($entity)->getTableName();
0 ignored issues
show
Bug introduced by
It seems like $entity can also be of type object; however, parameter $className of Doctrine\Common\Persiste...ger::getClassMetadata() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

286
        return $this->entityManager->getClassMetadata(/** @scrutinizer ignore-type */ $entity)->getTableName();
Loading history...
287
    }
288
289
    /**
290
     * Returns the audit table name for $entity.
291
     *
292
     * @param object|string $entity
293
     *
294
     * @return string
295
     */
296
    public function getEntityAuditTableName($entity): string
297
    {
298
        $entityName = \is_string($entity) ? $entity : \get_class($entity);
299
300
        $schema = '';
301
        if ($this->entityManager->getClassMetadata($entityName)->getSchemaName()) {
302
            $schema = $this->entityManager->getClassMetadata($entityName)->getSchemaName().'.';
303
        }
304
305
        return sprintf('%s%s%s%s', $schema, $this->configuration->getTablePrefix(), $this->getEntityTableName($entityName), $this->configuration->getTableSuffix());
306
    }
307
308
    /**
309
     * @return EntityManagerInterface
310
     */
311
    public function getEntityManager(): EntityManagerInterface
312
    {
313
        return $this->entityManager;
314
    }
315
316
    private function applyTypeFilters(QueryBuilder $queryBuilder, array $filters): QueryBuilder
317
    {
318
        if (!empty($filters)) {
319
            $queryBuilder
320
                ->andWhere('type IN (:filters)')
321
                ->setParameter('filters', $filters, Connection::PARAM_STR_ARRAY)
322
            ;
323
        }
324
325
        return $queryBuilder;
326
    }
327
328
    private function applyTransactionFilter(QueryBuilder $queryBuilder, ?string $transactionHash): QueryBuilder
329
    {
330
        if (null !== $transactionHash) {
331
            $queryBuilder
332
                ->andWhere('transaction_hash = :transaction_hash')
333
                ->setParameter('transaction_hash', $transactionHash)
334
            ;
335
        }
336
337
        return $queryBuilder;
338
    }
339
340
    /**
341
     * @param QueryBuilder    $queryBuilder
342
     * @param null|int|string $id
343
     *
344
     * @return QueryBuilder
345
     */
346
    private function applyObjectIdFilter(QueryBuilder $queryBuilder, $id): QueryBuilder
347
    {
348
        if (null !== $id) {
349
            $queryBuilder
350
                ->andWhere('object_id = :object_id')
351
                ->setParameter('object_id', $id)
352
            ;
353
        }
354
355
        return $queryBuilder;
356
    }
357
358
    private function applyPaging(QueryBuilder $queryBuilder, ?int $page, ?int $pageSize): QueryBuilder
359
    {
360
        if (null !== $pageSize) {
361
            $queryBuilder
362
                ->setFirstResult(($page - 1) * $pageSize)
363
                ->setMaxResults($pageSize)
364
            ;
365
        }
366
367
        return $queryBuilder;
368
    }
369
370
    private function isAllowedFilter(string $filter): bool
371
    {
372
        return \in_array($filter, [self::UPDATE, self::ASSOCIATE, self::DISSOCIATE, self::INSERT, self::REMOVE], true);
373
    }
374
375
    /**
376
     * Returns an array of audited entries/operations.
377
     *
378
     * @param object|string   $entity
379
     * @param null|int|string $id
380
     * @param null|int        $page
381
     * @param null|int        $pageSize
382
     * @param null|string     $transactionHash
383
     * @param bool            $strict
384
     *
385
     * @throws AccessDeniedException
386
     * @throws InvalidArgumentException
387
     *
388
     * @return QueryBuilder
389
     */
390
    private function getAuditsQueryBuilder($entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): QueryBuilder
391
    {
392
        $this->checkAuditable($entity);
393
        $this->checkRoles($entity, Security::VIEW_SCOPE);
394
395
        if (null !== $page && $page < 1) {
396
            throw new \InvalidArgumentException('$page must be greater or equal than 1.');
397
        }
398
399
        if (null !== $pageSize && $pageSize < 1) {
400
            throw new \InvalidArgumentException('$pageSize must be greater or equal than 1.');
401
        }
402
403
        $storage = $this->configuration->getEntityManager() ?? $this->entityManager;
404
        $connection = $storage->getConnection();
405
406
        $queryBuilder = $connection->createQueryBuilder();
407
        $queryBuilder
408
            ->select('*')
409
            ->from($this->getEntityAuditTableName($entity), 'at')
410
            ->orderBy('created_at', 'DESC')
411
            ->addOrderBy('id', 'DESC')
412
        ;
413
414
        $metadata = $this->entityManager->getClassMetadata($entity);
0 ignored issues
show
Bug introduced by
It seems like $entity can also be of type object; however, parameter $className of Doctrine\Common\Persiste...ger::getClassMetadata() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

414
        $metadata = $this->entityManager->getClassMetadata(/** @scrutinizer ignore-type */ $entity);
Loading history...
415
        if ($strict && $metadata instanceof ORMMetadata && ORMMetadata::INHERITANCE_TYPE_SINGLE_TABLE === $metadata->inheritanceType) {
416
            $queryBuilder
417
                ->andWhere('discriminator = :discriminator')
418
                ->setParameter('discriminator', \is_object($entity) ? \get_class($entity) : $entity)
419
            ;
420
        }
421
422
        $this->applyObjectIdFilter($queryBuilder, $id);
423
        $this->applyTypeFilters($queryBuilder, $this->filters);
424
        $this->applyTransactionFilter($queryBuilder, $transactionHash);
425
        $this->applyPaging($queryBuilder, $page, $pageSize);
426
427
        return $queryBuilder;
428
    }
429
430
    /**
431
     * Throws an InvalidArgumentException if given entity is not auditable.
432
     *
433
     * @param object|string $entity
434
     *
435
     * @throws InvalidArgumentException
436
     */
437
    private function checkAuditable($entity): void
438
    {
439
        if (!$this->configuration->isAuditable($entity)) {
440
            throw new InvalidArgumentException('Entity '.$entity.' is not auditable.');
0 ignored issues
show
Bug introduced by
Are you sure $entity of type object|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

440
            throw new InvalidArgumentException('Entity './** @scrutinizer ignore-type */ $entity.' is not auditable.');
Loading history...
441
        }
442
    }
443
444
    /**
445
     * Throws an AccessDeniedException if user not is granted to access audits for the given entity.
446
     *
447
     * @param object|string $entity
448
     * @param string        $scope
449
     *
450
     * @throws AccessDeniedException
451
     */
452
    private function checkRoles($entity, string $scope): void
453
    {
454
        $userProvider = $this->configuration->getUserProvider();
455
        $user = null === $userProvider ? null : $userProvider->getUser();
456
        $security = null === $userProvider ? null : $userProvider->getSecurity();
457
458
        if (!($user instanceof UserInterface) || !($security instanceof CoreSecurity)) {
459
            // If no security defined or no user identified, consider access granted
460
            return;
461
        }
462
463
        $entities = $this->configuration->getEntities();
464
465
        if (!isset($entities[$entity]['roles']) || null === $entities[$entity]['roles']) {
466
            // If no roles are configured, consider access granted
467
            return;
468
        }
469
470
        if (!isset($entities[$entity]['roles'][$scope]) || null === $entities[$entity]['roles'][$scope]) {
471
            // If no roles for the given scope are configured, consider access granted
472
            return;
473
        }
474
475
        // roles are defined for the give scope
476
        foreach ($entities[$entity]['roles'][$scope] as $role) {
477
            if ($security->isGranted($role)) {
478
                // role granted => access granted
479
                return;
480
            }
481
        }
482
483
        // access denied
484
        throw new AccessDeniedException('You are not allowed to access audits of '.$entity.' entity.');
0 ignored issues
show
Bug introduced by
Are you sure $entity of type object|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

484
        throw new AccessDeniedException('You are not allowed to access audits of './** @scrutinizer ignore-type */ $entity.' entity.');
Loading history...
485
    }
486
}
487