diff --git a/.env.example b/.env.example index 61d2819e..15a3e025 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_ENV=local APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_DEBUG=true APP_URL=https://solidtime.test +AUDITING_ENABLED=true SUPER_ADMINS=admin@example.com diff --git a/app/Extensions/Auditing/Resolvers/CustomIpAddressResolver.php b/app/Extensions/Auditing/Resolvers/CustomIpAddressResolver.php new file mode 100644 index 00000000..09f5f19e --- /dev/null +++ b/app/Extensions/Auditing/Resolvers/CustomIpAddressResolver.php @@ -0,0 +1,33 @@ +preloadedResolverData['ip_address'] ?? Request::ip(); + + if ($ip !== null) { + $ip = self::anonymizeIpAddress($ip); + } + + return $ip; + } +} diff --git a/app/Filament/Resources/AuditResource.php b/app/Filament/Resources/AuditResource.php new file mode 100644 index 00000000..4c8a7b33 --- /dev/null +++ b/app/Filament/Resources/AuditResource.php @@ -0,0 +1,95 @@ +schema([ + Forms\Components\TextInput::make('user_type') + ->maxLength(255), + Forms\Components\TextInput::make('user_id'), + Forms\Components\TextInput::make('event') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('auditable_type') + ->required() + ->maxLength(255), + Forms\Components\TextInput::make('auditable_id') + ->required(), + PrettyJson::make('old_values'), + PrettyJson::make('new_values'), + Forms\Components\Textarea::make('url'), + Forms\Components\TextInput::make('ip_address'), + Forms\Components\TextInput::make('user_agent') + ->maxLength(1023), + Forms\Components\TextInput::make('tags') + ->maxLength(255), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('user.name'), + Tables\Columns\TextColumn::make('event'), + Tables\Columns\TextColumn::make('auditable_type'), + Tables\Columns\TextColumn::make('auditable_id'), + IconColumn::make('was_command') + ->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan ')) + ->boolean(), + Tables\Columns\TextColumn::make('created_at') + ->sortable() + ->dateTime(), + Tables\Columns\TextColumn::make('updated_at') + ->sortable() + ->dateTime(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([ + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAudits::route('/'), + 'create' => Pages\CreateAudit::route('/create'), + 'view' => Pages\ViewAudit::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/AuditResource/Pages/CreateAudit.php b/app/Filament/Resources/AuditResource/Pages/CreateAudit.php new file mode 100644 index 00000000..dd46d79d --- /dev/null +++ b/app/Filament/Resources/AuditResource/Pages/CreateAudit.php @@ -0,0 +1,13 @@ + teamInvitations() * @method static OrganizationFactory factory() */ -class Organization extends JetstreamTeam +class Organization extends JetstreamTeam implements AuditableContract { + use Auditable; use HasFactory; use HasUuids; diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index dd7d8a5a..0e8cec52 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -11,6 +11,8 @@ use Illuminate\Support\Carbon; use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; +use OwenIt\Auditing\Auditable; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @property string $id @@ -23,8 +25,9 @@ * * @method static OrganizationInvitationFactory factory() */ -class OrganizationInvitation extends JetstreamTeamInvitation +class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract { + use Auditable; use HasFactory; use HasUuids; diff --git a/app/Models/Project.php b/app/Models/Project.php index 2f6e2c25..07fd4e1d 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; +use OwenIt\Auditing\Auditable; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @property string $id @@ -36,8 +38,9 @@ * @method Builder visibleByEmployee(User $user) * @method static ProjectFactory factory() */ -class Project extends Model +class Project extends Model implements AuditableContract { + use Auditable; use HasFactory; use HasUuids; diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index cccce192..5ef3be6e 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; +use OwenIt\Auditing\Auditable; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @property string $id @@ -27,8 +29,9 @@ * @method static Builder whereBelongsToOrganization(Organization $organization) * @method static ProjectMemberFactory factory() */ -class ProjectMember extends Model +class ProjectMember extends Model implements AuditableContract { + use Auditable; use HasFactory; use HasUuids; diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 7fc5dc71..61cc71cd 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; +use OwenIt\Auditing\Auditable; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @property string $id @@ -21,8 +23,9 @@ * * @method static TagFactory factory() */ -class Tag extends Model +class Tag extends Model implements AuditableContract { + use Auditable; use HasFactory; use HasUuids; diff --git a/app/Models/Task.php b/app/Models/Task.php index 65c41158..9677931d 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; +use OwenIt\Auditing\Auditable; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @property string $id @@ -30,8 +32,9 @@ * * @method static TaskFactory factory() */ -class Task extends Model +class Task extends Model implements AuditableContract { + use Auditable; use HasFactory; use HasUuids; diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 3d37492e..b052c4c1 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; use Korridor\LaravelComputedAttributes\ComputedAttributes; +use OwenIt\Auditing\Auditable; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @property string $id @@ -43,8 +45,9 @@ * @method Builder hasTag(Tag $tag) * @method static TimeEntryFactory factory() */ -class TimeEntry extends Model +class TimeEntry extends Model implements AuditableContract { + use Auditable; use ComputedAttributes; use HasFactory; use HasUuids; diff --git a/app/Models/User.php b/app/Models/User.php index e01213a5..b60ad93b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,6 +25,8 @@ use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasTeams; use Laravel\Passport\HasApiTokens; +use OwenIt\Auditing\Auditable; +use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** * @property string $id @@ -53,8 +55,9 @@ * @method Builder belongsToOrganization(Organization $organization) * @method Builder active() */ -class User extends Authenticatable implements FilamentUser, MustVerifyEmail +class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail { + use Auditable; use HasApiTokens; use HasFactory; use HasProfilePhoto; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b871fb11..1ef45aa3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,10 +5,12 @@ namespace App\Providers; use App\Models\Client; +use App\Models\FailedJob; use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\Project; +use App\Models\ProjectMember; use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; @@ -56,15 +58,17 @@ public function boot(): void Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); Model::preventAccessingMissingAttributes(! $this->app->isProduction()); Relation::enforceMorphMap([ + 'client' => Client::class, + 'failed-job' => FailedJob::class, 'membership' => Member::class, 'organization' => Organization::class, 'organization-invitation' => OrganizationInvitation::class, - 'user' => User::class, - 'time-entry' => TimeEntry::class, 'project' => Project::class, - 'task' => Task::class, - 'client' => Client::class, + 'project-member' => ProjectMember::class, 'tag' => Tag::class, + 'task' => Task::class, + 'time-entry' => TimeEntry::class, + 'user' => User::class, ]); Model::unguard(); diff --git a/app/Service/DeletionService.php b/app/Service/DeletionService.php index 9910938c..782fa463 100644 --- a/app/Service/DeletionService.php +++ b/app/Service/DeletionService.php @@ -86,7 +86,13 @@ public function deleteOrganization(Organization $organization, bool $inTransacti 'currentOrganization', ]) ->get(); - $organization->users()->sync([]); + + $members = Member::query() + ->whereBelongsTo($organization, 'organization') + ->get(); + foreach ($members as $member) { + $member->delete(); + } // Make sure all users have at least one organization and delete placeholders foreach ($users as $user) { diff --git a/composer.json b/composer.json index b0346bb2..5802a710 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "league/flysystem-aws-s3-v3": "^3.0", "novadaemon/filament-pretty-json": "^2.2", "nwidart/laravel-modules": "^11.0.11", + "owen-it/laravel-auditing": "^13.6", "pxlrbt/filament-environment-indicator": "^2.0", "spatie/temporary-directory": "^2.2", "stechstudio/filament-impersonate": "^3.8", diff --git a/composer.lock b/composer.lock index 04d1b85a..4a1569dd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "398572ca3d095ca043c84e7dfb4f051a", + "content-hash": "38a45676e6bb2159275c370648f7ca11", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -6699,6 +6699,94 @@ ], "time": "2024-06-17T08:53:37+00:00" }, + { + "name": "owen-it/laravel-auditing", + "version": "v13.6.8", + "source": { + "type": "git", + "url": "https://github.com/owen-it/laravel-auditing.git", + "reference": "28ecd2d5cc05c3619f99af42611877f54371af20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/owen-it/laravel-auditing/zipball/28ecd2d5cc05c3619f99af42611877f54371af20", + "reference": "28ecd2d5cc05c3619f99af42611877f54371af20", + "shasum": "" + }, + "require": { + "ext-json": "*", + "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", + "php": "^7.3|^8.0" + }, + "require-dev": { + "laravel/legacy-factories": "*", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0", + "phpunit/phpunit": "^9.6|^10.5|^11.0" + }, + "suggest": { + "irazasyed/larasupport": "Needed to publish the package configuration in Lumen" + }, + "type": "package", + "extra": { + "branch-alias": { + "dev-master": "v13-dev" + }, + "laravel": { + "providers": [ + "OwenIt\\Auditing\\AuditingServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "OwenIt\\Auditing\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "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" + } + ], + "description": "Audit changes of your Eloquent models in Laravel/Lumen", + "homepage": "https://laravel-auditing.com", + "keywords": [ + "Accountability", + "Audit", + "auditing", + "changes", + "eloquent", + "history", + "laravel", + "log", + "logging", + "lumen", + "observer", + "record", + "revision", + "tracking" + ], + "support": { + "issues": "https://github.com/owen-it/laravel-auditing/issues", + "source": "https://github.com/owen-it/laravel-auditing" + }, + "time": "2024-06-26T20:56:28+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v2.7.0", diff --git a/config/audit.php b/config/audit.php new file mode 100644 index 00000000..518aa43a --- /dev/null +++ b/config/audit.php @@ -0,0 +1,200 @@ + env('AUDITING_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | 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' => App\Extensions\Auditing\Resolvers\CustomIpAddressResolver::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' => true, + + /* + |-------------------------------------------------------------------------- + | 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' => false, + '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' => true, + + /* + |-------------------------------------------------------------------------- + | 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' => true, +]; diff --git a/database/factories/AuditFactory.php b/database/factories/AuditFactory.php new file mode 100644 index 00000000..55e0991f --- /dev/null +++ b/database/factories/AuditFactory.php @@ -0,0 +1,71 @@ + + */ +class AuditFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $morphPrefix = Config::get('audit.user.morph_prefix', 'user'); + + return [ + $morphPrefix.'_id' => function () { + return User::factory()->create()->id; + }, + $morphPrefix.'_type' => function () { + return (new User())->getMorphClass(); + }, + 'event' => 'updated', + 'auditable_id' => function () { + return User::factory()->create()->getKey(); + }, + 'auditable_type' => function () { + return (new User())->getMorphClass(); + }, + 'old_values' => [], + 'new_values' => [], + 'url' => $this->faker->url, + 'ip_address' => $this->faker->ipv4, + 'user_agent' => $this->faker->userAgent, + 'tags' => implode(',', $this->faker->words(4)), + ]; + } + + public function auditUser(User $user): self + { + return $this->state(function (array $attributes) use ($user) { + $morphPrefix = Config::get('audit.user.morph_prefix', 'user'); + + return [ + $morphPrefix.'_id' => $user->getKey(), + $morphPrefix.'_type' => $user->getMorphClass(), + ]; + }); + } + + public function auditFor(Model $model): self + { + return $this->state(function (array $attributes) use ($model) { + return [ + 'auditable_id' => $model->getKey(), + 'auditable_type' => $model->getMorphClass(), + ]; + }); + } +} diff --git a/database/migrations/2024_09_02_094105_create_audits_table.php b/database/migrations/2024_09_02_094105_create_audits_table.php new file mode 100644 index 00000000..a3363ad0 --- /dev/null +++ b/database/migrations/2024_09_02_094105_create_audits_table.php @@ -0,0 +1,50 @@ +create($table, function (Blueprint $table) { + + $morphPrefix = config('audit.user.morph_prefix', 'user'); + + $table->bigIncrements('id'); + $table->string($morphPrefix.'_type')->nullable(); + $table->uuid($morphPrefix.'_id')->nullable(); + $table->string('event'); + $table->uuidMorphs('auditable'); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->text('url')->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->string('user_agent', 1023)->nullable(); + $table->string('tags')->nullable(); + $table->timestamps(); + + $table->index([$morphPrefix.'_id', $morphPrefix.'_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $connection = config('audit.drivers.database.connection', config('database.default')); + $table = config('audit.drivers.database.table', 'audits'); + + Schema::connection($connection)->drop($table); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 73736125..55b2e308 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Enums\Role; +use App\Models\Audit; use App\Models\Client; use App\Models\Member; use App\Models\Organization; @@ -153,6 +154,7 @@ public function run(): void private function deleteAll(): void { + DB::table((new Audit())->getTable())->delete(); DB::table((new TimeEntry())->getTable())->delete(); DB::table((new Task())->getTable())->delete(); DB::table((new Tag())->getTable())->delete(); diff --git a/tests/Unit/Filament/AuditResourceTest.php b/tests/Unit/Filament/AuditResourceTest.php new file mode 100644 index 00000000..53bf585c --- /dev/null +++ b/tests/Unit/Filament/AuditResourceTest.php @@ -0,0 +1,58 @@ +withPersonalOrganization()->create([ + 'email' => 'admin@example.com', + ]); + + $this->actingAs($user); + } + + public function test_can_list_audits(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $timeEntry = TimeEntry::factory()->forMember($user->member)->create(); + DB::table((new Audit())->getTable())->delete(); + $audits = Audit::factory()->auditFor($timeEntry)->auditUser($user->user)->createMany(5); + + // Act + $response = Livewire::test(AuditResource\Pages\ListAudits::class); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($audits); + } + + public function test_can_see_view_page_of_audit(): void + { + // Arrange + DB::table((new Audit())->getTable())->delete(); + $audit = Audit::factory()->create(); + + // Act + $response = Livewire::test(AuditResource\Pages\ViewAudit::class, ['record' => $audit->getKey()]); + + // Assert + $response->assertSuccessful(); + } +}