Issues (35)

src/Auditable.php (16 issues)

1
<?php
2
3
namespace OwenIt\Auditing;
4
5
use Illuminate\Database\Eloquent\Relations\MorphMany;
6
use Illuminate\Database\Eloquent\SoftDeletes;
7
use Illuminate\Support\Arr;
8
use Illuminate\Support\Facades\App;
9
use Illuminate\Support\Facades\Config;
10
use OwenIt\Auditing\Contracts\AttributeEncoder;
11
use OwenIt\Auditing\Contracts\AttributeRedactor;
12
use OwenIt\Auditing\Contracts\IpAddressResolver;
13
use OwenIt\Auditing\Contracts\UrlResolver;
14
use OwenIt\Auditing\Contracts\UserAgentResolver;
15
use OwenIt\Auditing\Contracts\UserResolver;
16
use OwenIt\Auditing\Exceptions\AuditableTransitionException;
17
use OwenIt\Auditing\Exceptions\AuditingException;
18
19
trait Auditable
20
{
21
    /**
22
     * Auditable attributes excluded from the Audit.
23
     *
24
     * @var array
25
     */
26
    protected $excludedAttributes = [];
27
28
    /**
29
     * Audit event name.
30
     *
31
     * @var string
32
     */
33
    protected $auditEvent;
34
35
    /**
36
     * Is auditing disabled?
37
     *
38
     * @var bool
39
     */
40
    public static $auditingDisabled = false;
41
42
    /**
43
     * Auditable boot logic.
44
     *
45
     * @return void
46
     */
47 92
    public static function bootAuditable()
48
    {
49 92
        if (!self::$auditingDisabled && static::isAuditingEnabled()) {
50 90
            static::observe(new AuditableObserver());
51
        }
52 92
    }
53
54
    /**
55
     * {@inheritdoc}
56
     */
57 13
    public function audits(): MorphMany
58
    {
59 13
        return $this->morphMany(
0 ignored issues
show
It seems like morphMany() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

59
        return $this->/** @scrutinizer ignore-call */ morphMany(
Loading history...
60 13
            Config::get('audit.implementation', Models\Audit::class),
61 13
            'auditable'
62
        );
63
    }
64
65
    /**
66
     * Resolve the Auditable attributes to exclude from the Audit.
67
     *
68
     * @return void
69
     */
70 56
    protected function resolveAuditExclusions()
71
    {
72 56
        $this->excludedAttributes = $this->getAuditExclude();
73
74
        // When in strict mode, hidden and non visible attributes are excluded
75 56
        if ($this->getAuditStrict()) {
76
            // Hidden attributes
77 1
            $this->excludedAttributes = array_merge($this->excludedAttributes, $this->hidden);
78
79
            // Non visible attributes
80 1
            if ($this->visible) {
81 1
                $invisible = array_diff(array_keys($this->attributes), $this->visible);
82
83 1
                $this->excludedAttributes = array_merge($this->excludedAttributes, $invisible);
84
            }
85
        }
86
87
        // Exclude Timestamps
88 56
        if (!$this->getAuditTimestamps()) {
89 56
            array_push($this->excludedAttributes, $this->getCreatedAtColumn(), $this->getUpdatedAtColumn());
0 ignored issues
show
It seems like getCreatedAtColumn() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

89
            array_push($this->excludedAttributes, $this->/** @scrutinizer ignore-call */ getCreatedAtColumn(), $this->getUpdatedAtColumn());
Loading history...
It seems like getUpdatedAtColumn() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

89
            array_push($this->excludedAttributes, $this->getCreatedAtColumn(), $this->/** @scrutinizer ignore-call */ getUpdatedAtColumn());
Loading history...
90
91 56
            if (in_array(SoftDeletes::class, class_uses_recursive(get_class($this)))) {
92 52
                $this->excludedAttributes[] = $this->getDeletedAtColumn();
0 ignored issues
show
It seems like getDeletedAtColumn() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

92
                /** @scrutinizer ignore-call */ 
93
                $this->excludedAttributes[] = $this->getDeletedAtColumn();
Loading history...
93
            }
94
        }
95
96
        // Valid attributes are all those that made it out of the exclusion array
97 56
        $attributes = Arr::except($this->attributes, $this->excludedAttributes);
98
99 56
        foreach ($attributes as $attribute => $value) {
100
            // Apart from null, non scalar values will be excluded
101 52
            if (is_array($value) || (is_object($value) && !method_exists($value, '__toString'))) {
102 52
                $this->excludedAttributes[] = $attribute;
103
            }
104
        }
105 56
    }
106
107
    /**
108
     * Get the old/new attributes of a retrieved event.
109
     *
110
     * @return array
111
     */
112 2
    protected function getRetrievedEventAttributes(): array
113
    {
114
        // This is a read event with no attribute changes,
115
        // only metadata will be stored in the Audit
116
117
        return [
118 2
            [],
119
            [],
120
        ];
121
    }
122
123
    /**
124
     * Get the old/new attributes of a created event.
125
     *
126
     * @return array
127
     */
128 49
    protected function getCreatedEventAttributes(): array
129
    {
130 49
        $new = [];
131
132 49
        foreach ($this->attributes as $attribute => $value) {
133 45
            if ($this->isAttributeAuditable($attribute)) {
134 45
                $new[$attribute] = $value;
135
            }
136
        }
137
138
        return [
139 49
            [],
140 49
            $new,
141
        ];
142
    }
143
144
    /**
145
     * Get the old/new attributes of an updated event.
146
     *
147
     * @return array
148
     */
149 6
    protected function getUpdatedEventAttributes(): array
150
    {
151 6
        $old = [];
152 6
        $new = [];
153
154 6
        foreach ($this->getDirty() as $attribute => $value) {
0 ignored issues
show
It seems like getDirty() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

154
        foreach ($this->/** @scrutinizer ignore-call */ getDirty() as $attribute => $value) {
Loading history...
155 5
            if ($this->isAttributeAuditable($attribute)) {
156 5
                $old[$attribute] = Arr::get($this->original, $attribute);
157 5
                $new[$attribute] = Arr::get($this->attributes, $attribute);
158
            }
159
        }
160
161
        return [
162 6
            $old,
163 6
            $new,
164
        ];
165
    }
166
167
    /**
168
     * Get the old/new attributes of a deleted event.
169
     *
170
     * @return array
171
     */
172 4
    protected function getDeletedEventAttributes(): array
173
    {
174 4
        $old = [];
175
176 4
        foreach ($this->attributes as $attribute => $value) {
177 4
            if ($this->isAttributeAuditable($attribute)) {
178 4
                $old[$attribute] = $value;
179
            }
180
        }
181
182
        return [
183 4
            $old,
184
            [],
185
        ];
186
    }
187
188
    /**
189
     * Get the old/new attributes of a restored event.
190
     *
191
     * @return array
192
     */
193 2
    protected function getRestoredEventAttributes(): array
194
    {
195
        // A restored event is just a deleted event in reverse
196 2
        return array_reverse($this->getDeletedEventAttributes());
197
    }
198
199
    /**
200
     * {@inheritdoc}
201
     */
202 67
    public function readyForAuditing(): bool
203
    {
204 67
        if (static::$auditingDisabled) {
205 1
            return false;
206
        }
207
208 67
        return $this->isEventAuditable($this->auditEvent);
209
    }
210
211
    /**
212
     * Modify attribute value.
213
     *
214
     * @param string $attribute
215
     * @param mixed  $value
216
     *
217
     * @throws AuditingException
218
     *
219
     * @return mixed
220
     */
221 2
    protected function modifyAttributeValue(string $attribute, $value)
222
    {
223 2
        $attributeModifiers = $this->getAttributeModifiers();
224
225 2
        if (!array_key_exists($attribute, $attributeModifiers)) {
226 1
            return $value;
227
        }
228
229 2
        $attributeModifier = $attributeModifiers[$attribute];
230
231 2
        if (is_subclass_of($attributeModifier, AttributeRedactor::class)) {
232 1
            return call_user_func([$attributeModifier, 'redact'], $value);
233
        }
234
235 2
        if (is_subclass_of($attributeModifier, AttributeEncoder::class)) {
236 1
            return call_user_func([$attributeModifier, 'encode'], $value);
237
        }
238
239 1
        throw new AuditingException(sprintf('Invalid AttributeModifier implementation: %s', $attributeModifier));
240
    }
241
242
    /**
243
     * {@inheritdoc}
244
     */
245 61
    public function toAudit(): array
246
    {
247 61
        if (!$this->readyForAuditing()) {
248 1
            throw new AuditingException('A valid audit event has not been set');
249
        }
250
251 60
        $attributeGetter = $this->resolveAttributeGetter($this->auditEvent);
252
253 60
        if (!method_exists($this, $attributeGetter)) {
254 4
            throw new AuditingException(sprintf(
255 4
                'Unable to handle "%s" event, %s() method missing',
256 4
                $this->auditEvent,
257 4
                $attributeGetter
258
            ));
259
        }
260
261 56
        $this->resolveAuditExclusions();
262
263 56
        list($old, $new) = $this->$attributeGetter();
264
265 56
        if ($this->getAttributeModifiers()) {
266 2
            foreach ($old as $attribute => $value) {
267 1
                $old[$attribute] = $this->modifyAttributeValue($attribute, $value);
268
            }
269
270 2
            foreach ($new as $attribute => $value) {
271 2
                $new[$attribute] = $this->modifyAttributeValue($attribute, $value);
272
            }
273
        }
274
275 55
        $morphPrefix = Config::get('audit.user.morph_prefix', 'user');
276
277 55
        $tags = implode(',', $this->generateTags());
278
279 55
        $user = $this->resolveUser();
280
281 54
        return $this->transformAudit([
282 54
            'old_values'         => $old,
283 54
            'new_values'         => $new,
284 54
            'event'              => $this->auditEvent,
285 54
            'auditable_id'       => $this->getKey(),
0 ignored issues
show
It seems like getKey() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

285
            'auditable_id'       => $this->/** @scrutinizer ignore-call */ getKey(),
Loading history...
286 54
            'auditable_type'     => $this->getMorphClass(),
0 ignored issues
show
It seems like getMorphClass() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

286
            'auditable_type'     => $this->/** @scrutinizer ignore-call */ getMorphClass(),
Loading history...
287 54
            $morphPrefix . '_id'   => $user ? $user->getAuthIdentifier() : null,
288 54
            $morphPrefix . '_type' => $user ? $user->getMorphClass() : null,
289 54
            'url'                => $this->resolveUrl(),
290 53
            'ip_address'         => $this->resolveIpAddress(),
291 52
            'user_agent'         => $this->resolveUserAgent(),
292 51
            'tags'               => empty($tags) ? null : $tags,
293
        ]);
294
    }
295
296
    /**
297
     * {@inheritdoc}
298
     */
299 50
    public function transformAudit(array $data): array
300
    {
301 50
        return $data;
302
    }
303
304
    /**
305
     * Resolve the User.
306
     *
307
     * @throws AuditingException
308
     *
309
     * @return mixed|null
310
     */
311 55
    protected function resolveUser()
312
    {
313 55
        $userResolver = Config::get('audit.resolver.user');
314
315 55
        if (is_subclass_of($userResolver, UserResolver::class)) {
316 54
            return call_user_func([$userResolver, 'resolve']);
317
        }
318
319 1
        throw new AuditingException('Invalid UserResolver implementation');
320
    }
321
322
    /**
323
     * Resolve the URL.
324
     *
325
     * @throws AuditingException
326
     *
327
     * @return string
328
     */
329 54
    protected function resolveUrl(): string
330
    {
331 54
        $urlResolver = Config::get('audit.resolver.url');
332
333 54
        if (is_subclass_of($urlResolver, UrlResolver::class)) {
334 53
            return call_user_func([$urlResolver, 'resolve']);
335
        }
336
337 1
        throw new AuditingException('Invalid UrlResolver implementation');
338
    }
339
340
    /**
341
     * Resolve the IP Address.
342
     *
343
     * @throws AuditingException
344
     *
345
     * @return string
346
     */
347 53
    protected function resolveIpAddress(): string
348
    {
349 53
        $ipAddressResolver = Config::get('audit.resolver.ip_address');
350
351 53
        if (is_subclass_of($ipAddressResolver, IpAddressResolver::class)) {
352 52
            return call_user_func([$ipAddressResolver, 'resolve']);
353
        }
354
355 1
        throw new AuditingException('Invalid IpAddressResolver implementation');
356
    }
357
358
    /**
359
     * Resolve the User Agent.
360
     *
361
     * @throws AuditingException
362
     *
363
     * @return string|null
364
     */
365 52
    protected function resolveUserAgent()
366
    {
367 52
        $userAgentResolver = Config::get('audit.resolver.user_agent');
368
369 52
        if (is_subclass_of($userAgentResolver, UserAgentResolver::class)) {
370 51
            return call_user_func([$userAgentResolver, 'resolve']);
371
        }
372
373 1
        throw new AuditingException('Invalid UserAgentResolver implementation');
374
    }
375
376
    /**
377
     * Determine if an attribute is eligible for auditing.
378
     *
379
     * @param string $attribute
380
     *
381
     * @return bool
382
     */
383 51
    protected function isAttributeAuditable(string $attribute): bool
384
    {
385
        // The attribute should not be audited
386 51
        if (in_array($attribute, $this->excludedAttributes, true)) {
387 48
            return false;
388
        }
389
390
        // The attribute is auditable when explicitly
391
        // listed or when the include array is empty
392 51
        $include = $this->getAuditInclude();
393
394 51
        return empty($include) || in_array($attribute, $include, true);
395
    }
396
397
    /**
398
     * Determine whether an event is auditable.
399
     *
400
     * @param string $event
401
     *
402
     * @return bool
403
     */
404 68
    protected function isEventAuditable($event): bool
405
    {
406 68
        return is_string($this->resolveAttributeGetter($event));
407
    }
408
409
    /**
410
     * Attribute getter method resolver.
411
     *
412
     * @param string $event
413
     *
414
     * @return string|null
415
     */
416 68
    protected function resolveAttributeGetter($event)
417
    {
418 68
        foreach ($this->getAuditEvents() as $key => $value) {
419 68
            $auditableEvent = is_int($key) ? $value : $key;
420
421 68
            $auditableEventRegex = sprintf('/%s/', preg_replace('/\*+/', '.*', $auditableEvent));
422
423 68
            if (preg_match($auditableEventRegex, $event)) {
424 68
                return is_int($key) ? sprintf('get%sEventAttributes', ucfirst($event)) : $value;
425
            }
426
        }
427 26
    }
428
429
    /**
430
     * {@inheritdoc}
431
     */
432 68
    public function setAuditEvent(string $event): Contracts\Auditable
433
    {
434 68
        $this->auditEvent = $this->isEventAuditable($event) ? $event : null;
435
436 68
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type OwenIt\Auditing\Auditable which is incompatible with the type-hinted return OwenIt\Auditing\Contracts\Auditable.
Loading history...
437
    }
438
439
    /**
440
     * {@inheritdoc}
441
     */
442 2
    public function getAuditEvent()
443
    {
444 2
        return $this->auditEvent;
445
    }
446
447
    /**
448
     * {@inheritdoc}
449
     */
450 71
    public function getAuditEvents(): array
451
    {
452 71
        return $this->auditEvents ?? Config::get('audit.events', [
0 ignored issues
show
The property auditEvents does not exist on OwenIt\Auditing\Auditable. Did you mean auditEvent?
Loading history...
453 71
            'created',
454
            'updated',
455
            'deleted',
456
            'restored',
457
        ]);
458
    }
459
460
    /**
461
     * Disable Auditing.
462
     *
463
     * @return void
464
     */
465 1
    public static function disableAuditing()
466
    {
467 1
        static::$auditingDisabled = true;
468 1
    }
469
470
    /**
471
     * Enable Auditing.
472
     *
473
     * @return void
474
     */
475 1
    public static function enableAuditing()
476
    {
477 1
        static::$auditingDisabled = false;
478 1
    }
479
480
    /**
481
     * Determine whether auditing is enabled.
482
     *
483
     * @return bool
484
     */
485 94
    public static function isAuditingEnabled(): bool
486
    {
487 94
        if (App::runningInConsole()) {
488 92
            return Config::get('audit.console', false);
489
        }
490
491 2
        return Config::get('audit.enabled', true);
492
    }
493
494
    /**
495
     * {@inheritdoc}
496
     */
497 53
    public function getAuditInclude(): array
498
    {
499 53
        return $this->auditInclude ?? [];
500
    }
501
502
    /**
503
     * {@inheritdoc}
504
     */
505 58
    public function getAuditExclude(): array
506
    {
507 58
        return $this->auditExclude ?? [];
508
    }
509
510
    /**
511
     * {@inheritdoc}
512
     */
513 59
    public function getAuditStrict(): bool
514
    {
515 59
        return $this->auditStrict ?? Config::get('audit.strict', false);
516
    }
517
518
    /**
519
     * {@inheritdoc}
520
     */
521 59
    public function getAuditTimestamps(): bool
522
    {
523 59
        return $this->auditTimestamps ?? Config::get('audit.timestamps', false);
524
    }
525
526
    /**
527
     * {@inheritdoc}
528
     */
529 53
    public function getAuditDriver()
530
    {
531 53
        return $this->auditDriver ?? Config::get('audit.driver', 'database');
532
    }
533
534
    /**
535
     * {@inheritdoc}
536
     */
537 50
    public function getAuditThreshold(): int
538
    {
539 50
        return $this->auditThreshold ?? Config::get('audit.threshold', 0);
540
    }
541
542
    /**
543
     * {@inheritdoc}
544
     */
545 56
    public function getAttributeModifiers(): array
546
    {
547 56
        return $this->attributeModifiers ?? [];
548
    }
549
550
    /**
551
     * {@inheritdoc}
552
     */
553 56
    public function generateTags(): array
554
    {
555 56
        return [];
556
    }
557
558
    /**
559
     * {@inheritdoc}
560
     */
561 11
    public function transitionTo(Contracts\Audit $audit, bool $old = false): Contracts\Auditable
562
    {
563
        // The Audit must be for an Auditable model of this type
564 11
        if ($this->getMorphClass() !== $audit->auditable_type) {
0 ignored issues
show
Accessing auditable_type on the interface OwenIt\Auditing\Contracts\Audit suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
565 2
            throw new AuditableTransitionException(sprintf(
566 2
                'Expected Auditable type %s, got %s instead',
567 2
                $this->getMorphClass(),
568 2
                $audit->auditable_type
569
            ));
570
        }
571
572
        // The Audit must be for this specific Auditable model
573 9
        if ($this->getKey() !== $audit->auditable_id) {
0 ignored issues
show
Accessing auditable_id on the interface OwenIt\Auditing\Contracts\Audit suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
574 2
            throw new AuditableTransitionException(sprintf(
575 2
                'Expected Auditable id %s, got %s instead',
576 2
                $this->getKey(),
577 2
                $audit->auditable_id
578
            ));
579
        }
580
581
        // Redacted data should not be used when transitioning states
582 7
        foreach ($this->getAttributeModifiers() as $attribute => $modifier) {
583 1
            if (is_subclass_of($modifier, AttributeRedactor::class)) {
584 1
                throw new AuditableTransitionException('Cannot transition states when an AttributeRedactor is set');
585
            }
586
        }
587
588
        // The attribute compatibility between the Audit and the Auditable model must be met
589 6
        $modified = $audit->getModified();
590
591 6
        if ($incompatibilities = array_diff_key($modified, $this->getAttributes())) {
0 ignored issues
show
The method getAttributes() does not exist on OwenIt\Auditing\Auditable. Did you maybe mean getAttributeModifiers()? ( Ignorable by Annotation )

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

591
        if ($incompatibilities = array_diff_key($modified, $this->/** @scrutinizer ignore-call */ getAttributes())) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
It seems like $modified can also be of type string; however, parameter $array1 of array_diff_key() does only seem to accept array, 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

591
        if ($incompatibilities = array_diff_key(/** @scrutinizer ignore-type */ $modified, $this->getAttributes())) {
Loading history...
592 1
            throw new AuditableTransitionException(sprintf(
593 1
                'Incompatibility between [%s:%s] and [%s:%s]',
594 1
                $this->getMorphClass(),
595 1
                $this->getKey(),
596 1
                get_class($audit),
597 1
                $audit->getKey()
0 ignored issues
show
The method getKey() does not exist on OwenIt\Auditing\Contracts\Audit. Since it exists in all sub-types, consider adding an abstract or default implementation to OwenIt\Auditing\Contracts\Audit. ( Ignorable by Annotation )

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

597
                $audit->/** @scrutinizer ignore-call */ 
598
                        getKey()
Loading history...
598 1
            ), array_keys($incompatibilities));
599
        }
600
601 5
        $key = $old ? 'old' : 'new';
602
603 5
        foreach ($modified as $attribute => $value) {
604 3
            if (array_key_exists($key, $value)) {
605 3
                $this->setAttribute($attribute, $value[$key]);
0 ignored issues
show
It seems like setAttribute() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

605
                $this->/** @scrutinizer ignore-call */ 
606
                       setAttribute($attribute, $value[$key]);
Loading history...
606
            }
607
        }
608
609 5
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type OwenIt\Auditing\Auditable which is incompatible with the type-hinted return OwenIt\Auditing\Contracts\Auditable.
Loading history...
610
    }
611
}
612