resolved conflicts

This commit is contained in:
2025-03-31 03:46:05 +03:00
6454 changed files with 1520539 additions and 9 deletions
@@ -0,0 +1,79 @@
{
"name": "owen-it/laravel-auditing",
"description": "Audit changes of your Eloquent models in Laravel/Lumen",
"keywords": [
"accountability",
"audit",
"auditing",
"changes",
"eloquent",
"history",
"log",
"logging",
"observer",
"laravel",
"lumen",
"record",
"revision",
"tracking"
],
"homepage": "https://laravel-auditing.com",
"type": "package",
"license": "MIT",
"support": {
"issues": "https://github.com/owen-it/laravel-auditing/issues",
"source": "https://github.com/owen-it/laravel-auditing"
},
"authors": [
{
"name": "Antério Vieira",
"email": "anteriovieira@gmail.com"
},
{
"name": "Raphael França",
"email": "raphaelfrancabsb@gmail.com"
},
{
"name": "Morten D. Hansen",
"email": "morten@visia.dk"
}
],
"require": {
"php": "^7.3|^8.0",
"illuminate/console": "^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/filesystem": "^7.0|^8.0|^9.0|^10.0|^11.0",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.6|^10.5|^11.0",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0",
"laravel/legacy-factories": "*"
},
"autoload": {
"psr-4": {
"OwenIt\\Auditing\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"OwenIt\\Auditing\\Tests\\": "tests/"
}
},
"suggest": {
"irazasyed/larasupport": "Needed to publish the package configuration in Lumen"
},
"extra": {
"branch-alias": {
"dev-master": "v13-dev"
},
"laravel": {
"providers": [
"OwenIt\\Auditing\\AuditingServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
@@ -0,0 +1,198 @@
<?php
return [
'enabled' => env('AUDITING_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Audit Implementation
|--------------------------------------------------------------------------
|
| Define which Audit model implementation should be used.
|
*/
'implementation' => OwenIt\Auditing\Models\Audit::class,
/*
|--------------------------------------------------------------------------
| User Morph prefix & Guards
|--------------------------------------------------------------------------
|
| Define the morph prefix and authentication guards for the User resolver.
|
*/
'user' => [
'morph_prefix' => 'user',
'guards' => [
'web',
'api'
],
'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class
],
/*
|--------------------------------------------------------------------------
| Audit Resolvers
|--------------------------------------------------------------------------
|
| Define the IP Address, User Agent and URL resolver implementations.
|
*/
'resolvers' => [
'ip_address' => OwenIt\Auditing\Resolvers\IpAddressResolver::class,
'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class,
'url' => OwenIt\Auditing\Resolvers\UrlResolver::class,
],
/*
|--------------------------------------------------------------------------
| Audit Events
|--------------------------------------------------------------------------
|
| The Eloquent events that trigger an Audit.
|
*/
'events' => [
'created',
'updated',
'deleted',
'restored'
],
/*
|--------------------------------------------------------------------------
| Strict Mode
|--------------------------------------------------------------------------
|
| Enable the strict mode when auditing?
|
*/
'strict' => false,
/*
|--------------------------------------------------------------------------
| Global exclude
|--------------------------------------------------------------------------
|
| Have something you always want to exclude by default? - add it here.
| Note that this is overwritten (not merged) with local exclude
|
*/
'exclude' => [],
/*
|--------------------------------------------------------------------------
| Empty Values
|--------------------------------------------------------------------------
|
| Should Audit records be stored when the recorded old_values & new_values
| are both empty?
|
| Some events may be empty on purpose. Use allowed_empty_values to exclude
| those from the empty values check. For example when auditing
| model retrieved events which will never have new and old values.
|
|
*/
'empty_values' => true,
'allowed_empty_values' => [
'retrieved'
],
/*
|--------------------------------------------------------------------------
| Allowed Array Values
|--------------------------------------------------------------------------
|
| Should the array values be audited?
|
| By default, array values are not allowed. This is to prevent performance
| issues when storing large amounts of data. You can override this by
| setting allow_array_values to true.
*/
'allowed_array_values' => false,
/*
|--------------------------------------------------------------------------
| Audit Timestamps
|--------------------------------------------------------------------------
|
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
*/
'timestamps' => false,
/*
|--------------------------------------------------------------------------
| Audit Threshold
|--------------------------------------------------------------------------
|
| Specify a threshold for the amount of Audit records a model can have.
| Zero means no limit.
|
*/
'threshold' => 0,
/*
|--------------------------------------------------------------------------
| Audit Driver
|--------------------------------------------------------------------------
|
| The default audit driver used to keep track of changes.
|
*/
'driver' => 'database',
/*
|--------------------------------------------------------------------------
| Audit Driver Configurations
|--------------------------------------------------------------------------
|
| Available audit drivers and respective configurations.
|
*/
'drivers' => [
'database' => [
'table' => 'audits',
'connection' => null,
],
],
/*
|--------------------------------------------------------------------------
| Audit Queue Configurations
|--------------------------------------------------------------------------
|
| Available audit queue configurations.
|
*/
'queue' => [
'enable' => false,
'connection' => 'sync',
'queue' => 'default',
'delay' => 0,
],
/*
|--------------------------------------------------------------------------
| Audit Console
|--------------------------------------------------------------------------
|
| Whether console events should be audited (eg. php artisan db:seed).
|
*/
'console' => false,
];
@@ -0,0 +1,300 @@
<?php
namespace OwenIt\Auditing;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use InvalidArgumentException;
use OwenIt\Auditing\Contracts\AttributeEncoder;
trait Audit
{
/**
* Audit data.
*
* @var array
*/
protected $data = [];
/**
* The Audit attributes that belong to the metadata.
*
* @var array
*/
protected $metadata = [];
/**
* The Auditable attributes that were modified.
*
* @var array
*/
protected $modified = [];
/**
* {@inheritdoc}
*/
public function auditable()
{
return $this->morphTo();
}
/**
* {@inheritdoc}
*/
public function user()
{
$morphPrefix = Config::get('audit.user.morph_prefix', 'user');
return $this->morphTo(__FUNCTION__, $morphPrefix . '_type', $morphPrefix . '_id');
}
/**
* {@inheritdoc}
*/
public function getConnectionName()
{
return Config::get('audit.drivers.database.connection');
}
/**
* {@inheritdoc}
*/
public function getTable(): string
{
return Config::get('audit.drivers.database.table', parent::getTable());
}
/**
* {@inheritdoc}
*/
public function resolveData(): array
{
$morphPrefix = Config::get('audit.user.morph_prefix', 'user');
// Metadata
$this->data = [
'audit_id' => $this->getKey(),
'audit_event' => $this->event,
'audit_tags' => $this->tags,
'audit_created_at' => $this->serializeDate($this->{$this->getCreatedAtColumn()}),
'audit_updated_at' => $this->serializeDate($this->{$this->getUpdatedAtColumn()}),
'user_id' => $this->getAttribute($morphPrefix . '_id'),
'user_type' => $this->getAttribute($morphPrefix . '_type'),
];
// add resolvers data to metadata
$resolverData = [];
foreach (array_keys(Config::get('audit.resolvers', [])) as $name) {
$resolverData['audit_' . $name] = $this->$name;
}
$this->data = array_merge($this->data, $resolverData);
if ($this->user) {
foreach ($this->user->getArrayableAttributes() as $attribute => $value) {
$this->data['user_' . $attribute] = $value;
}
}
$this->metadata = array_keys($this->data);
// Modified Auditable attributes
foreach ($this->new_values ?? [] as $key => $value) {
$this->data['new_' . $key] = $value;
}
foreach ($this->old_values ?? [] as $key => $value) {
$this->data['old_' . $key] = $value;
}
$this->modified = array_diff_key(array_keys($this->data), $this->metadata);
return $this->data;
}
/**
* Get the formatted value of an Eloquent model.
*
* @param Model $model
* @param string $key
* @param mixed $value
*
* @return mixed
*/
protected function getFormattedValue(Model $model, string $key, $value)
{
// Apply defined get mutator
if ($model->hasGetMutator($key)) {
return $model->mutateAttribute($key, $value);
}
if (method_exists($model, 'hasAttributeMutator') && $model->hasAttributeMutator($key)) {
return $model->mutateAttributeMarkedAttribute($key, $value);
}
if (array_key_exists(
$key,
$model->getCasts()
) && $model->getCasts()[$key] == 'Illuminate\Database\Eloquent\Casts\AsArrayObject') {
$arrayObject = new \Illuminate\Database\Eloquent\Casts\ArrayObject(json_decode($value, true) ?: []);
return $arrayObject;
}
// Cast to native PHP type
if ($model->hasCast($key)) {
if ($model->getCastType($key) == 'datetime' ) {
$value = $this->castDatetimeUTC($model, $value);
}
unset($model->classCastCache[$key]);
return $model->castAttribute($key, $value);
}
// Honour DateTime attribute
if ($value !== null && in_array($key, $model->getDates(), true)) {
return $model->asDateTime($this->castDatetimeUTC($model, $value));
}
return $value;
}
private function castDatetimeUTC($model, $value)
{
if (!is_string($value)) {
return $value;
}
if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value)) {
return Date::instance(Carbon::createFromFormat('Y-m-d', $value, Date::now('UTC')->getTimezone())->startOfDay());
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $value)) {
return Date::instance(Carbon::createFromFormat('Y-m-d H:i:s', $value, Date::now('UTC')->getTimezone()));
}
try {
return Date::createFromFormat($model->getDateFormat(), $value, Date::now('UTC')->getTimezone());
} catch (InvalidArgumentException $e) {
return $value;
}
}
/**
* {@inheritdoc}
*/
public function getDataValue(string $key)
{
if (!array_key_exists($key, $this->data)) {
return;
}
$value = $this->data[$key];
// User value
if ($this->user && Str::startsWith($key, 'user_')) {
return $this->getFormattedValue($this->user, substr($key, 5), $value);
}
// Auditable value
if ($this->auditable && Str::startsWith($key, ['new_', 'old_'])) {
$attribute = substr($key, 4);
return $this->getFormattedValue(
$this->auditable,
$attribute,
$this->decodeAttributeValue($this->auditable, $attribute, $value)
);
}
return $value;
}
/**
* Decode attribute value.
*
* @param Contracts\Auditable $auditable
* @param string $attribute
* @param mixed $value
*
* @return mixed
*/
protected function decodeAttributeValue(Contracts\Auditable $auditable, string $attribute, $value)
{
$attributeModifiers = $auditable->getAttributeModifiers();
if (!array_key_exists($attribute, $attributeModifiers)) {
return $value;
}
$attributeDecoder = $attributeModifiers[$attribute];
if (is_subclass_of($attributeDecoder, AttributeEncoder::class)) {
return call_user_func([$attributeDecoder, 'decode'], $value);
}
return $value;
}
/**
* {@inheritdoc}
*/
public function getMetadata(bool $json = false, int $options = 0, int $depth = 512)
{
if (empty($this->data)) {
$this->resolveData();
}
$metadata = [];
foreach ($this->metadata as $key) {
$value = $this->getDataValue($key);
$metadata[$key] = $value;
if ($value instanceof DateTimeInterface) {
$metadata[$key] = !is_null($this->auditable) ? $this->auditable->serializeDate($value) : $this->serializeDate($value);
}
}
return $json ? json_encode($metadata, $options, $depth) : $metadata;
}
/**
* {@inheritdoc}
*/
public function getModified(bool $json = false, int $options = 0, int $depth = 512)
{
if (empty($this->data)) {
$this->resolveData();
}
$modified = [];
foreach ($this->modified as $key) {
$attribute = substr($key, 4);
$state = substr($key, 0, 3);
$value = $this->getDataValue($key);
$modified[$attribute][$state] = $value;
if ($value instanceof DateTimeInterface) {
$modified[$attribute][$state] = !is_null($this->auditable) ? $this->auditable->serializeDate($value) : $this->serializeDate($value);
}
}
return $json ? json_encode($modified, $options, $depth) : $modified;
}
/**
* Get the Audit tags as an array.
*
* @return array
*/
public function getTags(): array
{
return preg_split('/,/', $this->tags, -1, PREG_SPLIT_NO_EMPTY);
}
}
@@ -0,0 +1,872 @@
<?php
namespace OwenIt\Auditing;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use OwenIt\Auditing\Contracts\AttributeEncoder;
use OwenIt\Auditing\Contracts\AttributeRedactor;
use OwenIt\Auditing\Contracts\Resolver;
use OwenIt\Auditing\Events\AuditCustom;
use OwenIt\Auditing\Exceptions\AuditableTransitionException;
use OwenIt\Auditing\Exceptions\AuditingException;
trait Auditable
{
/**
* Auditable attributes excluded from the Audit.
*
* @var array
*/
protected $excludedAttributes = [];
/**
* Audit event name.
*
* @var string
*/
public $auditEvent;
/**
* Is auditing disabled?
*
* @var bool
*/
public static $auditingDisabled = false;
/**
* Property may set custom event data to register
* @var null|array
*/
public $auditCustomOld = null;
/**
* Property may set custom event data to register
* @var null|array
*/
public $auditCustomNew = null;
/**
* If this is a custom event (as opposed to an eloquent event
* @var bool
*/
public $isCustomEvent = false;
/**
* @var array Preloaded data to be used by resolvers
*/
public $preloadedResolverData = [];
/**
* Auditable boot logic.
*
* @return void
*/
public static function bootAuditable()
{
if (static::isAuditingEnabled()) {
static::observe(new AuditableObserver());
}
}
/**
* {@inheritdoc}
*/
public function audits(): MorphMany
{
return $this->morphMany(
Config::get('audit.implementation', Models\Audit::class),
'auditable'
);
}
/**
* Resolve the Auditable attributes to exclude from the Audit.
*
* @return void
*/
protected function resolveAuditExclusions()
{
$this->excludedAttributes = $this->getAuditExclude();
// When in strict mode, hidden and non visible attributes are excluded
if ($this->getAuditStrict()) {
// Hidden attributes
$this->excludedAttributes = array_merge($this->excludedAttributes, $this->hidden);
// Non visible attributes
if ($this->visible) {
$invisible = array_diff(array_keys($this->attributes), $this->visible);
$this->excludedAttributes = array_merge($this->excludedAttributes, $invisible);
}
}
// Exclude Timestamps
if (!$this->getAuditTimestamps()) {
if ($this->getCreatedAtColumn()) {
$this->excludedAttributes[] = $this->getCreatedAtColumn();
}
if ($this->getUpdatedAtColumn()) {
$this->excludedAttributes[] = $this->getUpdatedAtColumn();
}
if (in_array(SoftDeletes::class, class_uses_recursive(get_class($this)))) {
$this->excludedAttributes[] = $this->getDeletedAtColumn();
}
}
// Valid attributes are all those that made it out of the exclusion array
$attributes = Arr::except($this->attributes, $this->excludedAttributes);
foreach ($attributes as $attribute => $value) {
// Apart from null, non scalar values will be excluded
if (
(is_array($value) && !Config::get('audit.allowed_array_values', false)) ||
(is_object($value) &&
!method_exists($value, '__toString') &&
!($value instanceof \UnitEnum))
) {
$this->excludedAttributes[] = $attribute;
}
}
}
/**
* @return array
*/
public function getAuditExclude(): array
{
return $this->auditExclude ?? Config::get('audit.exclude', []);
}
/**
* @return array
*/
public function getAuditInclude(): array
{
return $this->auditInclude ?? [];
}
/**
* Get the old/new attributes of a retrieved event.
*
* @return array
*/
protected function getRetrievedEventAttributes(): array
{
// This is a read event with no attribute changes,
// only metadata will be stored in the Audit
return [
[],
[],
];
}
/**
* Get the old/new attributes of a created event.
*
* @return array
*/
protected function getCreatedEventAttributes(): array
{
$new = [];
foreach ($this->attributes as $attribute => $value) {
if ($this->isAttributeAuditable($attribute)) {
$new[$attribute] = $value;
}
}
return [
[],
$new,
];
}
protected function getCustomEventAttributes(): array
{
return [
$this->auditCustomOld,
$this->auditCustomNew
];
}
/**
* Get the old/new attributes of an updated event.
*
* @return array
*/
protected function getUpdatedEventAttributes(): array
{
$old = [];
$new = [];
foreach ($this->getDirty() as $attribute => $value) {
if ($this->isAttributeAuditable($attribute)) {
$old[$attribute] = Arr::get($this->original, $attribute);
$new[$attribute] = Arr::get($this->attributes, $attribute);
}
}
return [
$old,
$new,
];
}
/**
* Get the old/new attributes of a deleted event.
*
* @return array
*/
protected function getDeletedEventAttributes(): array
{
$old = [];
foreach ($this->attributes as $attribute => $value) {
if ($this->isAttributeAuditable($attribute)) {
$old[$attribute] = $value;
}
}
return [
$old,
[],
];
}
/**
* Get the old/new attributes of a restored event.
*
* @return array
*/
protected function getRestoredEventAttributes(): array
{
// A restored event is just a deleted event in reverse
return array_reverse($this->getDeletedEventAttributes());
}
/**
* {@inheritdoc}
*/
public function readyForAuditing(): bool
{
if (static::$auditingDisabled || Models\Audit::$auditingGloballyDisabled) {
return false;
}
if ($this->isCustomEvent) {
return true;
}
return $this->isEventAuditable($this->auditEvent);
}
/**
* Modify attribute value.
*
* @param string $attribute
* @param mixed $value
*
* @return mixed
* @throws AuditingException
*
*/
protected function modifyAttributeValue(string $attribute, $value)
{
$attributeModifiers = $this->getAttributeModifiers();
if (!array_key_exists($attribute, $attributeModifiers)) {
return $value;
}
$attributeModifier = $attributeModifiers[$attribute];
if (is_subclass_of($attributeModifier, AttributeRedactor::class)) {
return call_user_func([$attributeModifier, 'redact'], $value);
}
if (is_subclass_of($attributeModifier, AttributeEncoder::class)) {
return call_user_func([$attributeModifier, 'encode'], $value);
}
throw new AuditingException(sprintf('Invalid AttributeModifier implementation: %s', $attributeModifier));
}
/**
* {@inheritdoc}
*/
public function toAudit(): array
{
if (!$this->readyForAuditing()) {
throw new AuditingException('A valid audit event has not been set');
}
$attributeGetter = $this->resolveAttributeGetter($this->auditEvent);
if (!method_exists($this, $attributeGetter)) {
throw new AuditingException(sprintf(
'Unable to handle "%s" event, %s() method missing',
$this->auditEvent,
$attributeGetter
));
}
$this->resolveAuditExclusions();
list($old, $new) = $this->$attributeGetter();
if ($this->getAttributeModifiers() && !$this->isCustomEvent) {
foreach ($old as $attribute => $value) {
$old[$attribute] = $this->modifyAttributeValue($attribute, $value);
}
foreach ($new as $attribute => $value) {
$new[$attribute] = $this->modifyAttributeValue($attribute, $value);
}
}
$morphPrefix = Config::get('audit.user.morph_prefix', 'user');
$tags = implode(',', $this->generateTags());
$user = $this->resolveUser();
return $this->transformAudit(array_merge([
'old_values' => $old,
'new_values' => $new,
'event' => $this->auditEvent,
'auditable_id' => $this->getKey(),
'auditable_type' => $this->getMorphClass(),
$morphPrefix . '_id' => $user ? $user->getAuthIdentifier() : null,
$morphPrefix . '_type' => $user ? $user->getMorphClass() : null,
'tags' => empty($tags) ? null : $tags,
], $this->runResolvers()));
}
/**
* {@inheritdoc}
*/
public function transformAudit(array $data): array
{
return $data;
}
/**
* Resolve the User.
*
* @return mixed|null
* @throws AuditingException
*
*/
protected function resolveUser()
{
if (!empty($this->preloadedResolverData['user'] ?? null)) {
return $this->preloadedResolverData['user'];
}
$userResolver = Config::get('audit.user.resolver');
if (is_null($userResolver) && Config::has('audit.resolver') && !Config::has('audit.user.resolver')) {
trigger_error(
'The config file audit.php is not updated to the new version 13.0. Please see https://laravel-auditing.com/guide/upgrading.html',
E_USER_DEPRECATED
);
$userResolver = Config::get('audit.resolver.user');
}
if (is_subclass_of($userResolver, \OwenIt\Auditing\Contracts\UserResolver::class)) {
return call_user_func([$userResolver, 'resolve'], $this);
}
throw new AuditingException('Invalid UserResolver implementation');
}
protected function runResolvers(): array
{
$resolved = [];
$resolvers = Config::get('audit.resolvers', []);
if (empty($resolvers) && Config::has('audit.resolver')) {
trigger_error(
'The config file audit.php is not updated to the new version 13.0. Please see https://laravel-auditing.com/guide/upgrading.html',
E_USER_DEPRECATED
);
$resolvers = Config::get('audit.resolver', []);
}
foreach ($resolvers as $name => $implementation) {
if (empty($implementation)) {
continue;
}
if (!is_subclass_of($implementation, Resolver::class)) {
throw new AuditingException('Invalid Resolver implementation for: ' . $name);
}
$resolved[$name] = call_user_func([$implementation, 'resolve'], $this);
}
return $resolved;
}
public function preloadResolverData()
{
$this->preloadedResolverData = $this->runResolvers();
$user = $this->resolveUser();
if (!empty($user)) {
$this->preloadedResolverData['user'] = $user;
}
return $this;
}
/**
* Determine if an attribute is eligible for auditing.
*
* @param string $attribute
*
* @return bool
*/
protected function isAttributeAuditable(string $attribute): bool
{
// The attribute should not be audited
if (in_array($attribute, $this->excludedAttributes, true)) {
return false;
}
// The attribute is auditable when explicitly
// listed or when the include array is empty
$include = $this->getAuditInclude();
return empty($include) || in_array($attribute, $include, true);
}
/**
* Determine whether an event is auditable.
*
* @param string $event
*
* @return bool
*/
protected function isEventAuditable($event): bool
{
return is_string($this->resolveAttributeGetter($event));
}
/**
* Attribute getter method resolver.
*
* @param string $event
*
* @return string|null
*/
protected function resolveAttributeGetter($event)
{
if (empty($event)) {
return;
}
if ($this->isCustomEvent) {
return 'getCustomEventAttributes';
}
foreach ($this->getAuditEvents() as $key => $value) {
$auditableEvent = is_int($key) ? $value : $key;
$auditableEventRegex = sprintf('/%s/', preg_replace('/\*+/', '.*', $auditableEvent));
if (preg_match($auditableEventRegex, $event)) {
return is_int($key) ? sprintf('get%sEventAttributes', ucfirst($event)) : $value;
}
}
}
/**
* {@inheritdoc}
*/
public function setAuditEvent(string $event): Contracts\Auditable
{
$this->auditEvent = $this->isEventAuditable($event) ? $event : null;
return $this;
}
/**
* {@inheritdoc}
*/
public function getAuditEvent()
{
return $this->auditEvent;
}
/**
* {@inheritdoc}
*/
public function getAuditEvents(): array
{
return $this->auditEvents ?? Config::get('audit.events', [
'created',
'updated',
'deleted',
'restored',
]);
}
/**
* Is Auditing disabled.
*
* @return bool
*/
public static function isAuditingDisabled(): bool
{
return static::$auditingDisabled || Models\Audit::$auditingGloballyDisabled;
}
/**
* Disable Auditing.
*
* @return void
*/
public static function disableAuditing()
{
static::$auditingDisabled = true;
}
/**
* Enable Auditing.
*
* @return void
*/
public static function enableAuditing()
{
static::$auditingDisabled = false;
}
/**
* Execute a callback while auditing is disabled.
*
* @param callable $callback
* @param bool $globally
*
* @return mixed
*/
public static function withoutAuditing(callable $callback, bool $globally = false)
{
$auditingDisabled = static::$auditingDisabled;
static::disableAuditing();
Models\Audit::$auditingGloballyDisabled = $globally;
try {
return $callback();
} finally {
Models\Audit::$auditingGloballyDisabled = false;
static::$auditingDisabled = $auditingDisabled;
}
}
/**
* Determine whether auditing is enabled.
*
* @return bool
*/
public static function isAuditingEnabled(): bool
{
if (App::runningInConsole()) {
return Config::get('audit.enabled', true) && Config::get('audit.console', false);
}
return Config::get('audit.enabled', true);
}
/**
* {@inheritdoc}
*/
public function getAuditStrict(): bool
{
return $this->auditStrict ?? Config::get('audit.strict', false);
}
/**
* {@inheritdoc}
*/
public function getAuditTimestamps(): bool
{
return $this->auditTimestamps ?? Config::get('audit.timestamps', false);
}
/**
* {@inheritdoc}
*/
public function getAuditDriver()
{
return $this->auditDriver ?? Config::get('audit.driver', 'database');
}
/**
* {@inheritdoc}
*/
public function getAuditThreshold(): int
{
return $this->auditThreshold ?? Config::get('audit.threshold', 0);
}
/**
* {@inheritdoc}
*/
public function getAttributeModifiers(): array
{
return $this->attributeModifiers ?? [];
}
/**
* {@inheritdoc}
*/
public function generateTags(): array
{
return [];
}
/**
* {@inheritdoc}
*/
public function transitionTo(Contracts\Audit $audit, bool $old = false): Contracts\Auditable
{
// The Audit must be for an Auditable model of this type
if ($this->getMorphClass() !== $audit->auditable_type) {
throw new AuditableTransitionException(sprintf(
'Expected Auditable type %s, got %s instead',
$this->getMorphClass(),
$audit->auditable_type
));
}
// The Audit must be for this specific Auditable model
if ($this->getKey() !== $audit->auditable_id) {
throw new AuditableTransitionException(sprintf(
'Expected Auditable id (%s)%s, got (%s)%s instead',
gettype($this->getKey()),
$this->getKey(),
gettype($audit->auditable_id),
$audit->auditable_id
));
}
// Redacted data should not be used when transitioning states
foreach ($this->getAttributeModifiers() as $attribute => $modifier) {
if (is_subclass_of($modifier, AttributeRedactor::class)) {
throw new AuditableTransitionException('Cannot transition states when an AttributeRedactor is set');
}
}
// The attribute compatibility between the Audit and the Auditable model must be met
$modified = $audit->getModified();
if ($incompatibilities = array_diff_key($modified, $this->getAttributes())) {
throw new AuditableTransitionException(sprintf(
'Incompatibility between [%s:%s] and [%s:%s]',
$this->getMorphClass(),
$this->getKey(),
get_class($audit),
$audit->getKey()
), array_keys($incompatibilities));
}
$key = $old ? 'old' : 'new';
foreach ($modified as $attribute => $value) {
if (array_key_exists($key, $value)) {
$this->setAttribute($attribute, $value[$key]);
}
}
return $this;
}
/*
|--------------------------------------------------------------------------
| Pivot help methods
|--------------------------------------------------------------------------
|
| Methods for auditing pivot actions
|
*/
/**
* @param string $relationName
* @param mixed $id
* @param array $attributes
* @param bool $touch
* @param array $columns
* @param \Closure|null $callback
* @return void
* @throws AuditingException
*/
public function auditAttach(string $relationName, $id, array $attributes = [], $touch = true, $columns = ['*'], $callback = null)
{
$this->validateRelationshipMethodExistence($relationName, 'attach');
$relationCall = $this->{$relationName}();
if ($callback instanceof \Closure) {
$this->applyClosureToRelationship($relationCall, $callback);
}
$old = $relationCall->get($columns);
$relationCall->attach($id, $attributes, $touch);
$new = $relationCall->get($columns);
$this->dispatchRelationAuditEvent($relationName, 'attach', $old, $new);
}
/**
* @param string $relationName
* @param mixed $ids
* @param bool $touch
* @param array $columns
* @param \Closure|null $callback
* @return int
* @throws AuditingException
*/
public function auditDetach(string $relationName, $ids = null, $touch = true, $columns = ['*'], $callback = null)
{
$this->validateRelationshipMethodExistence($relationName, 'detach');
$relationCall = $this->{$relationName}();
if ($callback instanceof \Closure) {
$this->applyClosureToRelationship($relationCall, $callback);
}
$old = $relationCall->get($columns);
$results = $relationCall->detach($ids, $touch);
$new = $relationCall->get($columns);
$this->dispatchRelationAuditEvent($relationName, 'detach', $old, $new);
return empty($results) ? 0 : $results;
}
/**
* @param string $relationName
* @param Collection|Model|array $ids
* @param bool $detaching
* @param array $columns
* @param \Closure|null $callback
* @return array
* @throws AuditingException
*/
public function auditSync(string $relationName, $ids, $detaching = true, $columns = ['*'], $callback = null)
{
$this->validateRelationshipMethodExistence($relationName, 'sync');
$relationCall = $this->{$relationName}();
if ($callback instanceof \Closure) {
$this->applyClosureToRelationship($relationCall, $callback);
}
$old = $relationCall->get($columns);
$changes = $relationCall->sync($ids, $detaching);
if (collect($changes)->flatten()->isEmpty()) {
$old = $new = collect([]);
} else {
$new = $relationCall->get($columns);
}
$this->dispatchRelationAuditEvent($relationName, 'sync', $old, $new);
return $changes;
}
/**
* @param string $relationName
* @param Collection|Model|array $ids
* @param array $columns
* @param \Closure|null $callback
* @return array
* @throws AuditingException
*/
public function auditSyncWithoutDetaching(string $relationName, $ids, $columns = ['*'], $callback = null)
{
$this->validateRelationshipMethodExistence($relationName, 'syncWithoutDetaching');
return $this->auditSync($relationName, $ids, false, $columns, $callback);
}
/**
* @param string $relationName
* @param Collection|Model|array $ids
* @param array $values
* @param bool $detaching
* @param array $columns
* @param \Closure|null $callback
* @return array
*/
public function auditSyncWithPivotValues(string $relationName, $ids, array $values, bool $detaching = true, $columns = ['*'], $callback = null)
{
$this->validateRelationshipMethodExistence($relationName, 'syncWithPivotValues');
if ($ids instanceof Model) {
$ids = $ids->getKey();
} elseif ($ids instanceof \Illuminate\Database\Eloquent\Collection) {
$ids = $ids->isEmpty() ? [] : $ids->pluck($ids->first()->getKeyName())->toArray();
} elseif ($ids instanceof Collection) {
$ids = $ids->toArray();
}
return $this->auditSync($relationName, collect(Arr::wrap($ids))->mapWithKeys(function ($id) use ($values) {
return [$id => $values];
}), $detaching, $columns, $callback);
}
/**
* @param string $relationName
* @param string $event
* @param Collection $old
* @param Collection $new
* @return void
*/
private function dispatchRelationAuditEvent($relationName, $event, $old, $new)
{
$this->auditCustomOld[$relationName] = $old->diff($new)->toArray();
$this->auditCustomNew[$relationName] = $new->diff($old)->toArray();
if (
empty($this->auditCustomOld[$relationName]) &&
empty($this->auditCustomNew[$relationName])
) {
$this->auditCustomOld = $this->auditCustomNew = [];
}
$this->auditEvent = $event;
$this->isCustomEvent = true;
Event::dispatch(AuditCustom::class, [$this]);
$this->auditCustomOld = $this->auditCustomNew = [];
$this->isCustomEvent = false;
}
private function validateRelationshipMethodExistence(string $relationName, string $methodName): void
{
if (!method_exists($this, $relationName) || !method_exists($this->{$relationName}(), $methodName)) {
throw new AuditingException("Relationship $relationName was not found or does not support method $methodName");
}
}
private function applyClosureToRelationship(BelongsToMany $relation, \Closure $closure): void
{
try {
$closure($relation);
} catch (\Throwable $exception) {
throw new AuditingException("Invalid Closure for {$relation->getRelationName()} Relationship");
}
}
}
@@ -0,0 +1,134 @@
<?php
namespace OwenIt\Auditing;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Events\DispatchAudit;
use OwenIt\Auditing\Events\DispatchingAudit;
use OwenIt\Auditing\Facades\Auditor;
class AuditableObserver
{
/**
* Is the model being restored?
*
* @var bool
*/
public static $restoring = false;
/**
* Handle the retrieved event.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
*
* @return void
*/
public function retrieved(Auditable $model)
{
$this->dispatchAudit($model->setAuditEvent('retrieved'));
}
/**
* Handle the created event.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
*
* @return void
*/
public function created(Auditable $model)
{
$this->dispatchAudit($model->setAuditEvent('created'));
}
/**
* Handle the updated event.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
*
* @return void
*/
public function updated(Auditable $model)
{
// Ignore the updated event when restoring
if (!static::$restoring) {
$this->dispatchAudit($model->setAuditEvent('updated'));
}
}
/**
* Handle the deleted event.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
*
* @return void
*/
public function deleted(Auditable $model)
{
$this->dispatchAudit($model->setAuditEvent('deleted'));
}
/**
* Handle the restoring event.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
*
* @return void
*/
public function restoring(Auditable $model)
{
// When restoring a model, an updated event is also fired.
// By keeping track of the main event that took place,
// we avoid creating a second audit with wrong values
static::$restoring = true;
}
/**
* Handle the restored event.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
*
* @return void
*/
public function restored(Auditable $model)
{
$this->dispatchAudit($model->setAuditEvent('restored'));
// Once the model is restored, we need to put everything back
// as before, in case a legitimate update event is fired
static::$restoring = false;
}
protected function dispatchAudit(Auditable $model)
{
if (!$model->readyForAuditing()) {
return;
}
$model->preloadResolverData();
if (!Config::get('audit.queue.enable', false)) {
Auditor::execute($model);
return;
}
if (!$this->fireDispatchingAuditEvent($model)) {
return;
}
// Unload the relations to prevent large amounts of unnecessary data from being serialized.
app()->make('events')->dispatch(new DispatchAudit($model->withoutRelations()));
}
/**
* Fire the Auditing event.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
*
* @return bool
*/
protected function fireDispatchingAuditEvent(Auditable $model): bool
{
return app()->make('events')
->until(new DispatchingAudit($model)) !== false;
}
}
@@ -0,0 +1,72 @@
<?php
namespace OwenIt\Auditing;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use OwenIt\Auditing\Console\AuditDriverCommand;
use OwenIt\Auditing\Console\AuditResolverCommand;
use OwenIt\Auditing\Console\InstallCommand;
use OwenIt\Auditing\Contracts\Auditor;
use OwenIt\Auditing\Events\AuditCustom;
use OwenIt\Auditing\Events\DispatchAudit;
use OwenIt\Auditing\Listeners\ProcessDispatchAudit;
use OwenIt\Auditing\Listeners\RecordCustomAudit;
class AuditingServiceProvider extends ServiceProvider
{
/**
* Bootstrap the service provider.
*
* @return void
*/
public function boot()
{
$this->registerPublishing();
$this->mergeConfigFrom(__DIR__ . '/../config/audit.php', 'audit');
Event::listen(AuditCustom::class, RecordCustomAudit::class);
Event::listen(DispatchAudit::class, ProcessDispatchAudit::class);
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->commands([
AuditDriverCommand::class,
AuditResolverCommand::class,
InstallCommand::class,
]);
$this->app->singleton(Auditor::class, function ($app) {
return new \OwenIt\Auditing\Auditor($app);
});
}
/**
* Register the package's publishable resources.
*
* @return void
*/
private function registerPublishing()
{
if ($this->app->runningInConsole()) {
// Lumen lacks a config_path() helper, so we use base_path()
$this->publishes([
__DIR__ . '/../config/audit.php' => base_path('config/audit.php'),
], 'config');
if (!class_exists('CreateAuditsTable')) {
$this->publishes([
__DIR__ . '/../database/migrations/audits.stub' => database_path(
sprintf('migrations/%s_create_audits_table.php', date('Y_m_d_His'))
),
], 'migrations');
}
}
}
}
@@ -0,0 +1,18 @@
<?php
namespace OwenIt\Auditing\Contracts;
interface Auditor
{
/**
* Get an audit driver instance.
*/
public function auditDriver(Auditable $model): AuditDriver;
/**
* Perform an audit.
*
* @return void
*/
public function execute(Auditable $model);
}
@@ -0,0 +1,47 @@
<?php
namespace OwenIt\Auditing\Drivers;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Contracts\Audit;
use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Contracts\AuditDriver;
class Database implements AuditDriver
{
/**
* {@inheritdoc}
*/
public function audit(Auditable $model): ?Audit
{
return call_user_func([get_class($model->audits()->getModel()), 'create'], $model->toAudit());
}
/**
* {@inheritdoc}
*/
public function prune(Auditable $model): bool
{
if (($threshold = $model->getAuditThreshold()) > 0) {
$auditClass = get_class($model->audits()->getModel());
$auditModel = new $auditClass;
return $model->audits()
->leftJoinSub(
$model->audits()->select($auditModel->getKeyName())->limit($threshold)->latest(),
'audit_threshold',
function ($join) use ($auditModel) {
$join->on(
$auditModel->gettable().'.'.$auditModel->getKeyName(),
'=',
'audit_threshold.'.$auditModel->getKeyName()
);
}
)
->whereNull('audit_threshold.'.$auditModel->getKeyName())
->delete() > 0;
}
return false;
}
}
@@ -0,0 +1,45 @@
<?php
namespace OwenIt\Auditing\Events;
use OwenIt\Auditing\Contracts\Audit;
use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Contracts\AuditDriver;
class Audited
{
/**
* The Auditable model.
*
* @var \OwenIt\Auditing\Contracts\Auditable
*/
public $model;
/**
* Audit driver.
*
* @var \OwenIt\Auditing\Contracts\AuditDriver
*/
public $driver;
/**
* The Audit model.
*
* @var \OwenIt\Auditing\Contracts\Audit|null
*/
public $audit;
/**
* Create a new Audited event instance.
*
* @param \OwenIt\Auditing\Contracts\Auditable $model
* @param \OwenIt\Auditing\Contracts\AuditDriver $driver
* @param \OwenIt\Auditing\Contracts\Audit|null $audit
*/
public function __construct(Auditable $model, AuditDriver $driver, ?Audit $audit = null)
{
$this->model = $model;
$this->driver = $driver;
$this->audit = $audit;
}
}
@@ -0,0 +1,110 @@
<?php
namespace OwenIt\Auditing\Events;
use OwenIt\Auditing\Contracts\Auditable;
use ReflectionClass;
class DispatchAudit
{
/**
* The Auditable model.
*
* @var Auditable
*/
public $model;
/**
* Create a new DispatchAudit event instance.
*
* @param Auditable $model
*/
public function __construct(Auditable $model)
{
$this->model = $model;
}
/**
* Prepare the instance values for serialization.
*
* @return array
*/
public function __serialize()
{
$values = [
'class' => get_class($this->model),
'model_data' => [
'exists' => true,
'connection' => $this->model->getQueueableConnection()
]
];
$customProperties = array_merge([
'attributes',
'original',
'excludedAttributes',
'auditEvent',
'auditExclude',
'auditCustomOld',
'auditCustomNew',
'isCustomEvent',
'preloadedResolverData',
], $this->model->auditEventSerializedProperties ?? []);
$reflection = new ReflectionClass($this->model);
foreach ($customProperties as $key) {
try {
$values['model_data'][$key] = $this->getModelPropertyValue($reflection, $key);
} catch (\Throwable $e){
//
}
}
return $values;
}
/**
* Restore the model after serialization.
*
* @param array $values
* @return array
*/
public function __unserialize(array $values)
{
$this->model = new $values['class'];
$reflection = new ReflectionClass($this->model);
foreach ($values['model_data'] as $key => $value) {
$this->setModelPropertyValue($reflection, $key, $value);
}
return $values;
}
/**
* Set the property value for the given property.
*/
protected function setModelPropertyValue(ReflectionClass $reflection, string $name, $value)
{
$property = $reflection->getProperty($name);
$property->setAccessible(true);
$property->setValue($this->model, $value);
}
/**
* Get the property value for the given property.
*
* @return mixed
*/
protected function getModelPropertyValue(ReflectionClass $reflection, string $name)
{
$property = $reflection->getProperty($name);
$property->setAccessible(true);
return $property->getValue($this->model);
}
}
@@ -0,0 +1,35 @@
<?php
namespace OwenIt\Auditing\Exceptions;
use Throwable;
class AuditableTransitionException extends AuditingException
{
/**
* Attribute incompatibilities.
*
* @var array
*/
protected $incompatibilities = [];
/**
* {@inheritdoc}
*/
public function __construct($message = '', array $incompatibilities = [], $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->incompatibilities = $incompatibilities;
}
/**
* Get the attribute incompatibilities.
*
* @return array
*/
public function getIncompatibilities(): array
{
return $this->incompatibilities;
}
}
@@ -0,0 +1,44 @@
<?php
namespace OwenIt\Auditing\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $tags
* @property string $event
* @property array $new_values
* @property array $old_values
* @property mixed $user
* @property mixed $auditable.
*/
class Audit extends Model implements \OwenIt\Auditing\Contracts\Audit
{
use \OwenIt\Auditing\Audit;
/**
* {@inheritdoc}
*/
protected $guarded = [];
/**
* Is globally auditing disabled?
*
* @var bool
*/
public static $auditingGloballyDisabled = false;
/**
* {@inheritdoc}
*/
protected $casts = [
'old_values' => 'json',
'new_values' => 'json',
// Note: Please do not add 'auditable_id' in here, as it will break non-integer PK models
];
public function getSerializedDate($date)
{
return $this->serializeDate($date);
}
}