├── LICENSE.md ├── composer.json ├── config └── audit.php ├── database └── migrations │ └── audits.stub ├── src ├── Audit.php ├── Auditable.php ├── AuditableObserver.php ├── AuditingServiceProvider.php ├── Auditor.php ├── Console │ ├── AuditDriverCommand.php │ ├── AuditResolverCommand.php │ └── InstallCommand.php ├── Contracts │ ├── AttributeEncoder.php │ ├── AttributeModifier.php │ ├── AttributeRedactor.php │ ├── Audit.php │ ├── AuditDriver.php │ ├── Auditable.php │ ├── Auditor.php │ ├── Resolver.php │ └── UserResolver.php ├── Drivers │ └── Database.php ├── Encoders │ └── Base64Encoder.php ├── Events │ ├── AuditCustom.php │ ├── Audited.php │ ├── Auditing.php │ ├── DispatchAudit.php │ └── DispatchingAudit.php ├── Exceptions │ ├── AuditableTransitionException.php │ └── AuditingException.php ├── Facades │ └── Auditor.php ├── Listeners │ ├── ProcessDispatchAudit.php │ └── RecordCustomAudit.php ├── Models │ └── Audit.php ├── Redactors │ ├── LeftRedactor.php │ └── RightRedactor.php └── Resolvers │ ├── DumpResolver.php │ ├── IpAddressResolver.php │ ├── UrlResolver.php │ ├── UserAgentResolver.php │ └── UserResolver.php └── stubs ├── driver.stub └── resolver.stub /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### The MIT License (MIT) 2 | 3 | Copyright (C) 2015-2023 Antério Vieira, Quetzy Garcia, Raphael França. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "owen-it/laravel-auditing", 3 | "description": "Audit changes of your Eloquent models in Laravel", 4 | "keywords": [ 5 | "accountability", 6 | "audit", 7 | "auditing", 8 | "changes", 9 | "eloquent", 10 | "history", 11 | "log", 12 | "logging", 13 | "observer", 14 | "laravel", 15 | "lumen", 16 | "record", 17 | "revision", 18 | "tracking" 19 | ], 20 | "homepage": "https://laravel-auditing.com", 21 | "type": "package", 22 | "license": "MIT", 23 | "support": { 24 | "issues": "https://github.com/owen-it/laravel-auditing/issues", 25 | "source": "https://github.com/owen-it/laravel-auditing" 26 | }, 27 | "authors": [ 28 | { 29 | "name": "Antério Vieira", 30 | "email": "anteriovieira@gmail.com" 31 | }, 32 | { 33 | "name": "Raphael França", 34 | "email": "raphaelfrancabsb@gmail.com" 35 | }, 36 | { 37 | "name": "Morten D. Hansen", 38 | "email": "morten@visia.dk" 39 | } 40 | ], 41 | "require": { 42 | "php": "^8.2", 43 | "ext-json": "*", 44 | "illuminate/console": "^11.0|^12.0", 45 | "illuminate/database": "^11.0|^12.0", 46 | "illuminate/filesystem": "^11.0|^12.0" 47 | }, 48 | "require-dev": { 49 | "mockery/mockery": "^1.5.1", 50 | "orchestra/testbench": "^9.0|^10.0", 51 | "phpunit/phpunit": "^11.0" 52 | }, 53 | "autoload": { 54 | "psr-4": { 55 | "OwenIt\\Auditing\\": "src/" 56 | } 57 | }, 58 | "autoload-dev": { 59 | "psr-4": { 60 | "OwenIt\\Auditing\\Tests\\": "tests/" 61 | } 62 | }, 63 | "extra": { 64 | "branch-alias": { 65 | "dev-master": "v14-dev" 66 | }, 67 | "laravel": { 68 | "providers": [ 69 | "OwenIt\\Auditing\\AuditingServiceProvider" 70 | ] 71 | } 72 | }, 73 | "scripts": { 74 | "test": "phpunit", 75 | "format": "composer require --dev laravel/pint --quiet && pint --config .pint.json && composer remove --dev laravel/pint --no-update", 76 | "analyse": "composer require --dev larastan/larastan --quiet && phpstan analyse && composer remove --dev larastan/larastan --no-update" 77 | }, 78 | "minimum-stability": "dev", 79 | "prefer-stable": true 80 | } 81 | -------------------------------------------------------------------------------- /config/audit.php: -------------------------------------------------------------------------------- 1 | env('AUDITING_ENABLED', true), 6 | 7 | /* 8 | |-------------------------------------------------------------------------- 9 | | Audit Implementation 10 | |-------------------------------------------------------------------------- 11 | | 12 | | Define which Audit model implementation should be used. 13 | | 14 | */ 15 | 16 | 'implementation' => OwenIt\Auditing\Models\Audit::class, 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | User Morph prefix & Guards 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Define the morph prefix and authentication guards for the User resolver. 24 | | 25 | */ 26 | 27 | 'user' => [ 28 | 'morph_prefix' => 'user', 29 | 'guards' => [ 30 | 'web', 31 | 'api', 32 | ], 33 | 'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class, 34 | ], 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Audit Resolvers 39 | |-------------------------------------------------------------------------- 40 | | 41 | | Define the IP Address, User Agent and URL resolver implementations. 42 | | 43 | */ 44 | 'resolvers' => [ 45 | 'ip_address' => OwenIt\Auditing\Resolvers\IpAddressResolver::class, 46 | 'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class, 47 | 'url' => OwenIt\Auditing\Resolvers\UrlResolver::class, 48 | ], 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Audit Events 53 | |-------------------------------------------------------------------------- 54 | | 55 | | The Eloquent events that trigger an Audit. 56 | | 57 | */ 58 | 59 | 'events' => [ 60 | 'created', 61 | 'updated', 62 | 'deleted', 63 | 'restored', 64 | ], 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | Strict Mode 69 | |-------------------------------------------------------------------------- 70 | | 71 | | Enable the strict mode when auditing? 72 | | 73 | */ 74 | 75 | 'strict' => false, 76 | 77 | /* 78 | |-------------------------------------------------------------------------- 79 | | Global exclude 80 | |-------------------------------------------------------------------------- 81 | | 82 | | Have something you always want to exclude by default? - add it here. 83 | | Note that this is overwritten (not merged) with local exclude 84 | | 85 | */ 86 | 87 | 'exclude' => [], 88 | 89 | /* 90 | |-------------------------------------------------------------------------- 91 | | Empty Values 92 | |-------------------------------------------------------------------------- 93 | | 94 | | Should Audit records be stored when the recorded old_values & new_values 95 | | are both empty? 96 | | 97 | | Some events may be empty on purpose. Use allowed_empty_values to exclude 98 | | those from the empty values check. For example when auditing 99 | | model retrieved events which will never have new and old values. 100 | | 101 | | 102 | */ 103 | 104 | 'empty_values' => true, 105 | 'allowed_empty_values' => [ 106 | 'retrieved', 107 | ], 108 | 109 | /* 110 | |-------------------------------------------------------------------------- 111 | | Allowed Array Values 112 | |-------------------------------------------------------------------------- 113 | | 114 | | Should the array values be audited? 115 | | 116 | | By default, array values are not allowed. This is to prevent performance 117 | | issues when storing large amounts of data. You can override this by 118 | | setting allow_array_values to true. 119 | */ 120 | 'allowed_array_values' => false, 121 | 122 | /* 123 | |-------------------------------------------------------------------------- 124 | | Audit Timestamps 125 | |-------------------------------------------------------------------------- 126 | | 127 | | Should the created_at, updated_at and deleted_at timestamps be audited? 128 | | 129 | */ 130 | 131 | 'timestamps' => false, 132 | 133 | /* 134 | |-------------------------------------------------------------------------- 135 | | Audit Threshold 136 | |-------------------------------------------------------------------------- 137 | | 138 | | Specify a threshold for the amount of Audit records a model can have. 139 | | Zero means no limit. 140 | | 141 | */ 142 | 143 | 'threshold' => 0, 144 | 145 | /* 146 | |-------------------------------------------------------------------------- 147 | | Audit Driver 148 | |-------------------------------------------------------------------------- 149 | | 150 | | The default audit driver used to keep track of changes. 151 | | 152 | */ 153 | 154 | 'driver' => 'database', 155 | 156 | /* 157 | |-------------------------------------------------------------------------- 158 | | Audit Driver Configurations 159 | |-------------------------------------------------------------------------- 160 | | 161 | | Available audit drivers and respective configurations. 162 | | 163 | */ 164 | 165 | 'drivers' => [ 166 | 'database' => [ 167 | 'table' => 'audits', 168 | 'connection' => null, 169 | ], 170 | ], 171 | 172 | /* 173 | |-------------------------------------------------------------------------- 174 | | Audit Queue Configurations 175 | |-------------------------------------------------------------------------- 176 | | 177 | | Available audit queue configurations. 178 | | 179 | */ 180 | 181 | 'queue' => [ 182 | 'enable' => false, 183 | 'connection' => 'sync', 184 | 'queue' => 'default', 185 | 'delay' => 0, 186 | ], 187 | 188 | /* 189 | |-------------------------------------------------------------------------- 190 | | Audit Console 191 | |-------------------------------------------------------------------------- 192 | | 193 | | Whether console events should be audited (eg. php artisan db:seed). 194 | | 195 | */ 196 | 197 | 'console' => false, 198 | ]; 199 | -------------------------------------------------------------------------------- /database/migrations/audits.stub: -------------------------------------------------------------------------------- 1 | create($table, function (Blueprint $table) { 18 | 19 | $morphPrefix = config('audit.user.morph_prefix', 'user'); 20 | 21 | $table->bigIncrements('id'); 22 | $table->string($morphPrefix . '_type')->nullable(); 23 | $table->unsignedBigInteger($morphPrefix . '_id')->nullable(); 24 | $table->string('event'); 25 | $table->morphs('auditable'); 26 | $table->text('old_values')->nullable(); 27 | $table->text('new_values')->nullable(); 28 | $table->text('url')->nullable(); 29 | $table->ipAddress('ip_address')->nullable(); 30 | $table->string('user_agent', 1023)->nullable(); 31 | $table->string('tags')->nullable(); 32 | $table->timestamps(); 33 | 34 | $table->index([$morphPrefix . '_id', $morphPrefix . '_type']); 35 | }); 36 | } 37 | 38 | /** 39 | * Reverse the migrations. 40 | */ 41 | public function down(): void 42 | { 43 | $connection = config('audit.drivers.database.connection', config('database.default')); 44 | $table = config('audit.drivers.database.table', 'audits'); 45 | 46 | Schema::connection($connection)->drop($table); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/Audit.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected $data = []; 22 | 23 | /** 24 | * The Audit attributes that belong to the metadata. 25 | * 26 | * @var array 27 | */ 28 | protected $metadata = []; 29 | 30 | /** 31 | * The Auditable attributes that were modified. 32 | * 33 | * @var array 34 | */ 35 | protected $modified = []; 36 | 37 | /** 38 | * {@inheritdoc} 39 | */ 40 | public function auditable() 41 | { 42 | return $this->morphTo(); 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function user() 49 | { 50 | $morphPrefix = Config::get('audit.user.morph_prefix', 'user'); 51 | 52 | return $this->morphTo(__FUNCTION__, $morphPrefix.'_type', $morphPrefix.'_id'); 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function getConnectionName() 59 | { 60 | return Config::get('audit.drivers.database.connection'); 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | public function getTable(): string 67 | { 68 | return Config::get('audit.drivers.database.table', parent::getTable()); 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function resolveData(): array 75 | { 76 | $morphPrefix = Config::get('audit.user.morph_prefix', 'user'); 77 | 78 | // Metadata 79 | $this->data = [ 80 | 'audit_id' => $this->getKey(), 81 | 'audit_event' => $this->event, 82 | 'audit_tags' => $this->tags, 83 | 'audit_created_at' => $this->serializeDate($this->{$this->getCreatedAtColumn()}), 84 | 'audit_updated_at' => $this->serializeDate($this->{$this->getUpdatedAtColumn()}), 85 | 'user_id' => $this->getAttribute($morphPrefix.'_id'), 86 | 'user_type' => $this->getAttribute($morphPrefix.'_type'), 87 | ]; 88 | 89 | // add resolvers data to metadata 90 | $resolverData = []; 91 | foreach (array_keys(Config::get('audit.resolvers', [])) as $name) { 92 | $resolverData['audit_'.$name] = $this->$name; 93 | } 94 | $this->data = array_merge($this->data, $resolverData); 95 | 96 | if ($this->user) { 97 | foreach ($this->user->getArrayableAttributes() as $attribute => $value) { 98 | $this->data['user_'.$attribute] = $value; 99 | } 100 | } 101 | 102 | $this->metadata = array_keys($this->data); 103 | 104 | // Modified Auditable attributes 105 | foreach ($this->new_values ?? [] as $key => $value) { 106 | $this->data['new_'.$key] = $value; 107 | } 108 | 109 | foreach ($this->old_values ?? [] as $key => $value) { 110 | $this->data['old_'.$key] = $value; 111 | } 112 | 113 | $this->modified = array_diff_key(array_keys($this->data), $this->metadata); 114 | 115 | return $this->data; 116 | } 117 | 118 | /** 119 | * Get the formatted value of an Eloquent model. 120 | * 121 | * @param mixed $value 122 | * @return mixed 123 | */ 124 | protected function getFormattedValue(Model $model, string $key, $value) 125 | { 126 | // Apply defined get mutator 127 | if ($model->hasGetMutator($key)) { 128 | return $model->mutateAttribute($key, $value); 129 | } 130 | // hasAttributeMutator since 8.x 131 | // @phpstan-ignore function.alreadyNarrowedType 132 | if (method_exists($model, 'hasAttributeMutator') && $model->hasAttributeMutator($key)) { 133 | return $model->mutateAttributeMarkedAttribute($key, $value); 134 | } 135 | 136 | if (array_key_exists( 137 | $key, 138 | $model->getCasts() 139 | ) && $model->getCasts()[$key] == 'Illuminate\Database\Eloquent\Casts\AsArrayObject') { 140 | $arrayObject = new \Illuminate\Database\Eloquent\Casts\ArrayObject(json_decode($value, true) ?: []); 141 | 142 | return $arrayObject; 143 | } 144 | 145 | // Cast to native PHP type 146 | if ($model->hasCast($key)) { 147 | if ($model->getCastType($key) == 'datetime') { 148 | $value = $this->castDatetimeUTC($model, $value); 149 | } 150 | 151 | unset($model->classCastCache[$key]); 152 | 153 | return $model->castAttribute($key, $value); 154 | } 155 | 156 | // Honour DateTime attribute 157 | if ($value !== null && in_array($key, $model->getDates(), true)) { 158 | return $model->asDateTime($this->castDatetimeUTC($model, $value)); 159 | } 160 | 161 | return $value; 162 | } 163 | 164 | /** 165 | * @param Model $model 166 | * @param mixed $value 167 | * @return mixed 168 | */ 169 | private function castDatetimeUTC($model, $value) 170 | { 171 | if (! is_string($value)) { 172 | return $value; 173 | } 174 | 175 | if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value)) { 176 | $date = Carbon::createFromFormat('Y-m-d', $value, Date::now('UTC')->getTimezone()); 177 | 178 | if (! $date) { 179 | return $value; 180 | } 181 | 182 | return Date::instance($date->startOfDay()); 183 | } 184 | 185 | if (preg_match('/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/', $value)) { 186 | $date = Carbon::createFromFormat('Y-m-d H:i:s', $value, Date::now('UTC')->getTimezone()); 187 | 188 | if (! $date) { 189 | return $value; 190 | } 191 | 192 | return Date::instance($date); 193 | } 194 | 195 | try { 196 | return Date::createFromFormat($model->getDateFormat(), $value, Date::now('UTC')->getTimezone()); 197 | } catch (InvalidArgumentException $e) { 198 | return $value; 199 | } 200 | } 201 | 202 | /** 203 | * {@inheritdoc} 204 | */ 205 | public function getDataValue(string $key) 206 | { 207 | if (! array_key_exists($key, $this->data)) { 208 | return; 209 | } 210 | 211 | $value = $this->data[$key]; 212 | 213 | // User value 214 | if ($this->user && Str::startsWith($key, 'user_')) { 215 | return $this->getFormattedValue($this->user, substr($key, 5), $value); 216 | } 217 | 218 | // Auditable value 219 | if ($this->auditable && Str::startsWith($key, ['new_', 'old_'])) { 220 | $attribute = substr($key, 4); 221 | 222 | return $this->getFormattedValue( 223 | $this->auditable, 224 | $attribute, 225 | $this->decodeAttributeValue($this->auditable, $attribute, $value) 226 | ); 227 | } 228 | 229 | return $value; 230 | } 231 | 232 | /** 233 | * Decode attribute value. 234 | * 235 | * @param mixed $value 236 | * @return mixed 237 | */ 238 | protected function decodeAttributeValue(Contracts\Auditable $auditable, string $attribute, $value) 239 | { 240 | $attributeModifiers = $auditable->getAttributeModifiers(); 241 | 242 | if (! array_key_exists($attribute, $attributeModifiers)) { 243 | return $value; 244 | } 245 | 246 | $attributeDecoder = $attributeModifiers[$attribute]; 247 | 248 | if (is_subclass_of($attributeDecoder, AttributeEncoder::class)) { 249 | return call_user_func([$attributeDecoder, 'decode'], $value); 250 | } 251 | 252 | return $value; 253 | } 254 | 255 | /** 256 | * {@inheritdoc} 257 | */ 258 | public function getMetadata(bool $json = false, int $options = 0, int $depth = 512) 259 | { 260 | if (empty($this->data)) { 261 | $this->resolveData(); 262 | } 263 | 264 | $metadata = []; 265 | 266 | foreach ($this->metadata as $key) { 267 | $value = $this->getDataValue($key); 268 | $metadata[$key] = $value; 269 | 270 | if ($value instanceof DateTimeInterface) { 271 | $metadata[$key] = ! is_null($this->auditable) ? $this->auditable->serializeDate($value) : $this->serializeDate($value); 272 | } 273 | } 274 | 275 | if (! $json) { 276 | return $metadata; 277 | } 278 | 279 | return json_encode($metadata, $options, $depth) ?: '{}'; 280 | } 281 | 282 | /** 283 | * {@inheritdoc} 284 | */ 285 | public function getModified(bool $json = false, int $options = 0, int $depth = 512) 286 | { 287 | if (empty($this->data)) { 288 | $this->resolveData(); 289 | } 290 | 291 | $modified = []; 292 | 293 | foreach ($this->modified as $key) { 294 | $attribute = substr($key, 4); 295 | $state = substr($key, 0, 3); 296 | 297 | $value = $this->getDataValue($key); 298 | $modified[$attribute][$state] = $value; 299 | 300 | if ($value instanceof DateTimeInterface) { 301 | $modified[$attribute][$state] = ! is_null($this->auditable) ? $this->auditable->serializeDate($value) : $this->serializeDate($value); 302 | } 303 | } 304 | 305 | if (! $json) { 306 | return $modified; 307 | } 308 | 309 | return json_encode($modified, $options, $depth) ?: '{}'; 310 | } 311 | 312 | /** 313 | * Get the Audit tags as an array. 314 | * 315 | * @return array 316 | */ 317 | public function getTags(): array 318 | { 319 | return preg_split('/,/', $this->tags, -1, PREG_SPLIT_NO_EMPTY) ?: []; 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /src/Auditable.php: -------------------------------------------------------------------------------- 1 | morphMany( 91 | Config::get('audit.implementation', Models\Audit::class), 92 | 'auditable' 93 | ); 94 | } 95 | 96 | /** 97 | * Resolve the Auditable attributes to exclude from the Audit. 98 | * 99 | * @return void 100 | */ 101 | protected function resolveAuditExclusions() 102 | { 103 | $this->excludedAttributes = $this->getAuditExclude(); 104 | 105 | // When in strict mode, hidden and non visible attributes are excluded 106 | if ($this->getAuditStrict()) { 107 | // Hidden attributes 108 | $this->excludedAttributes = array_merge($this->excludedAttributes, $this->hidden); 109 | 110 | // Non visible attributes 111 | if ($this->visible) { 112 | $invisible = array_diff(array_keys($this->attributes), $this->visible); 113 | 114 | $this->excludedAttributes = array_merge($this->excludedAttributes, $invisible); 115 | } 116 | } 117 | 118 | // Exclude Timestamps 119 | if (! $this->getAuditTimestamps()) { 120 | if ($this->getCreatedAtColumn()) { 121 | $this->excludedAttributes[] = $this->getCreatedAtColumn(); 122 | } 123 | if ($this->getUpdatedAtColumn()) { 124 | $this->excludedAttributes[] = $this->getUpdatedAtColumn(); 125 | } 126 | if (in_array(SoftDeletes::class, class_uses_recursive(get_class($this)))) { 127 | $this->excludedAttributes[] = $this->getDeletedAtColumn(); 128 | } 129 | } 130 | 131 | // Valid attributes are all those that made it out of the exclusion array 132 | $attributes = Arr::except($this->attributes, $this->excludedAttributes); 133 | 134 | foreach ($attributes as $attribute => $value) { 135 | // Apart from null, non scalar values will be excluded 136 | if ( 137 | (is_array($value) && ! Config::get('audit.allowed_array_values', false)) || 138 | (is_object($value) && 139 | ! method_exists($value, '__toString') && 140 | ! ($value instanceof \UnitEnum)) 141 | ) { 142 | $this->excludedAttributes[] = $attribute; 143 | } 144 | } 145 | } 146 | 147 | public function getAuditExclude(): array 148 | { 149 | return $this->auditExclude ?? Config::get('audit.exclude', []); 150 | } 151 | 152 | public function getAuditInclude(): array 153 | { 154 | return $this->auditInclude ?? []; 155 | } 156 | 157 | /** 158 | * Get the old/new attributes of a retrieved event. 159 | */ 160 | protected function getRetrievedEventAttributes(): array 161 | { 162 | // This is a read event with no attribute changes, 163 | // only metadata will be stored in the Audit 164 | 165 | return [ 166 | [], 167 | [], 168 | ]; 169 | } 170 | 171 | /** 172 | * Get the old/new attributes of a created event. 173 | */ 174 | protected function getCreatedEventAttributes(): array 175 | { 176 | $new = []; 177 | 178 | foreach ($this->attributes as $attribute => $value) { 179 | if ($this->isAttributeAuditable($attribute)) { 180 | $new[$attribute] = $value; 181 | } 182 | } 183 | 184 | return [ 185 | [], 186 | $new, 187 | ]; 188 | } 189 | 190 | protected function getCustomEventAttributes(): array 191 | { 192 | return [ 193 | $this->auditCustomOld, 194 | $this->auditCustomNew, 195 | ]; 196 | } 197 | 198 | /** 199 | * Get the old/new attributes of an updated event. 200 | */ 201 | protected function getUpdatedEventAttributes(): array 202 | { 203 | $old = []; 204 | $new = []; 205 | 206 | foreach ($this->getDirty() as $attribute => $value) { 207 | if ($this->isAttributeAuditable($attribute)) { 208 | $old[$attribute] = Arr::get($this->original, $attribute); 209 | $new[$attribute] = Arr::get($this->attributes, $attribute); 210 | } 211 | } 212 | 213 | return [ 214 | $old, 215 | $new, 216 | ]; 217 | } 218 | 219 | /** 220 | * Get the old/new attributes of a deleted event. 221 | */ 222 | protected function getDeletedEventAttributes(): array 223 | { 224 | $old = []; 225 | 226 | foreach ($this->attributes as $attribute => $value) { 227 | if ($this->isAttributeAuditable($attribute)) { 228 | $old[$attribute] = $value; 229 | } 230 | } 231 | 232 | return [ 233 | $old, 234 | [], 235 | ]; 236 | } 237 | 238 | /** 239 | * Get the old/new attributes of a restored event. 240 | */ 241 | protected function getRestoredEventAttributes(): array 242 | { 243 | // A restored event is just a deleted event in reverse 244 | return array_reverse($this->getDeletedEventAttributes()); 245 | } 246 | 247 | /** 248 | * {@inheritdoc} 249 | */ 250 | public function readyForAuditing(): bool 251 | { 252 | if (static::$auditingDisabled || Models\Audit::$auditingGloballyDisabled) { 253 | return false; 254 | } 255 | 256 | if ($this->isCustomEvent) { 257 | return true; 258 | } 259 | 260 | return $this->isEventAuditable($this->auditEvent); 261 | } 262 | 263 | /** 264 | * Modify attribute value. 265 | * 266 | * @param mixed $value 267 | * @return mixed 268 | * 269 | * @throws AuditingException 270 | */ 271 | protected function modifyAttributeValue(string $attribute, $value) 272 | { 273 | $attributeModifiers = $this->getAttributeModifiers(); 274 | 275 | if (! array_key_exists($attribute, $attributeModifiers)) { 276 | return $value; 277 | } 278 | 279 | $attributeModifier = $attributeModifiers[$attribute]; 280 | 281 | if (is_subclass_of($attributeModifier, AttributeRedactor::class)) { 282 | return call_user_func([$attributeModifier, 'redact'], $value); 283 | } 284 | 285 | if (is_subclass_of($attributeModifier, AttributeEncoder::class)) { 286 | return call_user_func([$attributeModifier, 'encode'], $value); 287 | } 288 | 289 | throw new AuditingException(sprintf('Invalid AttributeModifier implementation: %s', $attributeModifier)); 290 | } 291 | 292 | /** 293 | * {@inheritdoc} 294 | */ 295 | public function toAudit(): array 296 | { 297 | if (! $this->readyForAuditing()) { 298 | throw new AuditingException('A valid audit event has not been set'); 299 | } 300 | 301 | $attributeGetter = $this->resolveAttributeGetter($this->auditEvent); 302 | 303 | if (! method_exists($this, $attributeGetter)) { 304 | throw new AuditingException(sprintf( 305 | 'Unable to handle "%s" event, %s() method missing', 306 | $this->auditEvent, 307 | $attributeGetter 308 | )); 309 | } 310 | 311 | $this->resolveAuditExclusions(); 312 | 313 | [$old, $new] = $this->$attributeGetter(); 314 | 315 | if ($this->getAttributeModifiers() && ! $this->isCustomEvent) { 316 | foreach ($old as $attribute => $value) { 317 | $old[$attribute] = $this->modifyAttributeValue($attribute, $value); 318 | } 319 | 320 | foreach ($new as $attribute => $value) { 321 | $new[$attribute] = $this->modifyAttributeValue($attribute, $value); 322 | } 323 | } 324 | 325 | $morphPrefix = Config::get('audit.user.morph_prefix', 'user'); 326 | 327 | $tags = implode(',', $this->generateTags()); 328 | 329 | $user = $this->resolveUser(); 330 | 331 | return $this->transformAudit(array_merge([ 332 | 'old_values' => $old, 333 | 'new_values' => $new, 334 | 'event' => $this->auditEvent, 335 | 'auditable_id' => $this->getKey(), 336 | 'auditable_type' => $this->getMorphClass(), 337 | $morphPrefix.'_id' => $user ? $user->getAuthIdentifier() : null, 338 | $morphPrefix.'_type' => $user ? $user->getMorphClass() : null, 339 | 'tags' => empty($tags) ? null : $tags, 340 | ], $this->runResolvers())); 341 | } 342 | 343 | /** 344 | * {@inheritdoc} 345 | */ 346 | public function transformAudit(array $data): array 347 | { 348 | return $data; 349 | } 350 | 351 | /** 352 | * Resolve the User. 353 | * 354 | * @return mixed|null 355 | * 356 | * @throws AuditingException 357 | */ 358 | protected function resolveUser() 359 | { 360 | if (! empty($this->preloadedResolverData['user'] ?? null)) { 361 | return $this->preloadedResolverData['user']; 362 | } 363 | 364 | $userResolver = Config::get('audit.user.resolver'); 365 | 366 | if (is_subclass_of($userResolver, \OwenIt\Auditing\Contracts\UserResolver::class)) { 367 | return call_user_func([$userResolver, 'resolve'], $this); 368 | } 369 | 370 | throw new AuditingException('Invalid UserResolver implementation'); 371 | } 372 | 373 | protected function runResolvers(): array 374 | { 375 | $resolved = []; 376 | $resolvers = Config::get('audit.resolvers', []); 377 | if (empty($resolvers) && Config::has('audit.resolver')) { 378 | throw new AuditingException( 379 | 'The config file audit.php is not updated. Please see https://laravel-auditing.com/guide/upgrading.html' 380 | ); 381 | } 382 | 383 | foreach ($resolvers as $name => $implementation) { 384 | if (empty($implementation)) { 385 | continue; 386 | } 387 | 388 | if (! is_subclass_of($implementation, Resolver::class)) { 389 | throw new AuditingException('Invalid Resolver implementation for: '.$name); 390 | } 391 | $resolved[$name] = call_user_func([$implementation, 'resolve'], $this); 392 | } 393 | 394 | return $resolved; 395 | } 396 | 397 | public function preloadResolverData() 398 | { 399 | $this->preloadedResolverData = $this->runResolvers(); 400 | 401 | $user = $this->resolveUser(); 402 | if (! empty($user)) { 403 | $this->preloadedResolverData['user'] = $user; 404 | } 405 | 406 | return $this; 407 | } 408 | 409 | /** 410 | * Determine if an attribute is eligible for auditing. 411 | */ 412 | protected function isAttributeAuditable(string $attribute): bool 413 | { 414 | // The attribute should not be audited 415 | if (in_array($attribute, $this->excludedAttributes, true)) { 416 | return false; 417 | } 418 | 419 | // The attribute is auditable when explicitly 420 | // listed or when the include array is empty 421 | $include = $this->getAuditInclude(); 422 | 423 | return empty($include) || in_array($attribute, $include, true); 424 | } 425 | 426 | /** 427 | * Determine whether an event is auditable. 428 | * 429 | * @param string $event 430 | */ 431 | protected function isEventAuditable($event): bool 432 | { 433 | return is_string($this->resolveAttributeGetter($event)); 434 | } 435 | 436 | /** 437 | * Attribute getter method resolver. 438 | * 439 | * @param string $event 440 | * @return string|null 441 | */ 442 | protected function resolveAttributeGetter($event) 443 | { 444 | if (empty($event)) { 445 | return; 446 | } 447 | 448 | if ($this->isCustomEvent) { 449 | return 'getCustomEventAttributes'; 450 | } 451 | 452 | foreach ($this->getAuditEvents() as $key => $value) { 453 | $auditableEvent = is_int($key) ? $value : $key; 454 | 455 | $auditableEventRegex = sprintf('/%s/', preg_replace('/\*+/', '.*', $auditableEvent)); 456 | 457 | if (preg_match($auditableEventRegex, $event)) { 458 | return is_int($key) ? sprintf('get%sEventAttributes', ucfirst($event)) : $value; 459 | } 460 | } 461 | } 462 | 463 | /** 464 | * {@inheritdoc} 465 | */ 466 | public function setAuditEvent(string $event): Contracts\Auditable 467 | { 468 | $this->auditEvent = $this->isEventAuditable($event) ? $event : null; 469 | 470 | return $this; 471 | } 472 | 473 | /** 474 | * {@inheritdoc} 475 | */ 476 | public function getAuditEvent() 477 | { 478 | return $this->auditEvent; 479 | } 480 | 481 | /** 482 | * {@inheritdoc} 483 | */ 484 | public function getAuditEvents(): array 485 | { 486 | return $this->auditEvents ?? Config::get('audit.events', [ 487 | 'created', 488 | 'updated', 489 | 'deleted', 490 | 'restored', 491 | ]); 492 | } 493 | 494 | /** 495 | * Is Auditing disabled. 496 | */ 497 | public static function isAuditingDisabled(): bool 498 | { 499 | return static::$auditingDisabled || Models\Audit::$auditingGloballyDisabled; 500 | } 501 | 502 | /** 503 | * Disable Auditing. 504 | * 505 | * @return void 506 | */ 507 | public static function disableAuditing() 508 | { 509 | static::$auditingDisabled = true; 510 | } 511 | 512 | /** 513 | * Enable Auditing. 514 | * 515 | * @return void 516 | */ 517 | public static function enableAuditing() 518 | { 519 | static::$auditingDisabled = false; 520 | } 521 | 522 | /** 523 | * Execute a callback while auditing is disabled. 524 | * 525 | * 526 | * @return mixed 527 | */ 528 | public static function withoutAuditing(callable $callback, bool $globally = false) 529 | { 530 | $auditingDisabled = static::$auditingDisabled; 531 | 532 | static::disableAuditing(); 533 | Models\Audit::$auditingGloballyDisabled = $globally; 534 | 535 | try { 536 | return $callback(); 537 | } finally { 538 | Models\Audit::$auditingGloballyDisabled = false; 539 | static::$auditingDisabled = $auditingDisabled; 540 | } 541 | } 542 | 543 | /** 544 | * Determine whether auditing is enabled. 545 | */ 546 | public static function isAuditingEnabled(): bool 547 | { 548 | if (App::runningInConsole()) { 549 | return Config::get('audit.enabled', true) && Config::get('audit.console', false); 550 | } 551 | 552 | return Config::get('audit.enabled', true); 553 | } 554 | 555 | /** 556 | * {@inheritdoc} 557 | */ 558 | public function getAuditStrict(): bool 559 | { 560 | return $this->auditStrict ?? Config::get('audit.strict', false); 561 | } 562 | 563 | /** 564 | * {@inheritdoc} 565 | */ 566 | public function getAuditTimestamps(): bool 567 | { 568 | return $this->auditTimestamps ?? Config::get('audit.timestamps', false); 569 | } 570 | 571 | /** 572 | * {@inheritdoc} 573 | */ 574 | public function getAuditDriver() 575 | { 576 | return $this->auditDriver ?? Config::get('audit.driver', 'database'); 577 | } 578 | 579 | /** 580 | * {@inheritdoc} 581 | */ 582 | public function getAuditThreshold(): int 583 | { 584 | return $this->auditThreshold ?? Config::get('audit.threshold', 0); 585 | } 586 | 587 | /** 588 | * {@inheritdoc} 589 | */ 590 | public function getAttributeModifiers(): array 591 | { 592 | return $this->attributeModifiers ?? []; 593 | } 594 | 595 | /** 596 | * {@inheritdoc} 597 | */ 598 | public function generateTags(): array 599 | { 600 | return []; 601 | } 602 | 603 | /** 604 | * {@inheritdoc} 605 | */ 606 | public function transitionTo(Contracts\Audit $audit, bool $old = false): Contracts\Auditable 607 | { 608 | // The Audit must be for an Auditable model of this type 609 | if ($this->getMorphClass() !== $audit->auditable_type) { 610 | throw new AuditableTransitionException(sprintf( 611 | 'Expected Auditable type %s, got %s instead', 612 | $this->getMorphClass(), 613 | $audit->auditable_type 614 | )); 615 | } 616 | 617 | // The Audit must be for this specific Auditable model 618 | if ($this->getKey() !== $audit->auditable_id) { 619 | throw new AuditableTransitionException(sprintf( 620 | 'Expected Auditable id (%s)%s, got (%s)%s instead', 621 | gettype($this->getKey()), 622 | $this->getKey(), 623 | gettype($audit->auditable_id), 624 | $audit->auditable_id 625 | )); 626 | } 627 | 628 | // Redacted data should not be used when transitioning states 629 | foreach ($this->getAttributeModifiers() as $attribute => $modifier) { 630 | if (is_subclass_of($modifier, AttributeRedactor::class)) { 631 | throw new AuditableTransitionException('Cannot transition states when an AttributeRedactor is set'); 632 | } 633 | } 634 | 635 | // The attribute compatibility between the Audit and the Auditable model must be met 636 | $modified = $audit->getModified(); 637 | 638 | if ($incompatibilities = array_diff_key($modified, $this->getAttributes())) { 639 | throw new AuditableTransitionException(sprintf( 640 | 'Incompatibility between [%s:%s] and [%s:%s]', 641 | $this->getMorphClass(), 642 | $this->getKey(), 643 | get_class($audit), 644 | $audit->getKey() 645 | ), array_keys($incompatibilities)); 646 | } 647 | 648 | $key = $old ? 'old' : 'new'; 649 | 650 | foreach ($modified as $attribute => $value) { 651 | if (array_key_exists($key, $value)) { 652 | $this->setAttribute($attribute, $value[$key]); 653 | } 654 | } 655 | 656 | return $this; 657 | } 658 | 659 | /* 660 | |-------------------------------------------------------------------------- 661 | | Pivot help methods 662 | |-------------------------------------------------------------------------- 663 | | 664 | | Methods for auditing pivot actions 665 | | 666 | */ 667 | 668 | /** 669 | * @param mixed $id 670 | * @param bool $touch 671 | * @param array $columns 672 | * @param \Closure|null $callback 673 | * @return void 674 | * 675 | * @throws AuditingException 676 | */ 677 | public function auditAttach(string $relationName, $id, array $attributes = [], $touch = true, $columns = ['*'], $callback = null) 678 | { 679 | $this->validateRelationshipMethodExistence($relationName, 'attach'); 680 | 681 | $relationCall = $this->{$relationName}(); 682 | 683 | if ($callback instanceof \Closure) { 684 | $this->applyClosureToRelationship($relationCall, $callback); 685 | } 686 | 687 | $old = $relationCall->get($columns); 688 | $relationCall->attach($id, $attributes, $touch); 689 | $new = $relationCall->get($columns); 690 | 691 | $this->dispatchRelationAuditEvent($relationName, 'attach', $old, $new); 692 | } 693 | 694 | /** 695 | * @param mixed $ids 696 | * @param bool $touch 697 | * @param array $columns 698 | * @param \Closure|null $callback 699 | * @return int 700 | * 701 | * @throws AuditingException 702 | */ 703 | public function auditDetach(string $relationName, $ids = null, $touch = true, $columns = ['*'], $callback = null) 704 | { 705 | $this->validateRelationshipMethodExistence($relationName, 'detach'); 706 | 707 | $relationCall = $this->{$relationName}(); 708 | 709 | if ($callback instanceof \Closure) { 710 | $this->applyClosureToRelationship($relationCall, $callback); 711 | } 712 | 713 | $old = $relationCall->get($columns); 714 | 715 | $pivotClass = $relationCall->getPivotClass(); 716 | 717 | if ($pivotClass !== Pivot::class && is_a($pivotClass, ContractsAuditable::class, true)) { 718 | $results = $pivotClass::withoutAuditing(function () use ($relationCall, $ids, $touch) { 719 | return $relationCall->detach($ids, $touch); 720 | }); 721 | } else { 722 | $results = $relationCall->detach($ids, $touch); 723 | } 724 | 725 | $new = $relationCall->get($columns); 726 | 727 | $this->dispatchRelationAuditEvent($relationName, 'detach', $old, $new); 728 | 729 | return empty($results) ? 0 : $results; 730 | } 731 | 732 | /** 733 | * @param Collection|Model|array $ids 734 | * @param bool $detaching 735 | * @param array $columns 736 | * @param \Closure|null $callback 737 | * @return array 738 | * 739 | * @throws AuditingException 740 | */ 741 | public function auditSync(string $relationName, $ids, $detaching = true, $columns = ['*'], $callback = null) 742 | { 743 | $this->validateRelationshipMethodExistence($relationName, 'sync'); 744 | 745 | $relationCall = $this->{$relationName}(); 746 | 747 | if ($callback instanceof \Closure) { 748 | $this->applyClosureToRelationship($relationCall, $callback); 749 | } 750 | 751 | $old = $relationCall->get($columns); 752 | 753 | $pivotClass = $relationCall->getPivotClass(); 754 | 755 | if ($pivotClass !== Pivot::class && is_a($pivotClass, ContractsAuditable::class, true)) { 756 | $changes = $pivotClass::withoutAuditing(function () use ($relationCall, $ids, $detaching) { 757 | return $relationCall->sync($ids, $detaching); 758 | }); 759 | } else { 760 | $changes = $relationCall->sync($ids, $detaching); 761 | } 762 | 763 | if (collect($changes)->flatten()->isEmpty()) { 764 | $old = $new = collect([]); 765 | } else { 766 | $new = $relationCall->get($columns); 767 | } 768 | 769 | $this->dispatchRelationAuditEvent($relationName, 'sync', $old, $new); 770 | 771 | return $changes; 772 | } 773 | 774 | /** 775 | * @param Collection|Model|array $ids 776 | * @param array $columns 777 | * @param \Closure|null $callback 778 | * @return array 779 | * 780 | * @throws AuditingException 781 | */ 782 | public function auditSyncWithoutDetaching(string $relationName, $ids, $columns = ['*'], $callback = null) 783 | { 784 | $this->validateRelationshipMethodExistence($relationName, 'syncWithoutDetaching'); 785 | 786 | return $this->auditSync($relationName, $ids, false, $columns, $callback); 787 | } 788 | 789 | /** 790 | * @param Collection|Model|array $ids 791 | * @param array $columns 792 | * @param \Closure|null $callback 793 | * @return array 794 | */ 795 | public function auditSyncWithPivotValues(string $relationName, $ids, array $values, bool $detaching = true, $columns = ['*'], $callback = null) 796 | { 797 | $this->validateRelationshipMethodExistence($relationName, 'syncWithPivotValues'); 798 | 799 | if ($ids instanceof Model) { 800 | $ids = $ids->getKey(); 801 | } elseif ($ids instanceof \Illuminate\Database\Eloquent\Collection) { 802 | $ids = $ids->isEmpty() ? [] : $ids->pluck($ids->first()->getKeyName())->toArray(); 803 | } elseif ($ids instanceof Collection) { 804 | $ids = $ids->toArray(); 805 | } 806 | 807 | return $this->auditSync($relationName, collect(Arr::wrap($ids))->mapWithKeys(function ($id) use ($values) { 808 | return [$id => $values]; 809 | }), $detaching, $columns, $callback); 810 | } 811 | 812 | /** 813 | * @param string $relationName 814 | * @param string $event 815 | * @param Collection $old 816 | * @param Collection $new 817 | * @return void 818 | */ 819 | private function dispatchRelationAuditEvent($relationName, $event, $old, $new) 820 | { 821 | $this->auditCustomOld[$relationName] = $old->diff($new)->toArray(); 822 | $this->auditCustomNew[$relationName] = $new->diff($old)->toArray(); 823 | 824 | if ( 825 | empty($this->auditCustomOld[$relationName]) && 826 | empty($this->auditCustomNew[$relationName]) 827 | ) { 828 | $this->auditCustomOld = $this->auditCustomNew = []; 829 | } 830 | 831 | $this->auditEvent = $event; 832 | $this->isCustomEvent = true; 833 | Event::dispatch(new AuditCustom($this)); 834 | $this->auditCustomOld = $this->auditCustomNew = []; 835 | $this->isCustomEvent = false; 836 | } 837 | 838 | private function validateRelationshipMethodExistence(string $relationName, string $methodName): void 839 | { 840 | if (! method_exists($this, $relationName) || ! method_exists($this->{$relationName}(), $methodName)) { 841 | throw new AuditingException("Relationship $relationName was not found or does not support method $methodName"); 842 | } 843 | } 844 | 845 | private function applyClosureToRelationship(BelongsToMany $relation, \Closure $closure): void 846 | { 847 | try { 848 | $closure($relation); 849 | } catch (\Throwable $exception) { 850 | throw new AuditingException("Invalid Closure for {$relation->getRelationName()} Relationship"); 851 | } 852 | } 853 | } 854 | -------------------------------------------------------------------------------- /src/AuditableObserver.php: -------------------------------------------------------------------------------- 1 | dispatchAudit($model->setAuditEvent('retrieved')); 28 | } 29 | 30 | /** 31 | * Handle the created event. 32 | * 33 | * @return void 34 | */ 35 | public function created(Auditable $model) 36 | { 37 | $this->dispatchAudit($model->setAuditEvent('created')); 38 | } 39 | 40 | /** 41 | * Handle the updated event. 42 | * 43 | * @return void 44 | */ 45 | public function updated(Auditable $model) 46 | { 47 | // Ignore the updated event when restoring 48 | if (! static::$restoring) { 49 | $this->dispatchAudit($model->setAuditEvent('updated')); 50 | } 51 | } 52 | 53 | /** 54 | * Handle the deleted event. 55 | * 56 | * @return void 57 | */ 58 | public function deleted(Auditable $model) 59 | { 60 | $this->dispatchAudit($model->setAuditEvent('deleted')); 61 | } 62 | 63 | /** 64 | * Handle the restoring event. 65 | * 66 | * @return void 67 | */ 68 | public function restoring(Auditable $model) 69 | { 70 | // When restoring a model, an updated event is also fired. 71 | // By keeping track of the main event that took place, 72 | // we avoid creating a second audit with wrong values 73 | static::$restoring = true; 74 | } 75 | 76 | /** 77 | * Handle the restored event. 78 | * 79 | * @return void 80 | */ 81 | public function restored(Auditable $model) 82 | { 83 | $this->dispatchAudit($model->setAuditEvent('restored')); 84 | 85 | // Once the model is restored, we need to put everything back 86 | // as before, in case a legitimate update event is fired 87 | static::$restoring = false; 88 | } 89 | 90 | protected function dispatchAudit(Auditable $model): void 91 | { 92 | if (! $model->readyForAuditing()) { 93 | return; 94 | } 95 | 96 | // @phpstan-ignore method.notFound 97 | $model->preloadResolverData(); 98 | if (! Config::get('audit.queue.enable', false)) { 99 | Auditor::execute($model); 100 | 101 | return; 102 | } 103 | 104 | if (! $this->fireDispatchingAuditEvent($model)) { 105 | return; 106 | } 107 | 108 | // Unload the relations to prevent large amounts of unnecessary data from being serialized. 109 | $model->withoutRelations(); 110 | app()->make('events')->dispatch(new DispatchAudit($model)); 111 | } 112 | 113 | /** 114 | * Fire the Auditing event. 115 | */ 116 | protected function fireDispatchingAuditEvent(Auditable $model): bool 117 | { 118 | return app()->make('events') 119 | ->until(new DispatchingAudit($model)) !== false; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/AuditingServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerPublishing(); 26 | $this->mergeConfigFrom(__DIR__.'/../config/audit.php', 'audit'); 27 | 28 | Event::listen(AuditCustom::class, RecordCustomAudit::class); 29 | Event::listen(DispatchAudit::class, ProcessDispatchAudit::class); 30 | } 31 | 32 | /** 33 | * Register the service provider. 34 | * 35 | * @return void 36 | */ 37 | public function register() 38 | { 39 | $this->commands([ 40 | AuditDriverCommand::class, 41 | AuditResolverCommand::class, 42 | InstallCommand::class, 43 | ]); 44 | 45 | $this->app->singleton(Auditor::class, function ($app) { 46 | return new \OwenIt\Auditing\Auditor($app); 47 | }); 48 | } 49 | 50 | /** 51 | * Register the package's publishable resources. 52 | * 53 | * @return void 54 | */ 55 | private function registerPublishing() 56 | { 57 | if ($this->app->runningInConsole()) { 58 | // Lumen lacks a config_path() helper, so we use base_path() 59 | $this->publishes([ 60 | __DIR__.'/../config/audit.php' => base_path('config/audit.php'), 61 | ], 'config'); 62 | 63 | if (! class_exists('CreateAuditsTable')) { 64 | $this->publishes([ 65 | __DIR__.'/../database/migrations/audits.stub' => database_path( 66 | sprintf('migrations/%s_create_audits_table.php', date('Y_m_d_His')) 67 | ), 68 | ], 'migrations'); 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Auditor.php: -------------------------------------------------------------------------------- 1 | container->make($driver); 35 | } 36 | 37 | throw $exception; 38 | } 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function auditDriver(Auditable $model): AuditDriver 45 | { 46 | $driver = $this->driver($model->getAuditDriver()); 47 | 48 | if (! $driver instanceof AuditDriver) { 49 | throw new AuditingException('The driver must implement the AuditDriver contract'); 50 | } 51 | 52 | return $driver; 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function execute(Auditable $model): void 59 | { 60 | if (! $model->readyForAuditing()) { 61 | return; 62 | } 63 | 64 | $driver = $this->auditDriver($model); 65 | 66 | if (! $this->fireAuditingEvent($model, $driver)) { 67 | return; 68 | } 69 | 70 | // Check if we want to avoid storing empty values 71 | $allowEmpty = Config::get('audit.empty_values'); 72 | $explicitAllowEmpty = in_array($model->getAuditEvent(), Config::get('audit.allowed_empty_values', [])); 73 | 74 | if (! $allowEmpty && ! $explicitAllowEmpty) { 75 | if ( 76 | empty($model->toAudit()['new_values']) && 77 | empty($model->toAudit()['old_values']) 78 | ) { 79 | return; 80 | } 81 | } 82 | 83 | $audit = $driver->audit($model); 84 | if (! $audit) { 85 | return; 86 | } 87 | 88 | $driver->prune($model); 89 | 90 | $this->container->make('events')->dispatch( 91 | new Audited($model, $driver, $audit) 92 | ); 93 | } 94 | 95 | /** 96 | * Create an instance of the Database audit driver. 97 | */ 98 | protected function createDatabaseDriver(): Database 99 | { 100 | return $this->container->make(Database::class); 101 | } 102 | 103 | /** 104 | * Fire the Auditing event. 105 | */ 106 | protected function fireAuditingEvent(Auditable $model, AuditDriver $driver): bool 107 | { 108 | return $this 109 | ->container 110 | ->make('events') 111 | ->until(new Auditing($model, $driver)) !== false; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Console/AuditDriverCommand.php: -------------------------------------------------------------------------------- 1 | info('Add your new resolver to the resolvers array in audit.php config file.'); 43 | 44 | return parent::handle(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | comment('Publishing Auditing Configuration...'); 26 | $this->callSilent('vendor:publish', ['--tag' => 'config']); 27 | 28 | $this->comment('Publishing Auditing Migrations...'); 29 | $this->callSilent('vendor:publish', ['--tag' => 'migrations']); 30 | 31 | $this->registerAuditingServiceProvider(); 32 | 33 | $this->info('Auditing installed successfully.'); 34 | } 35 | 36 | /** 37 | * Register the Auditing service provider in the application configuration file. 38 | * 39 | * @return void 40 | */ 41 | protected function registerAuditingServiceProvider() 42 | { 43 | $namespace = Str::replaceLast('\\', '', app()->getNamespace()); 44 | 45 | $appConfig = file_get_contents(config_path('app.php')); 46 | 47 | if (! $appConfig || Str::contains($appConfig, 'OwenIt\\Auditing\\AuditingServiceProvider::class')) { 48 | return; 49 | } 50 | 51 | file_put_contents(config_path('app.php'), str_replace( 52 | "{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL, 53 | "{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL." OwenIt\Auditing\AuditingServiceProvider::class,".PHP_EOL, 54 | $appConfig 55 | )); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Contracts/AttributeEncoder.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | public function resolveData(): array; 44 | 45 | /** 46 | * Get an Audit data value. 47 | * 48 | * 49 | * @return mixed 50 | */ 51 | public function getDataValue(string $key); 52 | 53 | /** 54 | * Get the Audit metadata. 55 | * 56 | * @param int<1, max> $depth 57 | * @return array|string 58 | */ 59 | public function getMetadata(bool $json = false, int $options = 0, int $depth = 512); 60 | 61 | /** 62 | * Get the Auditable modified attributes. 63 | * 64 | * @param int<1, max> $depth 65 | * @return array|string 66 | */ 67 | public function getModified(bool $json = false, int $options = 0, int $depth = 512); 68 | } 69 | -------------------------------------------------------------------------------- /src/Contracts/AuditDriver.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function audits(): MorphMany; 18 | 19 | /** 20 | * Set the Audit event. 21 | */ 22 | public function setAuditEvent(string $event): Auditable; 23 | 24 | /** 25 | * Get the Audit event that is set. 26 | * 27 | * @return string|null 28 | */ 29 | public function getAuditEvent(); 30 | 31 | /** 32 | * Get the events that trigger an Audit. 33 | * 34 | * @return array 35 | */ 36 | public function getAuditEvents(): array; 37 | 38 | /** 39 | * Is the model ready for auditing? 40 | */ 41 | public function readyForAuditing(): bool; 42 | 43 | /** 44 | * Return data for an Audit. 45 | * 46 | * @return array 47 | * 48 | * @throws \OwenIt\Auditing\Exceptions\AuditingException 49 | */ 50 | public function toAudit(): array; 51 | 52 | /** 53 | * Get the (Auditable) attributes included in audit. 54 | * 55 | * @return array 56 | */ 57 | public function getAuditInclude(): array; 58 | 59 | /** 60 | * Get the (Auditable) attributes excluded from audit. 61 | * 62 | * @return array 63 | */ 64 | public function getAuditExclude(): array; 65 | 66 | /** 67 | * Get the strict audit status. 68 | */ 69 | public function getAuditStrict(): bool; 70 | 71 | /** 72 | * Get the audit (Auditable) timestamps status. 73 | */ 74 | public function getAuditTimestamps(): bool; 75 | 76 | /** 77 | * Get the Audit Driver. 78 | * 79 | * @return string|null 80 | */ 81 | public function getAuditDriver(); 82 | 83 | /** 84 | * Get the Audit threshold. 85 | */ 86 | public function getAuditThreshold(): int; 87 | 88 | /** 89 | * Get the Attribute modifiers. 90 | * 91 | * @return array 92 | */ 93 | public function getAttributeModifiers(): array; 94 | 95 | /** 96 | * Transform the data before performing an audit. 97 | * 98 | * @param array $data 99 | * @return array 100 | */ 101 | public function transformAudit(array $data): array; 102 | 103 | /** 104 | * Generate an array with the model tags. 105 | * 106 | * @return array 107 | */ 108 | public function generateTags(): array; 109 | 110 | /** 111 | * Transition to another model state from an Audit. 112 | * 113 | * @throws \OwenIt\Auditing\Exceptions\AuditableTransitionException 114 | */ 115 | public function transitionTo(Audit $audit, bool $old = false): Auditable; 116 | } 117 | -------------------------------------------------------------------------------- /src/Contracts/Auditor.php: -------------------------------------------------------------------------------- 1 | audits()->getModel()), 'create'], $model->toAudit()); 17 | } 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function prune(Auditable $model): bool 23 | { 24 | if (($threshold = $model->getAuditThreshold()) > 0) { 25 | $auditClass = get_class($model->audits()->getModel()); 26 | $auditModel = new $auditClass; 27 | $keyName = $auditModel->getKeyName(); 28 | 29 | return $model->audits() 30 | ->leftJoinSub( 31 | $model->audits()->getQuery()->select($keyName)->limit($threshold)->latest(), 32 | 'audit_threshold', 33 | fn ($join) => $join->on( 34 | $auditModel->getTable().".$keyName", '=', "audit_threshold.$keyName" 35 | ) 36 | ) 37 | ->whereNull("audit_threshold.$keyName") 38 | ->delete() > 0; 39 | } 40 | 41 | return false; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Encoders/Base64Encoder.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | public function __serialize() 25 | { 26 | $values = [ 27 | 'class' => get_class($this->model), 28 | 'model_data' => [ 29 | 'exists' => true, 30 | 'connection' => $this->model->getQueueableConnection(), 31 | ], 32 | ]; 33 | 34 | $customProperties = array_merge([ 35 | 'attributes', 36 | 'original', 37 | 'excludedAttributes', 38 | 'auditEvent', 39 | 'auditExclude', 40 | 'auditCustomOld', 41 | 'auditCustomNew', 42 | 'isCustomEvent', 43 | 'preloadedResolverData', 44 | ], $this->model->auditEventSerializedProperties ?? []); 45 | 46 | $reflection = new ReflectionClass($this->model); 47 | 48 | foreach ($customProperties as $key) { 49 | try { 50 | $values['model_data'][$key] = $this->getModelPropertyValue($reflection, $key); 51 | } catch (\Throwable $e) { 52 | // 53 | } 54 | } 55 | 56 | return $values; 57 | } 58 | 59 | /** 60 | * Restore the model after serialization. 61 | * 62 | * @param array $values 63 | */ 64 | public function __unserialize(array $values): void 65 | { 66 | $model = new $values['class']; 67 | 68 | if (! $model instanceof Auditable) { 69 | return; 70 | } 71 | 72 | $this->model = $model; 73 | $reflection = new ReflectionClass($this->model); 74 | foreach ($values['model_data'] as $key => $value) { 75 | $this->setModelPropertyValue($reflection, $key, $value); 76 | } 77 | } 78 | 79 | /** 80 | * Set the property value for the given property. 81 | * 82 | * @param ReflectionClass $reflection 83 | * @param mixed $value 84 | */ 85 | protected function setModelPropertyValue(ReflectionClass $reflection, string $name, $value): void 86 | { 87 | $property = $reflection->getProperty($name); 88 | 89 | $property->setAccessible(true); 90 | 91 | $property->setValue($this->model, $value); 92 | } 93 | 94 | /** 95 | * Get the property value for the given property. 96 | * 97 | * @param ReflectionClass $reflection 98 | * @return mixed 99 | */ 100 | protected function getModelPropertyValue(ReflectionClass $reflection, string $name) 101 | { 102 | $property = $reflection->getProperty($name); 103 | 104 | $property->setAccessible(true); 105 | 106 | return $property->getValue($this->model); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Events/DispatchingAudit.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected $incompatibilities = []; 15 | 16 | /** 17 | * @param array $incompatibilities 18 | */ 19 | public function __construct(string $message = '', array $incompatibilities = [], int $code = 0, ?Throwable $previous = null) 20 | { 21 | parent::__construct($message, $code, $previous); 22 | 23 | $this->incompatibilities = $incompatibilities; 24 | } 25 | 26 | /** 27 | * Get the attribute incompatibilities. 28 | * 29 | * @return array 30 | */ 31 | public function getIncompatibilities(): array 32 | { 33 | return $this->incompatibilities; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exceptions/AuditingException.php: -------------------------------------------------------------------------------- 1 | model); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Listeners/RecordCustomAudit.php: -------------------------------------------------------------------------------- 1 | model); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Models/Audit.php: -------------------------------------------------------------------------------- 1 | $new_values 12 | * @property array $old_values 13 | * @property Carbon|null $created_at 14 | * @property Carbon|null $updated_at 15 | * @property mixed $user 16 | * @property mixed $auditable. 17 | * @property string|null $auditable_type 18 | * @property string|int|null $auditable_id 19 | */ 20 | class Audit extends Model implements \OwenIt\Auditing\Contracts\Audit 21 | { 22 | use \OwenIt\Auditing\Audit; 23 | 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | protected $guarded = []; 28 | 29 | /** 30 | * Is globally auditing disabled? 31 | * 32 | * @var bool 33 | */ 34 | public static $auditingGloballyDisabled = false; 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | protected $casts = [ 40 | 'old_values' => 'json', 41 | 'new_values' => 'json', 42 | // Note: Please do not add 'auditable_id' in here, as it will break non-integer PK models 43 | ]; 44 | 45 | public function getSerializedDate(\DateTimeInterface $date): string 46 | { 47 | return $this->serializeDate($date); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Redactors/LeftRedactor.php: -------------------------------------------------------------------------------- 1 | $tenth) ? ($total - $tenth) : 1; 17 | 18 | return str_pad(substr($value, $length), $total, '#', STR_PAD_LEFT); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Redactors/RightRedactor.php: -------------------------------------------------------------------------------- 1 | $tenth) ? ($total - $tenth) : 1; 17 | 18 | return str_pad(substr($value, 0, -$length), $total, '#', STR_PAD_RIGHT); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Resolvers/DumpResolver.php: -------------------------------------------------------------------------------- 1 | preloadedResolverData['ip_address'] ?? Request::ip(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Resolvers/UrlResolver.php: -------------------------------------------------------------------------------- 1 | preloadedResolverData['url'] ?? null)) { 15 | return $auditable->preloadedResolverData['url'] ?? ''; 16 | } 17 | 18 | if (App::runningInConsole()) { 19 | return self::resolveCommandLine(); 20 | } 21 | 22 | return Request::fullUrl(); 23 | } 24 | 25 | public static function resolveCommandLine(): string 26 | { 27 | $command = Request::server('argv', null); 28 | if (is_array($command)) { // @phpstan-ignore function.impossibleType 29 | return implode(' ', $command); 30 | } 31 | 32 | return 'console'; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Resolvers/UserAgentResolver.php: -------------------------------------------------------------------------------- 1 | preloadedResolverData['user_agent'] ?? Request::header('User-Agent', ''); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Resolvers/UserResolver.php: -------------------------------------------------------------------------------- 1 | check(); 23 | } catch (\Exception $exception) { 24 | continue; 25 | } 26 | 27 | if ($authenticated === true) { 28 | return Auth::guard($guard)->user(); 29 | } 30 | } 31 | 32 | return null; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /stubs/driver.stub: -------------------------------------------------------------------------------- 1 |