Add auditing

This commit is contained in:
Constantin Graf
2024-09-03 14:07:13 +02:00
committed by Constantin Graf
parent a01e1d6b0b
commit 156d2ff1a0
26 changed files with 732 additions and 16 deletions

View File

@@ -3,6 +3,7 @@ APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Extensions\Auditing\Resolvers;
use Illuminate\Support\Facades\Request;
use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Contracts\Resolver;
class CustomIpAddressResolver implements Resolver
{
private static function anonymizeIpAddress(string $ipAddress): string
{
/** @source https://stackoverflow.com/a/48777412 */
return preg_replace(
['/\.\d*$/', '/[\da-f]*:[\da-f]*$/'],
['.0', '0:0'],
$ipAddress
);
}
public static function resolve(Auditable $auditable): string
{
$ip = $auditable->preloadedResolverData['ip_address'] ?? Request::ip();
if ($ip !== null) {
$ip = self::anonymizeIpAddress($ip);
}
return $ip;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationGroup = 'System';
public static function form(Form $form): Form
{
return $form
->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}'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

33
app/Models/Audit.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/**
* @property int $id
* @property string|null $user_type
* @property string|null $user_id
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
* @property array|null $old_values
* @property array|null $new_values
* @property string|null $url
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $tags
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static AuditFactory factory()
*/
class Audit extends PackageAuditModel
{
use HasFactory;
}

View File

@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -25,8 +27,9 @@ use Illuminate\Support\Carbon;
* *
* @method static ClientFactory factory() * @method static ClientFactory factory()
*/ */
class Client extends Model class Client extends Model implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Laravel\Jetstream\Membership as JetstreamMembership; use Laravel\Jetstream\Membership as JetstreamMembership;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -25,8 +27,9 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
* *
* @method static MemberFactory factory() * @method static MemberFactory factory()
*/ */
class Member extends JetstreamMembership class Member extends JetstreamMembership implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -18,6 +18,8 @@ use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated; use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam; use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -37,8 +39,9 @@ use Laravel\Jetstream\Team as JetstreamTeam;
* @method HasMany<OrganizationInvitation> teamInvitations() * @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory() * @method static OrganizationFactory factory()
*/ */
class Organization extends JetstreamTeam class Organization extends JetstreamTeam implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -23,8 +25,9 @@ use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
* *
* @method static OrganizationInvitationFactory factory() * @method static OrganizationInvitationFactory factory()
*/ */
class OrganizationInvitation extends JetstreamTeamInvitation class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -36,8 +38,9 @@ use Illuminate\Support\Carbon;
* @method Builder<Project> visibleByEmployee(User $user) * @method Builder<Project> visibleByEmployee(User $user)
* @method static ProjectFactory factory() * @method static ProjectFactory factory()
*/ */
class Project extends Model class Project extends Model implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -27,8 +29,9 @@ use Illuminate\Support\Carbon;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization) * @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory() * @method static ProjectMemberFactory factory()
*/ */
class ProjectMember extends Model class ProjectMember extends Model implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -21,8 +23,9 @@ use Illuminate\Support\Carbon;
* *
* @method static TagFactory factory() * @method static TagFactory factory()
*/ */
class Tag extends Model class Tag extends Model implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -30,8 +32,9 @@ use Illuminate\Support\Carbon;
* *
* @method static TaskFactory factory() * @method static TaskFactory factory()
*/ */
class Task extends Model class Task extends Model implements AuditableContract
{ {
use Auditable;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes; use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -43,8 +45,9 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
* @method Builder<TimeEntry> hasTag(Tag $tag) * @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory() * @method static TimeEntryFactory factory()
*/ */
class TimeEntry extends Model class TimeEntry extends Model implements AuditableContract
{ {
use Auditable;
use ComputedAttributes; use ComputedAttributes;
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;

View File

@@ -25,6 +25,8 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams; use Laravel\Jetstream\HasTeams;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
* @property string $id * @property string $id
@@ -53,8 +55,9 @@ use Laravel\Passport\HasApiTokens;
* @method Builder<User> belongsToOrganization(Organization $organization) * @method Builder<User> belongsToOrganization(Organization $organization)
* @method Builder<User> active() * @method Builder<User> active()
*/ */
class User extends Authenticatable implements FilamentUser, MustVerifyEmail class User extends Authenticatable implements AuditableContract, FilamentUser, MustVerifyEmail
{ {
use Auditable;
use HasApiTokens; use HasApiTokens;
use HasFactory; use HasFactory;
use HasProfilePhoto; use HasProfilePhoto;

View File

@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Models\Client; use App\Models\Client;
use App\Models\FailedJob;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -56,15 +58,17 @@ class AppServiceProvider extends ServiceProvider
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
Model::preventAccessingMissingAttributes(! $this->app->isProduction()); Model::preventAccessingMissingAttributes(! $this->app->isProduction());
Relation::enforceMorphMap([ Relation::enforceMorphMap([
'client' => Client::class,
'failed-job' => FailedJob::class,
'membership' => Member::class, 'membership' => Member::class,
'organization' => Organization::class, 'organization' => Organization::class,
'organization-invitation' => OrganizationInvitation::class, 'organization-invitation' => OrganizationInvitation::class,
'user' => User::class,
'time-entry' => TimeEntry::class,
'project' => Project::class, 'project' => Project::class,
'task' => Task::class, 'project-member' => ProjectMember::class,
'client' => Client::class,
'tag' => Tag::class, 'tag' => Tag::class,
'task' => Task::class,
'time-entry' => TimeEntry::class,
'user' => User::class,
]); ]);
Model::unguard(); Model::unguard();

View File

@@ -86,7 +86,13 @@ class DeletionService
'currentOrganization', 'currentOrganization',
]) ])
->get(); ->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 // Make sure all users have at least one organization and delete placeholders
foreach ($users as $user) { foreach ($users as $user) {

View File

@@ -24,6 +24,7 @@
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"novadaemon/filament-pretty-json": "^2.2", "novadaemon/filament-pretty-json": "^2.2",
"nwidart/laravel-modules": "^11.0.11", "nwidart/laravel-modules": "^11.0.11",
"owen-it/laravel-auditing": "^13.6",
"pxlrbt/filament-environment-indicator": "^2.0", "pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2", "spatie/temporary-directory": "^2.2",
"stechstudio/filament-impersonate": "^3.8", "stechstudio/filament-impersonate": "^3.8",

90
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "398572ca3d095ca043c84e7dfb4f051a", "content-hash": "38a45676e6bb2159275c370648f7ca11",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -6699,6 +6699,94 @@
], ],
"time": "2024-06-17T08:53:37+00:00" "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", "name": "paragonie/constant_time_encoding",
"version": "v2.7.0", "version": "v2.7.0",

200
config/audit.php Normal file
View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
return [
'enabled' => 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,
];

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Audit;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
/**
* @extends Factory<Audit>
*/
class AuditFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
];
});
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAuditsTable extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$connection = config('audit.drivers.database.connection', config('database.default'));
$table = config('audit.drivers.database.table', 'audits');
Schema::connection($connection)->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);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Audit;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -153,6 +154,7 @@ class DatabaseSeeder extends Seeder
private function deleteAll(): void private function deleteAll(): void
{ {
DB::table((new Audit())->getTable())->delete();
DB::table((new TimeEntry())->getTable())->delete(); DB::table((new TimeEntry())->getTable())->delete();
DB::table((new Task())->getTable())->delete(); DB::table((new Task())->getTable())->delete();
DB::table((new Tag())->getTable())->delete(); DB::table((new Tag())->getTable())->delete();

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament;
use App\Filament\Resources\AuditResource;
use App\Models\Audit;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
#[UsesClass(AuditResource::class)]
class AuditResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->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();
}
}