Passed
Pull Request — master (#127)
by Damien
02:29
created

AuditReader::isAllowedFilter()   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 1
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\Common\Persistence\Mapping\ClassMetadata;
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 Pagerfanta\Adapter\DoctrineDbalSingleTableAdapter;
17
use Pagerfanta\Pagerfanta;
18
use PDO;
19
use Symfony\Component\Security\Core\Security as CoreSecurity;
20
21
class AuditReader
22
{
23
    public const UPDATE = 'update';
24
    public const ASSOCIATE = 'associate';
25
    public const DISSOCIATE = 'dissociate';
26
    public const INSERT = 'insert';
27
    public const REMOVE = 'remove';
28
29
    public const PAGE_SIZE = 50;
30
31
    /**
32
     * @var AuditConfiguration
33
     */
34
    private $configuration;
35
36
    /**
37
     * @var EntityManagerInterface
38
     */
39
    private $entityManager;
40
41
    /**
42
     * @var array
43
     */
44
    private $filters = [];
45
46
    /**
47
     * AuditReader constructor.
48
     *
49
     * @param AuditConfiguration     $configuration
50
     * @param EntityManagerInterface $entityManager
51
     */
52
    public function __construct(
53
        AuditConfiguration $configuration,
54
        EntityManagerInterface $entityManager
55
    ) {
56
        $this->configuration = $configuration;
57
        $this->entityManager = $entityManager;
58
    }
59
60
    /**
61
     * @return AuditConfiguration
62
     */
63
    public function getConfiguration(): AuditConfiguration
64
    {
65
        return $this->configuration;
66
    }
67
68
    /**
69
     * Set the filter(s) for AuditEntry retrieving.
70
     *
71
     * @param array|string $filter
72
     *
73
     * @return AuditReader
74
     */
75
    public function filterBy($filter): self
76
    {
77
        $filters = [];
78
79
        if (!\is_array($filter)) {
80
            $filter = [$filter];
81
        }
82
83
        foreach ($filter as $f) {
84
            if ($this->isAllowedFilter($f)) {
85
                $filters[] = $f;
86
            }
87
        }
88
89
        $this->filters = $filters;
90
91
        return $this;
92
    }
93
94
    /**
95
     * Returns current filter.
96
     *
97
     * @return array
98
     */
99
    public function getFilters(): array
100
    {
101
        return $this->filters;
102
    }
103
104
    /**
105
     * Returns an array of audit table names indexed by entity FQN.
106
     *
107
     * @throws \Doctrine\ORM\ORMException
108
     *
109
     * @return array
110
     */
111
    public function getEntities(): array
112
    {
113
        $metadataDriver = $this->entityManager->getConfiguration()->getMetadataDriverImpl();
114
        $entities = [];
115
        if (null !== $metadataDriver) {
116
            $entities = $metadataDriver->getAllClassNames();
117
        }
118
        $audited = [];
119
        foreach ($entities as $entity) {
120
            if ($this->configuration->isAuditable($entity)) {
121
                $audited[$entity] = $this->getEntityTableName($entity);
122
            }
123
        }
124
        ksort($audited);
125
126
        return $audited;
127
    }
128
129
    /**
130
     * Returns an array of audited entries/operations.
131
     *
132
     * @param object|string   $entity
133
     * @param null|int|string $id
134
     * @param null|int        $page
135
     * @param null|int        $pageSize
136
     * @param null|string     $transactionHash
137
     * @param bool            $strict
138
     *
139
     * @throws AccessDeniedException
140
     * @throws InvalidArgumentException
141
     *
142
     * @return array
143
     */
144
    public function getAudits($entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): array
145
    {
146
        $this->checkAuditable($entity);
147
        $this->checkRoles($entity, Security::VIEW_SCOPE);
148
149
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id, $page, $pageSize, $transactionHash, $strict);
150
151
        /** @var Statement $statement */
152
        $statement = $queryBuilder->execute();
153
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
154
155
        return $statement->fetchAll();
156
    }
157
158
    /**
159
     * Returns an array of all audited entries/operations for a given transaction hash
160
     * indexed by entity FQCN.
161
     *
162
     * @param string $transactionHash
163
     *
164
     * @throws InvalidArgumentException
165
     * @throws \Doctrine\ORM\ORMException
166
     *
167
     * @return array
168
     */
169
    public function getAuditsByTransactionHash(string $transactionHash): array
170
    {
171
        $results = [];
172
173
        $entities = $this->getEntities();
174
        foreach ($entities as $entity => $tablename) {
175
            try {
176
                $audits = $this->getAudits($entity, null, null, null, $transactionHash);
177
                if (\count($audits) > 0) {
178
                    $results[$entity] = $audits;
179
                }
180
            } catch (AccessDeniedException $e) {
181
                // acces denied
182
            }
183
        }
184
185
        return $results;
186
    }
187
188
    /**
189
     * Returns an array of audited entries/operations.
190
     *
191
     * @param object|string   $entity
192
     * @param null|int|string $id
193
     * @param int             $page
194
     * @param int             $pageSize
195
     *
196
     * @throws AccessDeniedException
197
     * @throws InvalidArgumentException
198
     *
199
     * @return Pagerfanta
200
     */
201
    public function getAuditsPager($entity, $id = null, int $page = 1, int $pageSize = self::PAGE_SIZE): Pagerfanta
202
    {
203
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id);
204
205
        $adapter = new DoctrineDbalSingleTableAdapter($queryBuilder, 'at.id');
206
207
        $pagerfanta = new Pagerfanta($adapter);
208
        $pagerfanta
209
            ->setMaxPerPage($pageSize)
210
            ->setCurrentPage($page)
211
        ;
212
213
        return $pagerfanta;
214
    }
215
216
    /**
217
     * Returns the amount of audited entries/operations.
218
     *
219
     * @param object|string   $entity
220
     * @param null|int|string $id
221
     *
222
     * @throws AccessDeniedException
223
     * @throws InvalidArgumentException
224
     *
225
     * @return int
226
     */
227
    public function getAuditsCount($entity, $id = null): int
228
    {
229
        $queryBuilder = $this->getAuditsQueryBuilder($entity, $id);
230
231
        $result = $queryBuilder
232
            ->resetQueryPart('select')
233
            ->resetQueryPart('orderBy')
234
            ->select('COUNT(id)')
235
            ->execute()
236
            ->fetchColumn(0)
237
        ;
238
239
        return false === $result ? 0 : $result;
240
    }
241
242
    /**
243
     * @param object|string $entity
244
     * @param string        $id
245
     *
246
     * @throws AccessDeniedException
247
     * @throws InvalidArgumentException
248
     *
249
     * @return mixed[]
250
     */
251
    public function getAudit($entity, $id)
252
    {
253
        $this->checkAuditable($entity);
254
        $this->checkRoles($entity, Security::VIEW_SCOPE);
255
256
        $connection = $this->entityManager->getConnection();
257
258
        /**
259
         * @var \Doctrine\DBAL\Query\QueryBuilder
260
         */
261
        $queryBuilder = $connection->createQueryBuilder();
262
        $queryBuilder
263
            ->select('*')
264
            ->from($this->getEntityAuditTableName($entity))
265
            ->where('id = :id')
266
            ->setParameter('id', $id)
267
        ;
268
269
        $this->applyTypeFilters($queryBuilder, $this->filters);
270
271
        /** @var Statement $statement */
272
        $statement = $queryBuilder->execute();
273
        $statement->setFetchMode(PDO::FETCH_CLASS, AuditEntry::class);
274
275
        return $statement->fetchAll();
276
    }
277
278
    /**
279
     * Returns the table name of $entity.
280
     *
281
     * @param object|string $entity
282
     *
283
     * @return string
284
     */
285
    public function getEntityTableName($entity): string
286
    {
287
        return $this->getClassMetadata($entity)->getTableName();
288
    }
289
290
    /**
291
     * Returns the audit table name for $entity.
292
     *
293
     * @param object|string $entity
294
     *
295
     * @return string
296
     */
297
    public function getEntityAuditTableName($entity): string
298
    {
299
        $entityName = \is_string($entity) ? $entity : \get_class($entity);
300
        $schema = $this->getClassMetadata($entityName)->getSchemaName() ? $this->getClassMetadata($entityName)->getSchemaName().'.' : '';
301
302
        return sprintf('%s%s%s%s', $schema, $this->configuration->getTablePrefix(), $this->getEntityTableName($entityName), $this->configuration->getTableSuffix());
303
    }
304
305
    /**
306
     * @return EntityManagerInterface
307
     */
308
    public function getEntityManager(): EntityManagerInterface
309
    {
310
        return $this->entityManager;
311
    }
312
313
    private function applyTypeFilters(QueryBuilder $queryBuilder, array $filters): QueryBuilder
314
    {
315
        if (!empty($filters)) {
316
            $queryBuilder
317
                ->andWhere('type IN (:filters)')
318
                ->setParameter('filters', $filters, Connection::PARAM_STR_ARRAY)
319
            ;
320
        }
321
322
        return $queryBuilder;
323
    }
324
325
    private function applyTransactionFilter(QueryBuilder $queryBuilder, ?string $transactionHash): QueryBuilder
326
    {
327
        if (null !== $transactionHash) {
328
            $queryBuilder
329
                ->andWhere('transaction_hash = :transaction_hash')
330
                ->setParameter('transaction_hash', $transactionHash)
331
            ;
332
        }
333
334
        return $queryBuilder;
335
    }
336
337
    /**
338
     * @param QueryBuilder    $queryBuilder
339
     * @param null|int|string $id
340
     *
341
     * @return QueryBuilder
342
     */
343
    private function applyObjectIdFilter(QueryBuilder $queryBuilder, $id): QueryBuilder
344
    {
345
        if (null !== $id) {
346
            $queryBuilder
347
                ->andWhere('object_id = :object_id')
348
                ->setParameter('object_id', $id)
349
            ;
350
        }
351
352
        return $queryBuilder;
353
    }
354
355
    private function applyPaging(QueryBuilder $queryBuilder, ?int $page, ?int $pageSize): QueryBuilder
356
    {
357
        if (null !== $pageSize) {
358
            $queryBuilder
359
                ->setFirstResult(($page - 1) * $pageSize)
360
                ->setMaxResults($pageSize)
361
            ;
362
        }
363
364
        return $queryBuilder;
365
    }
366
367
    private function isAllowedFilter(string $filter): bool
368
    {
369
        return \in_array($filter, [self::UPDATE, self::ASSOCIATE, self::DISSOCIATE, self::INSERT, self::REMOVE], true);
370
    }
371
372
    /**
373
     * Returns an array of audited entries/operations.
374
     *
375
     * @param object|string   $entity
376
     * @param null|int|string $id
377
     * @param null|int        $page
378
     * @param null|int        $pageSize
379
     * @param null|string     $transactionHash
380
     * @param bool            $strict
381
     *
382
     * @throws AccessDeniedException
383
     * @throws InvalidArgumentException
384
     *
385
     * @return QueryBuilder
386
     */
387
    private function getAuditsQueryBuilder($entity, $id = null, ?int $page = null, ?int $pageSize = null, ?string $transactionHash = null, bool $strict = true): QueryBuilder
388
    {
389
        $this->checkAuditable($entity);
390
        $this->checkRoles($entity, Security::VIEW_SCOPE);
391
392
        if (null !== $page && $page < 1) {
393
            throw new \InvalidArgumentException('$page must be greater or equal than 1.');
394
        }
395
396
        if (null !== $pageSize && $pageSize < 1) {
397
            throw new \InvalidArgumentException('$pageSize must be greater or equal than 1.');
398
        }
399
400
        $storage = $this->selectStorage();
401
        $connection = $storage->getConnection();
402
403
        $queryBuilder = $connection->createQueryBuilder();
404
        $queryBuilder
405
            ->select('*')
406
            ->from($this->getEntityAuditTableName($entity), 'at')
407
            ->orderBy('created_at', 'DESC')
408
            ->addOrderBy('id', 'DESC')
409
        ;
410
411
        $metadata = $this->getClassMetadata($entity);
412
        if ($strict && $metadata instanceof ORMMetadata && ORMMetadata::INHERITANCE_TYPE_SINGLE_TABLE === $metadata->inheritanceType) {
413
            $queryBuilder
414
                ->andWhere('discriminator = :discriminator')
415
                ->setParameter('discriminator', \is_object($entity) ? \get_class($entity) : $entity)
416
            ;
417
        }
418
419
        $this->applyObjectIdFilter($queryBuilder, $id);
420
        $this->applyTypeFilters($queryBuilder, $this->filters);
421
        $this->applyTransactionFilter($queryBuilder, $transactionHash);
422
        $this->applyPaging($queryBuilder, $page, $pageSize);
423
424
        return $queryBuilder;
425
    }
426
427
    /**
428
     * @param object|string $entity
429
     *
430
     * @return ClassMetadata
431
     */
432
    private function getClassMetadata($entity): ClassMetadata
433
    {
434
        return $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

434
        return $this->entityManager->getClassMetadata(/** @scrutinizer ignore-type */ $entity);
Loading history...
435
    }
436
437
    /**
438
     * @return EntityManagerInterface
439
     */
440
    private function selectStorage(): EntityManagerInterface
441
    {
442
        return $this->configuration->getEntityManager() ?? $this->entityManager;
443
    }
444
445
    /**
446
     * Throws an InvalidArgumentException if given entity is not auditable.
447
     *
448
     * @param object|string $entity
449
     *
450
     * @throws InvalidArgumentException
451
     */
452
    private function checkAuditable($entity): void
453
    {
454
        if (!$this->configuration->isAuditable($entity)) {
455
            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

455
            throw new InvalidArgumentException('Entity './** @scrutinizer ignore-type */ $entity.' is not auditable.');
Loading history...
456
        }
457
    }
458
459
    /**
460
     * Throws an AccessDeniedException if user not is granted to access audits for the given entity.
461
     *
462
     * @param object|string $entity
463
     * @param string        $scope
464
     *
465
     * @throws AccessDeniedException
466
     */
467
    private function checkRoles($entity, string $scope): void
468
    {
469
        $userProvider = $this->configuration->getUserProvider();
470
        $user = null === $userProvider ? null : $userProvider->getUser();
471
        $security = null === $userProvider ? null : $userProvider->getSecurity();
472
473
        if (!($user instanceof UserInterface) || !($security instanceof CoreSecurity)) {
474
            // If no security defined or no user identified, consider access granted
475
            return;
476
        }
477
478
        $entities = $this->configuration->getEntities();
479
480
        if (!isset($entities[$entity]['roles']) || null === $entities[$entity]['roles']) {
481
            // If no roles are configured, consider access granted
482
            return;
483
        }
484
485
        if (!isset($entities[$entity]['roles'][$scope]) || null === $entities[$entity]['roles'][$scope]) {
486
            // If no roles for the given scope are configured, consider access granted
487
            return;
488
        }
489
490
        // roles are defined for the give scope
491
        foreach ($entities[$entity]['roles'][$scope] as $role) {
492
            if ($security->isGranted($role)) {
493
                // role granted => access granted
494
                return;
495
            }
496
        }
497
498
        // access denied
499
        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

499
        throw new AccessDeniedException('You are not allowed to access audits of './** @scrutinizer ignore-type */ $entity.' entity.');
Loading history...
500
    }
501
}
502