Phase 6: security & ops hardening (T6.1-T6.5)

- T6.1: named rate limiters (api-read/api-write/api-auth/api-webhooks),
  applied per module route group with tighter limits on booking/payment
  writes and the KBZ webhook than read-only catalog/routing endpoints.
- T6.2: install spatie/laravel-activitylog; LogsActivity on Booking/
  Payment/Refund status transitions and catalog/pricing admin CRUD
  (EvCompany, Destination, DepartureTimeSlot, EvRoute, RoutePricing).
  New IdentityPlugin with a read-only AuditLogResource gated by
  view_audit_log.
- T6.3: JSON error envelope for api/* in bootstrap/app.php (401/403/404/
  405/429/500 fallback), plus PaymentGatewayException (422 declined /
  502 unavailable).
- T6.4: feature tests proving the FastAPI agent token gets 403 on
  refund/cancel-not-owned and 405 (no write handler) on catalog/routing
  writes.
- T6.5: install gboquizosanchez/filament-log-viewer with a custom
  Filament admin theme (required for its views' Tailwind classes to
  compile), LOG_CHANNEL/FILAMENT_LOG_VIEWER_DRIVER=daily, registered
  under Operations in the sidebar.

252 tests passing.
This commit is contained in:
Nyan Lin Paing
2026-08-09 20:59:00 +07:00
parent e2e7902307
commit 46f9b8d5a3
43 changed files with 1176 additions and 25 deletions
@@ -0,0 +1,51 @@
<?php
namespace Modules\Identity\Filament\Resources\AuditLogs;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Modules\Identity\Filament\Resources\AuditLogs\Pages\ListAuditLogs;
use Modules\Identity\Filament\Resources\AuditLogs\Pages\ViewAuditLog;
use Modules\Identity\Filament\Resources\AuditLogs\Schemas\AuditLogInfolist;
use Modules\Identity\Filament\Resources\AuditLogs\Tables\AuditLogsTable;
use Spatie\Activitylog\Models\Activity;
use UnitEnum;
/**
* Read-only by design (T6.2, gated by view_audit_log AuditLogPolicy):
* every row here comes from the LogsActivity trait on Booking/Payment/
* Refund/catalog/pricing models, never hand-entered.
*/
class AuditLogResource extends Resource
{
protected static ?string $model = Activity::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClipboardDocumentList;
protected static string|UnitEnum|null $navigationGroup = 'Access';
protected static ?string $navigationLabel = 'Audit Log';
protected static ?string $modelLabel = 'Audit Log Entry';
public static function table(Table $table): Table
{
return AuditLogsTable::configure($table);
}
public static function infolist(Schema $schema): Schema
{
return AuditLogInfolist::configure($schema);
}
public static function getPages(): array
{
return [
'index' => ListAuditLogs::route('/'),
'view' => ViewAuditLog::route('/{record}'),
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Identity\Filament\Resources\AuditLogs\Pages;
use Filament\Resources\Pages\ListRecords;
use Modules\Identity\Filament\Resources\AuditLogs\AuditLogResource;
class ListAuditLogs extends ListRecords
{
protected static string $resource = AuditLogResource::class;
protected function getHeaderActions(): array
{
// No CreateAction — audit log rows are only ever written by the
// LogsActivity trait, never hand-entered here.
return [];
}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Identity\Filament\Resources\AuditLogs\Pages;
use Filament\Resources\Pages\ViewRecord;
use Modules\Identity\Filament\Resources\AuditLogs\AuditLogResource;
class ViewAuditLog extends ViewRecord
{
protected static string $resource = AuditLogResource::class;
protected function getHeaderActions(): array
{
// Read-only — no EditAction/DeleteAction.
return [];
}
}
@@ -0,0 +1,41 @@
<?php
namespace Modules\Identity\Filament\Resources\AuditLogs\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class AuditLogInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Event')
->schema([
Grid::make(3)
->schema([
TextEntry::make('log_name')->label('Module')->badge(),
TextEntry::make('event')->badge()->placeholder('—'),
TextEntry::make('created_at')->dateTime(),
TextEntry::make('subject_type')->label('Subject Type')->placeholder('—'),
TextEntry::make('subject_id')->label('Subject ID')->placeholder('—'),
TextEntry::make('causer.name')->label('Caused By')->placeholder('System'),
]),
TextEntry::make('description')->columnSpanFull(),
]),
Section::make('Changes')
->schema([
TextEntry::make('attribute_changes')
->label('')
->formatStateUsing(fn (mixed $state) => $state
? json_encode($state, JSON_PRETTY_PRINT)
: null)
->placeholder('—')
->columnSpanFull(),
]),
]);
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\Identity\Filament\Resources\AuditLogs\Tables;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
class AuditLogsTable
{
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with(['causer', 'subject']))
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('created_at')
->dateTime()
->sortable(),
TextColumn::make('log_name')
->label('Module')
->badge(),
TextColumn::make('event')
->badge()
->color(fn (?string $state) => match ($state) {
'created' => 'success',
'updated' => 'warning',
'deleted' => 'danger',
default => 'gray',
})
->placeholder('—'),
TextColumn::make('subject_type')
->label('Subject')
->formatStateUsing(fn (?string $state) => $state ? Str::afterLast($state, '\\') : '—')
->description(fn (Activity $record) => $record->subject_id ? "#{$record->subject_id}" : null),
TextColumn::make('description')
->wrap(),
TextColumn::make('causer.name')
->label('Caused By')
->placeholder('System')
->searchable(),
])
->filters([
SelectFilter::make('log_name')
->label('Module')
->options(fn () => Activity::query()->distinct()->pluck('log_name', 'log_name')->filter()->all()),
SelectFilter::make('event')
->options([
'created' => 'Created',
'updated' => 'Updated',
'deleted' => 'Deleted',
]),
])
->recordActions([
ViewAction::make(),
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Identity;
use Filament\Contracts\Plugin;
use Filament\Panel;
/**
* Thin Access-management plugin (see tickets.md T1.3) Identity has no
* customer-facing CRUD of its own, just the read-only AuditLogResource
* (T6.2) discovered here.
*/
class IdentityPlugin implements Plugin
{
public function getId(): string
{
return 'identity';
}
public function register(Panel $panel): void
{
$panel
->discoverResources(
in: __DIR__.'/Filament/Resources',
for: 'Modules\Identity\Filament\Resources',
)
->discoverPages(
in: __DIR__.'/Filament/Pages',
for: 'Modules\Identity\Filament\Pages',
)
->discoverWidgets(
in: __DIR__.'/Filament/Widgets',
for: 'Modules\Identity\Filament\Widgets',
);
}
public function boot(Panel $panel): void {}
public static function make(): static
{
return app(static::class);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Identity\Policies;
use App\Models\User;
use Spatie\Activitylog\Models\Activity;
/**
* The audit trail (T6.2) is read-only from the admin panel nothing ever
* creates/edits/deletes an Activity row through Filament, only the
* LogsActivity trait writes here. Gated by view_audit_log per domain.md §8.
*/
class AuditLogPolicy
{
public function viewAny(User $user): bool
{
return $user->can('view_audit_log');
}
public function view(User $user, Activity $activity): bool
{
return $user->can('view_audit_log');
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, Activity $activity): bool
{
return false;
}
public function delete(User $user, Activity $activity): bool
{
return false;
}
}
@@ -2,11 +2,17 @@
namespace Modules\Identity\Providers;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\ServiceProvider;
use Modules\Identity\Policies\AuditLogPolicy;
use Spatie\Activitylog\Models\Activity;
class IdentityServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void {}
public function boot(Gate $gate): void
{
$gate->policy(Activity::class, AuditLogPolicy::class);
}
}