Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation. Key changes: - Findings workflow service + SLA policy and alerting. - Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults. - New migrations, jobs, command, UI/resource updates, and comprehensive test coverage. Tests: - `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #135
478 lines
18 KiB
PHP
478 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Resources;
|
|
|
|
use App\Filament\Clusters\Monitoring\AlertsCluster;
|
|
use App\Filament\Resources\AlertRuleResource\Pages;
|
|
use App\Models\AlertDestination;
|
|
use App\Models\AlertRule;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\ActionGroup;
|
|
use Filament\Actions\BulkActionGroup;
|
|
use Filament\Actions\EditAction;
|
|
use Filament\Facades\Filament;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Resource;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Components\Utilities\Get;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Arr;
|
|
use UnitEnum;
|
|
|
|
class AlertRuleResource extends Resource
|
|
{
|
|
protected static bool $isScopedToTenant = false;
|
|
|
|
protected static ?string $model = AlertRule::class;
|
|
|
|
protected static ?string $slug = 'alert-rules';
|
|
|
|
protected static ?string $cluster = AlertsCluster::class;
|
|
|
|
protected static ?int $navigationSort = 2;
|
|
|
|
protected static bool $isGloballySearchable = false;
|
|
|
|
protected static ?string $recordTitleAttribute = 'name';
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
|
|
protected static ?string $navigationLabel = 'Alert rules';
|
|
|
|
public static function shouldRegisterNavigation(): bool
|
|
{
|
|
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
|
return false;
|
|
}
|
|
|
|
return parent::shouldRegisterNavigation();
|
|
}
|
|
|
|
public static function canViewAny(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can('viewAny', AlertRule::class);
|
|
}
|
|
|
|
public static function canCreate(): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can('create', AlertRule::class);
|
|
}
|
|
|
|
public static function canEdit(Model $record): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can('update', $record);
|
|
}
|
|
|
|
public static function canDelete(Model $record): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $record instanceof AlertRule) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can('delete', $record);
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert rules in v1.')
|
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
|
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
|
|
}
|
|
|
|
public static function getEloquentQuery(): Builder
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
return parent::getEloquentQuery()
|
|
->with('destinations')
|
|
->when(
|
|
$workspaceId !== null,
|
|
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
|
|
)
|
|
->when(
|
|
$workspaceId === null,
|
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
|
);
|
|
}
|
|
|
|
public static function form(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->schema([
|
|
Section::make('Rule')
|
|
->schema([
|
|
TextInput::make('name')
|
|
->required()
|
|
->maxLength(255),
|
|
Toggle::make('is_enabled')
|
|
->label('Enabled')
|
|
->default(true),
|
|
Select::make('event_type')
|
|
->required()
|
|
->options(self::eventTypeOptions())
|
|
->native(false),
|
|
Select::make('minimum_severity')
|
|
->required()
|
|
->options(self::severityOptions())
|
|
->native(false),
|
|
]),
|
|
Section::make('Applies to')
|
|
->schema([
|
|
Select::make('tenant_scope_mode')
|
|
->label('Applies to tenants')
|
|
->required()
|
|
->options([
|
|
AlertRule::TENANT_SCOPE_ALL => 'All tenants',
|
|
AlertRule::TENANT_SCOPE_ALLOWLIST => 'Selected tenants',
|
|
])
|
|
->default(AlertRule::TENANT_SCOPE_ALL)
|
|
->native(false)
|
|
->live()
|
|
->helperText('This rule is workspace-wide. Use this to limit where it applies.'),
|
|
Select::make('tenant_allowlist')
|
|
->label('Selected tenants')
|
|
->multiple()
|
|
->options(self::tenantOptions())
|
|
->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST)
|
|
->native(false)
|
|
->helperText('Only these tenants will trigger this rule.'),
|
|
]),
|
|
Section::make('Delivery')
|
|
->schema([
|
|
TextInput::make('cooldown_seconds')
|
|
->label('Cooldown (seconds)')
|
|
->numeric()
|
|
->minValue(0)
|
|
->nullable(),
|
|
Toggle::make('quiet_hours_enabled')
|
|
->label('Enable quiet hours')
|
|
->default(false)
|
|
->live(),
|
|
TextInput::make('quiet_hours_start')
|
|
->label('Quiet hours start')
|
|
->type('time')
|
|
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
|
TextInput::make('quiet_hours_end')
|
|
->label('Quiet hours end')
|
|
->type('time')
|
|
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
|
Select::make('quiet_hours_timezone')
|
|
->label('Quiet hours timezone')
|
|
->options(self::timezoneOptions())
|
|
->searchable()
|
|
->native(false)
|
|
->visible(fn (Get $get): bool => (bool) $get('quiet_hours_enabled')),
|
|
Select::make('destination_ids')
|
|
->label('Destinations')
|
|
->multiple()
|
|
->required()
|
|
->options(self::destinationOptions())
|
|
->native(false),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
public static function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->defaultSort('name')
|
|
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
|
|
? static::getUrl('edit', ['record' => $record])
|
|
: null)
|
|
->columns([
|
|
TextColumn::make('name')
|
|
->searchable(),
|
|
TextColumn::make('event_type')
|
|
->label('Event')
|
|
->badge()
|
|
->formatStateUsing(fn (?string $state): string => self::eventTypeLabel((string) $state)),
|
|
TextColumn::make('minimum_severity')
|
|
->label('Min severity')
|
|
->badge()
|
|
->formatStateUsing(fn (?string $state): string => self::severityOptions()[(string) $state] ?? ucfirst((string) $state)),
|
|
TextColumn::make('destinations_count')
|
|
->label('Destinations')
|
|
->counts('destinations'),
|
|
TextColumn::make('is_enabled')
|
|
->label('Enabled')
|
|
->badge()
|
|
->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No')
|
|
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
|
])
|
|
->actions([
|
|
EditAction::make()
|
|
->label('Edit')
|
|
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
|
ActionGroup::make([
|
|
Action::make('toggle_enabled')
|
|
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
|
->icon(fn (AlertRule $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play')
|
|
->requiresConfirmation()
|
|
->action(function (AlertRule $record): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $user->can('update', $record)) {
|
|
throw new AuthorizationException;
|
|
}
|
|
|
|
$enabled = ! (bool) $record->is_enabled;
|
|
$record->forceFill([
|
|
'is_enabled' => $enabled,
|
|
])->save();
|
|
|
|
$actionId = $enabled
|
|
? AuditActionId::AlertRuleEnabled
|
|
: AuditActionId::AlertRuleDisabled;
|
|
|
|
self::audit($record, $actionId, [
|
|
'alert_rule_id' => (int) $record->getKey(),
|
|
'name' => (string) $record->name,
|
|
'event_type' => (string) $record->event_type,
|
|
'is_enabled' => $enabled,
|
|
]);
|
|
|
|
Notification::make()
|
|
->title($enabled ? 'Rule enabled' : 'Rule disabled')
|
|
->success()
|
|
->send();
|
|
}),
|
|
Action::make('delete')
|
|
->label('Delete')
|
|
->icon('heroicon-o-trash')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->action(function (AlertRule $record): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User || ! $user->can('delete', $record)) {
|
|
throw new AuthorizationException;
|
|
}
|
|
|
|
self::audit($record, AuditActionId::AlertRuleDeleted, [
|
|
'alert_rule_id' => (int) $record->getKey(),
|
|
'name' => (string) $record->name,
|
|
'event_type' => (string) $record->event_type,
|
|
]);
|
|
|
|
$record->delete();
|
|
|
|
Notification::make()
|
|
->title('Rule deleted')
|
|
->success()
|
|
->send();
|
|
}),
|
|
])->label('More'),
|
|
])
|
|
->bulkActions([
|
|
BulkActionGroup::make([])->label('More'),
|
|
]);
|
|
}
|
|
|
|
public static function getPages(): array
|
|
{
|
|
return [
|
|
'index' => Pages\ListAlertRules::route('/'),
|
|
'create' => Pages\CreateAlertRule::route('/create'),
|
|
'edit' => Pages\EditAlertRule::route('/{record}/edit'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function normalizePayload(array $data): array
|
|
{
|
|
$tenantAllowlist = Arr::wrap($data['tenant_allowlist'] ?? []);
|
|
$tenantAllowlist = array_values(array_unique(array_filter(array_map(static fn (mixed $value): int => (int) $value, $tenantAllowlist))));
|
|
|
|
if (($data['tenant_scope_mode'] ?? AlertRule::TENANT_SCOPE_ALL) !== AlertRule::TENANT_SCOPE_ALLOWLIST) {
|
|
$tenantAllowlist = [];
|
|
}
|
|
|
|
$quietHoursEnabled = (bool) ($data['quiet_hours_enabled'] ?? false);
|
|
|
|
$data['is_enabled'] = (bool) ($data['is_enabled'] ?? true);
|
|
$data['tenant_allowlist'] = $tenantAllowlist;
|
|
$data['cooldown_seconds'] = is_numeric($data['cooldown_seconds'] ?? null) ? (int) $data['cooldown_seconds'] : null;
|
|
$data['quiet_hours_enabled'] = $quietHoursEnabled;
|
|
|
|
if (! $quietHoursEnabled) {
|
|
$data['quiet_hours_start'] = null;
|
|
$data['quiet_hours_end'] = null;
|
|
$data['quiet_hours_timezone'] = null;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $destinationIds
|
|
*/
|
|
public static function syncDestinations(AlertRule $record, array $destinationIds): void
|
|
{
|
|
$allowedDestinationIds = AlertDestination::query()
|
|
->where('workspace_id', (int) $record->workspace_id)
|
|
->whereIn('id', $destinationIds)
|
|
->pluck('id')
|
|
->map(static fn (mixed $value): int => (int) $value)
|
|
->all();
|
|
|
|
$record->destinations()->syncWithPivotValues(
|
|
array_values(array_unique($allowedDestinationIds)),
|
|
['workspace_id' => (int) $record->workspace_id],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function eventTypeOptions(): array
|
|
{
|
|
return [
|
|
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
|
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
|
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
|
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
|
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function severityOptions(): array
|
|
{
|
|
return [
|
|
'low' => 'Low',
|
|
'medium' => 'Medium',
|
|
'high' => 'High',
|
|
'critical' => 'Critical',
|
|
];
|
|
}
|
|
|
|
public static function eventTypeLabel(string $eventType): string
|
|
{
|
|
return self::eventTypeOptions()[$eventType] ?? ucfirst(str_replace('_', ' ', $eventType));
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private static function destinationOptions(): array
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if (! is_int($workspaceId)) {
|
|
return [];
|
|
}
|
|
|
|
return AlertDestination::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->orderBy('name')
|
|
->pluck('name', 'id')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private static function tenantOptions(): array
|
|
{
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if (! is_int($workspaceId)) {
|
|
return [];
|
|
}
|
|
|
|
return Tenant::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->where('status', 'active')
|
|
->orderBy('name')
|
|
->pluck('name', 'id')
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private static function timezoneOptions(): array
|
|
{
|
|
$identifiers = \DateTimeZone::listIdentifiers();
|
|
sort($identifiers);
|
|
|
|
return array_combine($identifiers, $identifiers);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $metadata
|
|
*/
|
|
public static function audit(AlertRule $record, AuditActionId $actionId, array $metadata): void
|
|
{
|
|
$workspace = $record->workspace;
|
|
|
|
if ($workspace === null) {
|
|
return;
|
|
}
|
|
|
|
$actor = auth()->user();
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $workspace,
|
|
action: $actionId->value,
|
|
context: [
|
|
'metadata' => $metadata,
|
|
],
|
|
actor: $actor instanceof User ? $actor : null,
|
|
resourceType: 'alert_rule',
|
|
resourceId: (string) $record->getKey(),
|
|
);
|
|
}
|
|
}
|