From 8c2798a10ede5dd63ef72d0fa441733f73f11bbe Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 18 Feb 2026 15:25:14 +0100 Subject: [PATCH] feat: alerts v1 (navigation + guards) --- .../Commands/TenantpilotDispatchAlerts.php | 106 ++++ .../Clusters/Monitoring/AlertsCluster.php | 27 + app/Filament/Pages/Monitoring/Alerts.php | 54 +- .../Resources/AlertDeliveryResource.php | 278 +++++++++++ .../Pages/ListAlertDeliveries.php | 22 + .../Pages/ViewAlertDelivery.php | 13 + .../Resources/AlertDestinationResource.php | 380 ++++++++++++++ .../Pages/CreateAlertDestination.php | 47 ++ .../Pages/EditAlertDestination.php | 49 ++ .../Pages/ListAlertDestinations.php | 37 ++ app/Filament/Resources/AlertRuleResource.php | 462 ++++++++++++++++++ .../Pages/CreateAlertRule.php | 62 +++ .../AlertRuleResource/Pages/EditAlertRule.php | 73 +++ .../Pages/ListAlertRules.php | 37 ++ .../Widgets/Alerts/AlertsKpiHeader.php | 107 ++++ app/Jobs/Alerts/.gitkeep | 0 app/Jobs/Alerts/DeliverAlertsJob.php | 216 ++++++++ app/Jobs/Alerts/EvaluateAlertsJob.php | 256 ++++++++++ app/Models/AlertDelivery.php | 77 +++ app/Models/AlertDestination.php | 49 ++ app/Models/AlertRule.php | 65 +++ app/Models/AlertRuleDestination.php | 30 ++ .../Alerts/EmailAlertNotification.php | 52 ++ app/Policies/AlertDeliveryPolicy.php | 91 ++++ app/Policies/AlertDestinationPolicy.php | 106 ++++ app/Policies/AlertRulePolicy.php | 106 ++++ app/Providers/AuthServiceProvider.php | 9 + app/Providers/Filament/AdminPanelProvider.php | 11 +- .../Filament/TenantPanelProvider.php | 2 +- app/Services/Alerts/.gitkeep | 0 app/Services/Alerts/AlertDispatchService.php | 192 ++++++++ .../Alerts/AlertFingerprintService.php | 52 ++ .../Alerts/AlertQuietHoursService.php | 132 +++++ app/Services/Alerts/AlertSender.php | 96 ++++ app/Services/Alerts/TeamsWebhookSender.php | 58 +++ .../Alerts/WorkspaceTimezoneResolver.php | 68 +++ .../Auth/WorkspaceRoleCapabilityMap.php | 6 + app/Support/Audit/AuditActionId.php | 12 + app/Support/Auth/Capabilities.php | 5 + .../EnsureFilamentTenantSelected.php | 29 +- app/Support/OperationCatalog.php | 3 + .../OperationRunCapabilityResolver.php | 1 + config/tenantpilot.php | 11 + database/factories/AlertDeliveryFactory.php | 78 +++ .../factories/AlertDestinationFactory.php | 40 ++ database/factories/AlertRuleFactory.php | 33 ++ ...230100_create_alert_destinations_table.php | 30 ++ ..._02_16_230200_create_alert_rules_table.php | 37 ++ ...0_create_alert_rule_destinations_table.php | 27 + ...6_230300_create_alert_deliveries_table.php | 40 ++ .../pages/monitoring/alerts.blade.php | 34 +- routes/console.php | 4 + routes/web.php | 13 - .../checklists/requirements.md | 34 ++ .../contracts/openapi.yaml | 291 +++++++++++ specs/099-alerts-v1-teams-email/data-model.md | 116 +++++ specs/099-alerts-v1-teams-email/plan.md | 217 ++++++++ specs/099-alerts-v1-teams-email/quickstart.md | 55 +++ specs/099-alerts-v1-teams-email/research.md | 47 ++ specs/099-alerts-v1-teams-email/spec.md | 206 ++++++++ specs/099-alerts-v1-teams-email/tasks.md | 192 ++++++++ .../Alerts/AlertDeliveryViewerTest.php | 100 ++++ .../Alerts/AlertDestinationAccessTest.php | 60 +++ .../Alerts/AlertDestinationCrudTest.php | 67 +++ .../Filament/Alerts/AlertRuleAccessTest.php | 57 +++ .../Filament/Alerts/AlertRuleCrudTest.php | 66 +++ .../Monitoring/MonitoringOperationsTest.php | 6 +- .../OperationsCanonicalUrlsTest.php | 1 + tests/Feature/OpsUx/OperateHubShellTest.php | 15 +- tests/Unit/Alerts/AlertQuietHoursTest.php | 63 +++ tests/Unit/Alerts/AlertRetryPolicyTest.php | 70 +++ tests/Unit/Alerts/AlertSuppressionTest.php | 57 +++ 72 files changed, 5513 insertions(+), 32 deletions(-) create mode 100644 app/Console/Commands/TenantpilotDispatchAlerts.php create mode 100644 app/Filament/Clusters/Monitoring/AlertsCluster.php create mode 100644 app/Filament/Resources/AlertDeliveryResource.php create mode 100644 app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php create mode 100644 app/Filament/Resources/AlertDeliveryResource/Pages/ViewAlertDelivery.php create mode 100644 app/Filament/Resources/AlertDestinationResource.php create mode 100644 app/Filament/Resources/AlertDestinationResource/Pages/CreateAlertDestination.php create mode 100644 app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php create mode 100644 app/Filament/Resources/AlertDestinationResource/Pages/ListAlertDestinations.php create mode 100644 app/Filament/Resources/AlertRuleResource.php create mode 100644 app/Filament/Resources/AlertRuleResource/Pages/CreateAlertRule.php create mode 100644 app/Filament/Resources/AlertRuleResource/Pages/EditAlertRule.php create mode 100644 app/Filament/Resources/AlertRuleResource/Pages/ListAlertRules.php create mode 100644 app/Filament/Widgets/Alerts/AlertsKpiHeader.php create mode 100644 app/Jobs/Alerts/.gitkeep create mode 100644 app/Jobs/Alerts/DeliverAlertsJob.php create mode 100644 app/Jobs/Alerts/EvaluateAlertsJob.php create mode 100644 app/Models/AlertDelivery.php create mode 100644 app/Models/AlertDestination.php create mode 100644 app/Models/AlertRule.php create mode 100644 app/Models/AlertRuleDestination.php create mode 100644 app/Notifications/Alerts/EmailAlertNotification.php create mode 100644 app/Policies/AlertDeliveryPolicy.php create mode 100644 app/Policies/AlertDestinationPolicy.php create mode 100644 app/Policies/AlertRulePolicy.php create mode 100644 app/Services/Alerts/.gitkeep create mode 100644 app/Services/Alerts/AlertDispatchService.php create mode 100644 app/Services/Alerts/AlertFingerprintService.php create mode 100644 app/Services/Alerts/AlertQuietHoursService.php create mode 100644 app/Services/Alerts/AlertSender.php create mode 100644 app/Services/Alerts/TeamsWebhookSender.php create mode 100644 app/Services/Alerts/WorkspaceTimezoneResolver.php create mode 100644 database/factories/AlertDeliveryFactory.php create mode 100644 database/factories/AlertDestinationFactory.php create mode 100644 database/factories/AlertRuleFactory.php create mode 100644 database/migrations/2026_02_16_230100_create_alert_destinations_table.php create mode 100644 database/migrations/2026_02_16_230200_create_alert_rules_table.php create mode 100644 database/migrations/2026_02_16_230210_create_alert_rule_destinations_table.php create mode 100644 database/migrations/2026_02_16_230300_create_alert_deliveries_table.php create mode 100644 specs/099-alerts-v1-teams-email/checklists/requirements.md create mode 100644 specs/099-alerts-v1-teams-email/contracts/openapi.yaml create mode 100644 specs/099-alerts-v1-teams-email/data-model.md create mode 100644 specs/099-alerts-v1-teams-email/plan.md create mode 100644 specs/099-alerts-v1-teams-email/quickstart.md create mode 100644 specs/099-alerts-v1-teams-email/research.md create mode 100644 specs/099-alerts-v1-teams-email/spec.md create mode 100644 specs/099-alerts-v1-teams-email/tasks.md create mode 100644 tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php create mode 100644 tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php create mode 100644 tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php create mode 100644 tests/Feature/Filament/Alerts/AlertRuleAccessTest.php create mode 100644 tests/Feature/Filament/Alerts/AlertRuleCrudTest.php create mode 100644 tests/Unit/Alerts/AlertQuietHoursTest.php create mode 100644 tests/Unit/Alerts/AlertRetryPolicyTest.php create mode 100644 tests/Unit/Alerts/AlertSuppressionTest.php diff --git a/app/Console/Commands/TenantpilotDispatchAlerts.php b/app/Console/Commands/TenantpilotDispatchAlerts.php new file mode 100644 index 0000000..b4f63f5 --- /dev/null +++ b/app/Console/Commands/TenantpilotDispatchAlerts.php @@ -0,0 +1,106 @@ + (int) $value, + (array) $this->option('workspace'), + ))); + + $workspaces = $this->resolveWorkspaces($workspaceFilter); + + $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; + + $queuedEvaluate = 0; + $queuedDeliver = 0; + $skippedEvaluate = 0; + $skippedDeliver = 0; + + foreach ($workspaces as $workspace) { + $evaluateRun = $operationRuns->ensureWorkspaceRunWithIdentity( + workspace: $workspace, + type: 'alerts.evaluate', + identityInputs: ['slot_key' => $slotKey], + context: [ + 'trigger' => 'scheduled_dispatch', + 'slot_key' => $slotKey, + ], + initiator: null, + ); + + if ($evaluateRun->wasRecentlyCreated) { + EvaluateAlertsJob::dispatch((int) $workspace->getKey(), (int) $evaluateRun->getKey()); + $queuedEvaluate++; + } else { + $skippedEvaluate++; + } + + $deliverRun = $operationRuns->ensureWorkspaceRunWithIdentity( + workspace: $workspace, + type: 'alerts.deliver', + identityInputs: ['slot_key' => $slotKey], + context: [ + 'trigger' => 'scheduled_dispatch', + 'slot_key' => $slotKey, + ], + initiator: null, + ); + + if ($deliverRun->wasRecentlyCreated) { + DeliverAlertsJob::dispatch((int) $workspace->getKey(), (int) $deliverRun->getKey()); + $queuedDeliver++; + } else { + $skippedDeliver++; + } + } + + $this->info(sprintf( + 'Alert dispatch scanned %d workspace(s): evaluate queued=%d skipped=%d, deliver queued=%d skipped=%d.', + $workspaces->count(), + $queuedEvaluate, + $skippedEvaluate, + $queuedDeliver, + $skippedDeliver, + )); + + return self::SUCCESS; + } + + /** + * @param array $workspaceIds + * @return Collection + */ + private function resolveWorkspaces(array $workspaceIds): Collection + { + return Workspace::query() + ->when( + $workspaceIds !== [], + fn ($query) => $query->whereIn('id', $workspaceIds), + fn ($query) => $query->whereHas('tenants'), + ) + ->orderBy('id') + ->get(); + } +} diff --git a/app/Filament/Clusters/Monitoring/AlertsCluster.php b/app/Filament/Clusters/Monitoring/AlertsCluster.php new file mode 100644 index 0000000..d0daa76 --- /dev/null +++ b/app/Filament/Clusters/Monitoring/AlertsCluster.php @@ -0,0 +1,27 @@ +getId() === 'admin'; + } +} diff --git a/app/Filament/Pages/Monitoring/Alerts.php b/app/Filament/Pages/Monitoring/Alerts.php index c09fbda..4adcb89 100644 --- a/app/Filament/Pages/Monitoring/Alerts.php +++ b/app/Filament/Pages/Monitoring/Alerts.php @@ -4,30 +4,76 @@ namespace App\Filament\Pages\Monitoring; +use App\Filament\Clusters\Monitoring\AlertsCluster; +use App\Filament\Widgets\Alerts\AlertsKpiHeader; +use App\Models\User; +use App\Models\Workspace; +use App\Services\Auth\WorkspaceCapabilityResolver; +use App\Support\Auth\Capabilities; use App\Support\OperateHub\OperateHubShell; +use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions\Action; +use Filament\Facades\Filament; use Filament\Pages\Page; use UnitEnum; class Alerts extends Page { - protected static bool $isDiscovered = false; + protected static ?string $cluster = AlertsCluster::class; - protected static bool $shouldRegisterNavigation = false; + protected static ?int $navigationSort = 20; protected static string|UnitEnum|null $navigationGroup = 'Monitoring'; - protected static ?string $navigationLabel = 'Alerts'; + protected static ?string $navigationLabel = 'Overview'; protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert'; - protected static ?string $slug = 'alerts'; + protected static ?string $slug = 'overview'; protected static ?string $title = 'Alerts'; protected string $view = 'filament.pages.monitoring.alerts'; + public static function canAccess(): bool + { + if (Filament::getCurrentPanel()?->getId() !== 'admin') { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + return $resolver->isMember($user, $workspace) + && $resolver->can($user, $workspace, Capabilities::ALERTS_VIEW); + } + + protected function getHeaderWidgets(): array + { + return [ + AlertsKpiHeader::class, + ]; + } + /** * @return array */ diff --git a/app/Filament/Resources/AlertDeliveryResource.php b/app/Filament/Resources/AlertDeliveryResource.php new file mode 100644 index 0000000..fd7a9c2 --- /dev/null +++ b/app/Filament/Resources/AlertDeliveryResource.php @@ -0,0 +1,278 @@ +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', AlertDelivery::class); + } + + public static function canView(Model $record): bool + { + $user = auth()->user(); + + if (! $user instanceof User || ! $record instanceof AlertDelivery) { + return false; + } + + return $user->can('view', $record); + } + + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly) + ->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'Deliveries are generated by jobs and intentionally have no empty-state CTA.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.'); + } + + public static function getEloquentQuery(): Builder + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + $user = auth()->user(); + + return parent::getEloquentQuery() + ->with(['tenant', 'rule', 'destination']) + ->when( + ! $user instanceof User, + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ) + ->when( + ! is_int($workspaceId), + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ) + ->when( + is_int($workspaceId), + fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId), + ) + ->when( + $user instanceof User, + fn (Builder $query): Builder => $query->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')), + ) + ->when( + Filament::getTenant() instanceof Tenant, + fn (Builder $query): Builder => $query->where('tenant_id', (int) Filament::getTenant()->getKey()), + ) + ->latest('id'); + } + + public static function form(Schema $schema): Schema + { + return $schema; + } + + public static function infolist(Schema $schema): Schema + { + return $schema + ->schema([ + Section::make('Delivery') + ->schema([ + TextEntry::make('status') + ->badge() + ->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state)) + ->color(fn (?string $state): string => self::statusColor((string) $state)), + TextEntry::make('event_type') + ->label('Event') + ->badge() + ->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)), + TextEntry::make('severity') + ->badge() + ->formatStateUsing(fn (?string $state): string => ucfirst((string) $state)) + ->placeholder('—'), + TextEntry::make('tenant.name') + ->label('Tenant'), + TextEntry::make('rule.name') + ->label('Rule') + ->placeholder('—'), + TextEntry::make('destination.name') + ->label('Destination') + ->placeholder('—'), + TextEntry::make('attempt_count') + ->label('Attempts'), + TextEntry::make('fingerprint_hash') + ->label('Fingerprint') + ->copyable(), + TextEntry::make('send_after') + ->dateTime() + ->placeholder('—'), + TextEntry::make('sent_at') + ->dateTime() + ->placeholder('—'), + TextEntry::make('last_error_code') + ->label('Last error code') + ->placeholder('—'), + TextEntry::make('last_error_message') + ->label('Last error message') + ->placeholder('—') + ->columnSpanFull(), + TextEntry::make('created_at') + ->dateTime(), + TextEntry::make('updated_at') + ->dateTime(), + ]) + ->columns(2) + ->columnSpanFull(), + Section::make('Payload') + ->schema([ + ViewEntry::make('payload') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (AlertDelivery $record): array => is_array($record->payload) ? $record->payload : []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('id', 'desc') + ->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record) + ? static::getUrl('view', ['record' => $record]) + : null) + ->columns([ + TextColumn::make('created_at') + ->label('Created') + ->since(), + TextColumn::make('tenant.name') + ->label('Tenant') + ->searchable(), + TextColumn::make('event_type') + ->label('Event') + ->badge(), + TextColumn::make('severity') + ->badge() + ->formatStateUsing(fn (?string $state): string => ucfirst((string) $state)) + ->placeholder('—'), + TextColumn::make('status') + ->badge() + ->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state)) + ->color(fn (?string $state): string => self::statusColor((string) $state)), + TextColumn::make('rule.name') + ->label('Rule') + ->placeholder('—'), + TextColumn::make('destination.name') + ->label('Destination') + ->placeholder('—'), + TextColumn::make('attempt_count') + ->label('Attempts'), + ]) + ->filters([ + SelectFilter::make('status') + ->options([ + AlertDelivery::STATUS_QUEUED => 'Queued', + AlertDelivery::STATUS_DEFERRED => 'Deferred', + AlertDelivery::STATUS_SENT => 'Sent', + AlertDelivery::STATUS_FAILED => 'Failed', + AlertDelivery::STATUS_SUPPRESSED => 'Suppressed', + AlertDelivery::STATUS_CANCELED => 'Canceled', + ]), + ]) + ->actions([ + ViewAction::make()->label('View'), + ]) + ->bulkActions([]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAlertDeliveries::route('/'), + 'view' => Pages\ViewAlertDelivery::route('/{record}'), + ]; + } + + private static function statusLabel(string $status): string + { + return match ($status) { + AlertDelivery::STATUS_QUEUED => 'Queued', + AlertDelivery::STATUS_DEFERRED => 'Deferred', + AlertDelivery::STATUS_SENT => 'Sent', + AlertDelivery::STATUS_FAILED => 'Failed', + AlertDelivery::STATUS_SUPPRESSED => 'Suppressed', + AlertDelivery::STATUS_CANCELED => 'Canceled', + default => ucfirst($status), + }; + } + + private static function statusColor(string $status): string + { + return match ($status) { + AlertDelivery::STATUS_QUEUED => 'gray', + AlertDelivery::STATUS_DEFERRED => 'warning', + AlertDelivery::STATUS_SENT => 'success', + AlertDelivery::STATUS_FAILED => 'danger', + AlertDelivery::STATUS_SUPPRESSED => 'info', + AlertDelivery::STATUS_CANCELED => 'gray', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php b/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php new file mode 100644 index 0000000..5a7dfd1 --- /dev/null +++ b/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php @@ -0,0 +1,22 @@ +headerActions( + scopeActionName: 'operate_hub_scope_alerts', + returnActionName: 'operate_hub_return_alerts', + ); + } +} diff --git a/app/Filament/Resources/AlertDeliveryResource/Pages/ViewAlertDelivery.php b/app/Filament/Resources/AlertDeliveryResource/Pages/ViewAlertDelivery.php new file mode 100644 index 0000000..729829b --- /dev/null +++ b/app/Filament/Resources/AlertDeliveryResource/Pages/ViewAlertDelivery.php @@ -0,0 +1,13 @@ +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', AlertDestination::class); + } + + public static function canCreate(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $user->can('create', AlertDestination::class); + } + + public static function canEdit(Model $record): bool + { + $user = auth()->user(); + + if (! $user instanceof User || ! $record instanceof AlertDestination) { + return false; + } + + return $user->can('update', $record); + } + + public static function canDelete(Model $record): bool + { + $user = auth()->user(); + + if (! $user instanceof User || ! $record instanceof AlertDestination) { + 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 destinations 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() + ->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([ + TextInput::make('name') + ->required() + ->maxLength(255), + Select::make('type') + ->required() + ->options(self::typeOptions()) + ->native(false) + ->live(), + Toggle::make('is_enabled') + ->label('Enabled') + ->default(true), + TextInput::make('teams_webhook_url') + ->label('Teams webhook URL') + ->placeholder('https://...') + ->url() + ->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_TEAMS_WEBHOOK), + TagsInput::make('email_recipients') + ->label('Email recipients') + ->visible(fn (Get $get): bool => $get('type') === AlertDestination::TYPE_EMAIL) + ->placeholder('ops@example.com') + ->nestedRecursiveRules(['email']), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('name') + ->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record) + ? static::getUrl('edit', ['record' => $record]) + : null) + ->columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('type') + ->badge() + ->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)), + TextColumn::make('is_enabled') + ->label('Enabled') + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No') + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), + TextColumn::make('updated_at') + ->since(), + ]) + ->actions([ + EditAction::make() + ->label('Edit') + ->visible(fn (AlertDestination $record): bool => static::canEdit($record)), + ActionGroup::make([ + Action::make('toggle_enabled') + ->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable') + ->icon(fn (AlertDestination $record): string => $record->is_enabled ? 'heroicon-o-pause' : 'heroicon-o-play') + ->action(function (AlertDestination $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::AlertDestinationEnabled + : AuditActionId::AlertDestinationDisabled; + + self::audit($record, $actionId, [ + 'alert_destination_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'type' => (string) $record->type, + 'is_enabled' => $enabled, + ]); + + Notification::make() + ->title($enabled ? 'Destination enabled' : 'Destination disabled') + ->success() + ->send(); + }), + Action::make('delete') + ->label('Delete') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(function (AlertDestination $record): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $user->can('delete', $record)) { + throw new AuthorizationException; + } + + self::audit($record, AuditActionId::AlertDestinationDeleted, [ + 'alert_destination_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'type' => (string) $record->type, + ]); + + $record->delete(); + + Notification::make() + ->title('Destination deleted') + ->success() + ->send(); + }), + ])->label('More'), + ]) + ->bulkActions([ + BulkActionGroup::make([])->label('More'), + ]) + ->emptyStateActions([ + \Filament\Actions\CreateAction::make() + ->label('Create target') + ->disabled(fn (): bool => ! static::canCreate()), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAlertDestinations::route('/'), + 'create' => Pages\CreateAlertDestination::route('/create'), + 'edit' => Pages\EditAlertDestination::route('/{record}/edit'), + ]; + } + + /** + * @param array $data + */ + public static function normalizePayload(array $data, ?AlertDestination $record = null): array + { + $type = trim((string) ($data['type'] ?? $record?->type ?? '')); + $existingConfig = is_array($record?->config ?? null) ? $record->config : []; + + if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) { + $webhookUrl = trim((string) ($data['teams_webhook_url'] ?? '')); + + if ($webhookUrl === '' && $record instanceof AlertDestination) { + $webhookUrl = trim((string) Arr::get($existingConfig, 'webhook_url', '')); + } + + $data['config'] = [ + 'webhook_url' => $webhookUrl, + ]; + } + + if ($type === AlertDestination::TYPE_EMAIL) { + $recipients = Arr::wrap($data['email_recipients'] ?? []); + $recipients = array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))); + + if ($recipients === [] && $record instanceof AlertDestination) { + $existingRecipients = Arr::get($existingConfig, 'recipients', []); + $recipients = is_array($existingRecipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $existingRecipients))) : []; + } + + $data['config'] = [ + 'recipients' => array_values(array_unique($recipients)), + ]; + } + + unset($data['teams_webhook_url'], $data['email_recipients']); + + return $data; + } + + /** + * @return array + */ + public static function typeOptions(): array + { + return [ + AlertDestination::TYPE_TEAMS_WEBHOOK => 'Microsoft Teams webhook', + AlertDestination::TYPE_EMAIL => 'Email', + ]; + } + + public static function typeLabel(string $type): string + { + return self::typeOptions()[$type] ?? ucfirst($type); + } + + /** + * @param array $data + */ + public static function assertValidConfigPayload(array $data): void + { + $type = (string) ($data['type'] ?? ''); + $config = is_array($data['config'] ?? null) ? $data['config'] : []; + + if ($type === AlertDestination::TYPE_TEAMS_WEBHOOK) { + $webhook = trim((string) Arr::get($config, 'webhook_url', '')); + + if ($webhook === '') { + throw ValidationException::withMessages([ + 'teams_webhook_url' => ['The Teams webhook URL is required.'], + ]); + } + } + + if ($type === AlertDestination::TYPE_EMAIL) { + $recipients = Arr::get($config, 'recipients', []); + $recipients = is_array($recipients) ? array_values(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients))) : []; + + if ($recipients === []) { + throw ValidationException::withMessages([ + 'email_recipients' => ['At least one recipient is required for email destinations.'], + ]); + } + } + } + + /** + * @param array $metadata + */ + public static function audit(AlertDestination $record, AuditActionId $actionId, array $metadata): void + { + $workspace = $record->workspace; + + if ($workspace === null) { + return; + } + + app(WorkspaceAuditLogger::class)->log( + workspace: $workspace, + action: $actionId->value, + context: [ + 'metadata' => $metadata, + ], + actor: auth()->user() instanceof User ? auth()->user() : null, + resourceType: 'alert_destination', + resourceId: (string) $record->getKey(), + ); + } +} diff --git a/app/Filament/Resources/AlertDestinationResource/Pages/CreateAlertDestination.php b/app/Filament/Resources/AlertDestinationResource/Pages/CreateAlertDestination.php new file mode 100644 index 0000000..fc37dbd --- /dev/null +++ b/app/Filament/Resources/AlertDestinationResource/Pages/CreateAlertDestination.php @@ -0,0 +1,47 @@ +currentWorkspaceId(request()); + $data['workspace_id'] = (int) $workspaceId; + $data = AlertDestinationResource::normalizePayload($data); + AlertDestinationResource::assertValidConfigPayload($data); + + return $data; + } + + protected function afterCreate(): void + { + $record = $this->record; + + if (! $record instanceof AlertDestination) { + return; + } + + AlertDestinationResource::audit($record, AuditActionId::AlertDestinationCreated, [ + 'alert_destination_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'type' => (string) $record->type, + 'is_enabled' => (bool) $record->is_enabled, + ]); + + Notification::make() + ->title('Destination created') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php b/app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php new file mode 100644 index 0000000..2bbd2c1 --- /dev/null +++ b/app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php @@ -0,0 +1,49 @@ +record; + $data = AlertDestinationResource::normalizePayload( + data: $data, + record: $record instanceof AlertDestination ? $record : null, + ); + AlertDestinationResource::assertValidConfigPayload($data); + + return $data; + } + + protected function afterSave(): void + { + $record = $this->record; + + if (! $record instanceof AlertDestination) { + return; + } + + AlertDestinationResource::audit($record, AuditActionId::AlertDestinationUpdated, [ + 'alert_destination_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'type' => (string) $record->type, + 'is_enabled' => (bool) $record->is_enabled, + ]); + + Notification::make() + ->title('Destination updated') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/AlertDestinationResource/Pages/ListAlertDestinations.php b/app/Filament/Resources/AlertDestinationResource/Pages/ListAlertDestinations.php new file mode 100644 index 0000000..6116bb1 --- /dev/null +++ b/app/Filament/Resources/AlertDestinationResource/Pages/ListAlertDestinations.php @@ -0,0 +1,37 @@ +headerActions( + scopeActionName: 'operate_hub_scope_alerts', + returnActionName: 'operate_hub_return_alerts', + ), + CreateAction::make() + ->label('Create target') + ->disabled(fn (): bool => ! AlertDestinationResource::canCreate()), + ]; + } + + protected function getTableEmptyStateActions(): array + { + return [ + CreateAction::make() + ->label('Create target') + ->disabled(fn (): bool => ! AlertDestinationResource::canCreate()), + ]; + } +} diff --git a/app/Filament/Resources/AlertRuleResource.php b/app/Filament/Resources/AlertRuleResource.php new file mode 100644 index 0000000..fd27be7 --- /dev/null +++ b/app/Filament/Resources/AlertRuleResource.php @@ -0,0 +1,462 @@ +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([ + 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), + Select::make('tenant_scope_mode') + ->required() + ->options([ + AlertRule::TENANT_SCOPE_ALL => 'All tenants', + AlertRule::TENANT_SCOPE_ALLOWLIST => 'Allowlist', + ]) + ->default(AlertRule::TENANT_SCOPE_ALL) + ->native(false) + ->live(), + Select::make('tenant_allowlist') + ->label('Tenant allowlist') + ->multiple() + ->options(self::tenantOptions()) + ->visible(fn (Get $get): bool => $get('tenant_scope_mode') === AlertRule::TENANT_SCOPE_ALLOWLIST) + ->native(false), + 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 $data + * @return array + */ + 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 $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 + */ + public static function eventTypeOptions(): array + { + return [ + AlertRule::EVENT_HIGH_DRIFT => 'High drift', + AlertRule::EVENT_COMPARE_FAILED => 'Compare failed', + AlertRule::EVENT_SLA_DUE => 'SLA due', + ]; + } + + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + private static function timezoneOptions(): array + { + $identifiers = \DateTimeZone::listIdentifiers(); + sort($identifiers); + + return array_combine($identifiers, $identifiers); + } + + /** + * @param array $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(), + ); + } +} diff --git a/app/Filament/Resources/AlertRuleResource/Pages/CreateAlertRule.php b/app/Filament/Resources/AlertRuleResource/Pages/CreateAlertRule.php new file mode 100644 index 0000000..2777095 --- /dev/null +++ b/app/Filament/Resources/AlertRuleResource/Pages/CreateAlertRule.php @@ -0,0 +1,62 @@ + + */ + private array $destinationIds = []; + + protected function mutateFormDataBeforeCreate(array $data): array + { + $workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspaceId(request()); + $data['workspace_id'] = (int) $workspaceId; + + $this->destinationIds = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): int => (int) $value, + Arr::wrap($data['destination_ids'] ?? []), + )))); + + unset($data['destination_ids']); + + return AlertRuleResource::normalizePayload($data); + } + + protected function afterCreate(): void + { + $record = $this->record; + + if (! $record instanceof AlertRule) { + return; + } + + AlertRuleResource::syncDestinations($record, $this->destinationIds); + + AlertRuleResource::audit($record, AuditActionId::AlertRuleCreated, [ + 'alert_rule_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'event_type' => (string) $record->event_type, + 'minimum_severity' => (string) $record->minimum_severity, + 'is_enabled' => (bool) $record->is_enabled, + 'destination_ids' => $this->destinationIds, + ]); + + Notification::make() + ->title('Rule created') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/AlertRuleResource/Pages/EditAlertRule.php b/app/Filament/Resources/AlertRuleResource/Pages/EditAlertRule.php new file mode 100644 index 0000000..9441bf1 --- /dev/null +++ b/app/Filament/Resources/AlertRuleResource/Pages/EditAlertRule.php @@ -0,0 +1,73 @@ + + */ + private array $destinationIds = []; + + protected function mutateFormDataBeforeFill(array $data): array + { + $record = $this->record; + + if ($record instanceof AlertRule) { + $data['destination_ids'] = $record->destinations() + ->pluck('alert_destinations.id') + ->map(static fn (mixed $value): int => (int) $value) + ->all(); + } + + return $data; + } + + protected function mutateFormDataBeforeSave(array $data): array + { + $this->destinationIds = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): int => (int) $value, + Arr::wrap($data['destination_ids'] ?? []), + )))); + + unset($data['destination_ids']); + + return AlertRuleResource::normalizePayload($data); + } + + protected function afterSave(): void + { + $record = $this->record; + + if (! $record instanceof AlertRule) { + return; + } + + AlertRuleResource::syncDestinations($record, $this->destinationIds); + + AlertRuleResource::audit($record, AuditActionId::AlertRuleUpdated, [ + 'alert_rule_id' => (int) $record->getKey(), + 'name' => (string) $record->name, + 'event_type' => (string) $record->event_type, + 'minimum_severity' => (string) $record->minimum_severity, + 'is_enabled' => (bool) $record->is_enabled, + 'destination_ids' => $this->destinationIds, + ]); + + Notification::make() + ->title('Rule updated') + ->success() + ->send(); + } +} diff --git a/app/Filament/Resources/AlertRuleResource/Pages/ListAlertRules.php b/app/Filament/Resources/AlertRuleResource/Pages/ListAlertRules.php new file mode 100644 index 0000000..f0b0012 --- /dev/null +++ b/app/Filament/Resources/AlertRuleResource/Pages/ListAlertRules.php @@ -0,0 +1,37 @@ +headerActions( + scopeActionName: 'operate_hub_scope_alerts', + returnActionName: 'operate_hub_return_alerts', + ), + CreateAction::make() + ->label('Create rule') + ->disabled(fn (): bool => ! AlertRuleResource::canCreate()), + ]; + } + + protected function getTableEmptyStateActions(): array + { + return [ + CreateAction::make() + ->label('Create rule') + ->disabled(fn (): bool => ! AlertRuleResource::canCreate()), + ]; + } +} diff --git a/app/Filament/Widgets/Alerts/AlertsKpiHeader.php b/app/Filament/Widgets/Alerts/AlertsKpiHeader.php new file mode 100644 index 0000000..035dac2 --- /dev/null +++ b/app/Filament/Widgets/Alerts/AlertsKpiHeader.php @@ -0,0 +1,107 @@ + + */ + protected function getStats(): array + { + $user = auth()->user(); + + if (! $user instanceof User) { + return []; + } + + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return []; + } + + $stats = []; + + if (AlertRuleResource::canViewAny()) { + $totalRules = (int) AlertRule::query() + ->where('workspace_id', $workspaceId) + ->count(); + + $enabledRules = (int) AlertRule::query() + ->where('workspace_id', $workspaceId) + ->where('is_enabled', true) + ->count(); + + $stats[] = Stat::make('Enabled rules', $enabledRules) + ->description('Total '.$totalRules); + } + + if (AlertDestinationResource::canViewAny()) { + $totalDestinations = (int) AlertDestination::query() + ->where('workspace_id', $workspaceId) + ->count(); + + $enabledDestinations = (int) AlertDestination::query() + ->where('workspace_id', $workspaceId) + ->where('is_enabled', true) + ->count(); + + $stats[] = Stat::make('Enabled targets', $enabledDestinations) + ->description('Total '.$totalDestinations); + } + + if (AlertDeliveryResource::canViewAny()) { + $deliveriesQuery = $this->deliveriesQueryForViewer($user, $workspaceId); + + $deliveries24Hours = (int) (clone $deliveriesQuery) + ->where('created_at', '>=', now()->subDay()) + ->count(); + + $failed7Days = (int) (clone $deliveriesQuery) + ->where('created_at', '>=', now()->subDays(7)) + ->where('status', AlertDelivery::STATUS_FAILED) + ->count(); + + $stats[] = Stat::make('Deliveries (24h)', $deliveries24Hours); + $stats[] = Stat::make('Failed (7d)', $failed7Days); + } + + return $stats; + } + + private function deliveriesQueryForViewer(User $user, int $workspaceId): Builder + { + $query = AlertDelivery::query() + ->where('workspace_id', $workspaceId) + ->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')); + + $activeTenant = Filament::getTenant(); + + if ($activeTenant instanceof Tenant) { + $query->where('tenant_id', (int) $activeTenant->getKey()); + } + + return $query; + } +} diff --git a/app/Jobs/Alerts/.gitkeep b/app/Jobs/Alerts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Jobs/Alerts/DeliverAlertsJob.php b/app/Jobs/Alerts/DeliverAlertsJob.php new file mode 100644 index 0000000..e95f0ad --- /dev/null +++ b/app/Jobs/Alerts/DeliverAlertsJob.php @@ -0,0 +1,216 @@ +whereKey($this->workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return; + } + + $operationRun = $this->resolveOperationRun($workspace, $operationRuns); + + if (! $operationRun instanceof OperationRun) { + return; + } + + if ($operationRun->status === OperationRunStatus::Completed->value) { + return; + } + + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Running->value, + outcome: OperationRunOutcome::Pending->value, + ); + + $now = CarbonImmutable::now('UTC'); + $batchSize = max(1, (int) config('tenantpilot.alerts.deliver_batch_size', 200)); + $maxAttempts = max(1, (int) config('tenantpilot.alerts.delivery_max_attempts', 3)); + + $deliveries = AlertDelivery::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->whereIn('status', [ + AlertDelivery::STATUS_QUEUED, + AlertDelivery::STATUS_DEFERRED, + ]) + ->where(function ($query) use ($now): void { + $query->whereNull('send_after') + ->orWhere('send_after', '<=', $now); + }) + ->orderBy('id') + ->limit($batchSize) + ->get(); + + $processed = 0; + $succeeded = 0; + $terminalFailures = 0; + $requeued = 0; + + foreach ($deliveries as $delivery) { + if ($delivery->isTerminal()) { + continue; + } + + $processed++; + + try { + $alertSender->send($delivery); + + $delivery->forceFill([ + 'attempt_count' => (int) $delivery->attempt_count + 1, + 'status' => AlertDelivery::STATUS_SENT, + 'send_after' => null, + 'sent_at' => $now, + 'last_error_code' => null, + 'last_error_message' => null, + ])->save(); + + $succeeded++; + } catch (Throwable $exception) { + $attemptCount = (int) $delivery->attempt_count + 1; + $errorCode = $this->sanitizeErrorCode($exception); + $errorMessage = $this->sanitizeErrorMessage($exception); + + if ($attemptCount >= $maxAttempts) { + $delivery->forceFill([ + 'attempt_count' => $attemptCount, + 'status' => AlertDelivery::STATUS_FAILED, + 'send_after' => null, + 'last_error_code' => $errorCode, + 'last_error_message' => $errorMessage, + ])->save(); + + $terminalFailures++; + + continue; + } + + $delivery->forceFill([ + 'attempt_count' => $attemptCount, + 'status' => AlertDelivery::STATUS_QUEUED, + 'send_after' => $now->addSeconds($this->backoffSeconds($attemptCount)), + 'last_error_code' => $errorCode, + 'last_error_message' => $errorMessage, + ])->save(); + + $requeued++; + } + } + + $outcome = OperationRunOutcome::Succeeded->value; + + if ($terminalFailures > 0 && $succeeded === 0 && $requeued === 0) { + $outcome = OperationRunOutcome::Failed->value; + } elseif ($terminalFailures > 0 || $requeued > 0) { + $outcome = OperationRunOutcome::PartiallySucceeded->value; + } + + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: $outcome, + summaryCounts: [ + 'total' => count($deliveries), + 'processed' => $processed, + 'succeeded' => $succeeded, + 'failed' => $terminalFailures, + 'skipped' => $requeued, + ], + ); + } + + private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun + { + if (is_int($this->operationRunId) && $this->operationRunId > 0) { + $operationRun = OperationRun::query() + ->whereKey($this->operationRunId) + ->where('workspace_id', (int) $workspace->getKey()) + ->where('type', 'alerts.deliver') + ->first(); + + if ($operationRun instanceof OperationRun) { + return $operationRun; + } + } + + $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; + + return $operationRuns->ensureWorkspaceRunWithIdentity( + workspace: $workspace, + type: 'alerts.deliver', + identityInputs: [ + 'slot_key' => $slotKey, + ], + context: [ + 'trigger' => 'job', + 'slot_key' => $slotKey, + ], + initiator: null, + ); + } + + private function backoffSeconds(int $attemptCount): int + { + $baseSeconds = max(1, (int) config('tenantpilot.alerts.delivery_retry_base_seconds', 60)); + $maxSeconds = max($baseSeconds, (int) config('tenantpilot.alerts.delivery_retry_max_seconds', 900)); + + $exponent = max(0, $attemptCount - 1); + $delaySeconds = $baseSeconds * (2 ** $exponent); + + return (int) min($maxSeconds, $delaySeconds); + } + + private function sanitizeErrorCode(Throwable $exception): string + { + $shortName = class_basename($exception); + $shortName = trim((string) $shortName); + + if ($shortName === '') { + return 'delivery_exception'; + } + + return strtolower(preg_replace('/[^a-z0-9]+/i', '_', $shortName) ?? 'delivery_exception'); + } + + private function sanitizeErrorMessage(Throwable $exception): string + { + $message = trim($exception->getMessage()); + + if ($message === '') { + return 'Alert delivery failed.'; + } + + $message = preg_replace('/https?:\/\/\S+/i', '[redacted-url]', $message) ?? $message; + + return mb_substr($message, 0, 500); + } +} diff --git a/app/Jobs/Alerts/EvaluateAlertsJob.php b/app/Jobs/Alerts/EvaluateAlertsJob.php new file mode 100644 index 0000000..3102d4a --- /dev/null +++ b/app/Jobs/Alerts/EvaluateAlertsJob.php @@ -0,0 +1,256 @@ +whereKey($this->workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return; + } + + $operationRun = $this->resolveOperationRun($workspace, $operationRuns); + + if (! $operationRun instanceof OperationRun) { + return; + } + + if ($operationRun->status === OperationRunStatus::Completed->value) { + return; + } + + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Running->value, + outcome: OperationRunOutcome::Pending->value, + ); + + $windowStart = $this->resolveWindowStart($operationRun); + + try { + $events = [ + ...$this->highDriftEvents((int) $workspace->getKey(), $windowStart), + ...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart), + ]; + + $createdDeliveries = 0; + + foreach ($events as $event) { + $createdDeliveries += $dispatchService->dispatchEvent($workspace, $event); + } + + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: [ + 'total' => count($events), + 'processed' => count($events), + 'created' => $createdDeliveries, + ], + ); + } catch (Throwable $exception) { + $operationRuns->updateRun( + $operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [ + [ + 'code' => 'alerts.evaluate.failed', + 'message' => $this->sanitizeErrorMessage($exception), + ], + ], + ); + + throw $exception; + } + } + + private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun + { + if (is_int($this->operationRunId) && $this->operationRunId > 0) { + $operationRun = OperationRun::query() + ->whereKey($this->operationRunId) + ->where('workspace_id', (int) $workspace->getKey()) + ->where('type', 'alerts.evaluate') + ->first(); + + if ($operationRun instanceof OperationRun) { + return $operationRun; + } + } + + $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; + + return $operationRuns->ensureWorkspaceRunWithIdentity( + workspace: $workspace, + type: 'alerts.evaluate', + identityInputs: [ + 'slot_key' => $slotKey, + ], + context: [ + 'trigger' => 'job', + 'slot_key' => $slotKey, + ], + initiator: null, + ); + } + + private function resolveWindowStart(OperationRun $operationRun): CarbonImmutable + { + $previous = OperationRun::query() + ->where('workspace_id', (int) $operationRun->workspace_id) + ->whereNull('tenant_id') + ->where('type', 'alerts.evaluate') + ->where('status', OperationRunStatus::Completed->value) + ->whereNotNull('completed_at') + ->where('id', '<', (int) $operationRun->getKey()) + ->orderByDesc('completed_at') + ->orderByDesc('id') + ->first(); + + if ($previous instanceof OperationRun && $previous->completed_at !== null) { + return CarbonImmutable::instance($previous->completed_at); + } + + $lookbackMinutes = max(1, (int) config('tenantpilot.alerts.evaluate_initial_lookback_minutes', 15)); + + return CarbonImmutable::now('UTC')->subMinutes($lookbackMinutes); + } + + /** + * @return array> + */ + private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array + { + $findings = Finding::query() + ->where('workspace_id', $workspaceId) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->whereIn('severity', [Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL]) + ->where('status', Finding::STATUS_NEW) + ->where('created_at', '>', $windowStart) + ->orderBy('id') + ->get(); + + $events = []; + + foreach ($findings as $finding) { + $events[] = [ + 'event_type' => 'high_drift', + 'tenant_id' => (int) $finding->tenant_id, + 'severity' => (string) $finding->severity, + 'fingerprint_key' => 'finding:'.(int) $finding->getKey(), + 'title' => 'High drift finding detected', + 'body' => sprintf( + 'Finding %d was created with severity %s.', + (int) $finding->getKey(), + (string) $finding->severity, + ), + 'metadata' => [ + 'finding_id' => (int) $finding->getKey(), + ], + ]; + } + + return $events; + } + + /** + * @return array> + */ + private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array + { + $failedRuns = OperationRun::query() + ->where('workspace_id', $workspaceId) + ->whereNotNull('tenant_id') + ->where('type', 'drift_generate_findings') + ->where('status', OperationRunStatus::Completed->value) + ->where('outcome', OperationRunOutcome::Failed->value) + ->where('created_at', '>', $windowStart) + ->orderBy('id') + ->get(); + + $events = []; + + foreach ($failedRuns as $failedRun) { + $tenantId = (int) ($failedRun->tenant_id ?? 0); + + if ($tenantId <= 0) { + continue; + } + + $events[] = [ + 'event_type' => 'compare_failed', + 'tenant_id' => $tenantId, + 'severity' => 'high', + 'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(), + 'title' => 'Drift compare failed', + 'body' => $this->firstFailureMessage($failedRun), + 'metadata' => [ + 'operation_run_id' => (int) $failedRun->getKey(), + ], + ]; + } + + return $events; + } + + private function firstFailureMessage(OperationRun $run): string + { + $failures = is_array($run->failure_summary) ? $run->failure_summary : []; + + foreach ($failures as $failure) { + if (! is_array($failure)) { + continue; + } + + $message = trim((string) ($failure['message'] ?? '')); + + if ($message !== '') { + return $message; + } + } + + return 'A drift compare operation run failed.'; + } + + private function sanitizeErrorMessage(Throwable $exception): string + { + $message = trim($exception->getMessage()); + + if ($message === '') { + return 'Unexpected alert evaluation error.'; + } + + $message = preg_replace('/https?:\/\/\S+/i', '[redacted-url]', $message) ?? $message; + + return mb_substr($message, 0, 500); + } +} diff --git a/app/Models/AlertDelivery.php b/app/Models/AlertDelivery.php new file mode 100644 index 0000000..e574ba3 --- /dev/null +++ b/app/Models/AlertDelivery.php @@ -0,0 +1,77 @@ + 'datetime', + 'sent_at' => 'datetime', + 'payload' => 'array', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function rule(): BelongsTo + { + return $this->belongsTo(AlertRule::class, 'alert_rule_id'); + } + + public function destination(): BelongsTo + { + return $this->belongsTo(AlertDestination::class, 'alert_destination_id'); + } + + public function prunable(): Builder + { + $retentionDays = (int) config('tenantpilot.alerts.delivery_retention_days', 90); + $retentionDays = max(1, $retentionDays); + + return static::query()->where('created_at', '<', now()->subDays($retentionDays)); + } + + public function isTerminal(): bool + { + return in_array($this->status, [ + self::STATUS_SENT, + self::STATUS_FAILED, + self::STATUS_SUPPRESSED, + self::STATUS_CANCELED, + ], true); + } +} diff --git a/app/Models/AlertDestination.php b/app/Models/AlertDestination.php new file mode 100644 index 0000000..71e78ce --- /dev/null +++ b/app/Models/AlertDestination.php @@ -0,0 +1,49 @@ + 'boolean', + 'config' => 'encrypted:array', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function rules(): BelongsToMany + { + return $this->belongsToMany(AlertRule::class, 'alert_rule_destinations') + ->using(AlertRuleDestination::class) + ->withPivot(['id', 'workspace_id']) + ->withTimestamps(); + } + + public function deliveries(): HasMany + { + return $this->hasMany(AlertDelivery::class); + } +} diff --git a/app/Models/AlertRule.php b/app/Models/AlertRule.php new file mode 100644 index 0000000..b961b98 --- /dev/null +++ b/app/Models/AlertRule.php @@ -0,0 +1,65 @@ + 'boolean', + 'tenant_allowlist' => 'array', + 'cooldown_seconds' => 'integer', + 'quiet_hours_enabled' => 'boolean', + ]; + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function destinations(): BelongsToMany + { + return $this->belongsToMany(AlertDestination::class, 'alert_rule_destinations') + ->using(AlertRuleDestination::class) + ->withPivot(['id', 'workspace_id']) + ->withTimestamps(); + } + + public function deliveries(): HasMany + { + return $this->hasMany(AlertDelivery::class); + } + + public function appliesToTenant(int $tenantId): bool + { + if ($this->tenant_scope_mode !== self::TENANT_SCOPE_ALLOWLIST) { + return true; + } + + $allowlist = is_array($this->tenant_allowlist) ? $this->tenant_allowlist : []; + $allowlist = array_values(array_unique(array_map(static fn (mixed $value): int => (int) $value, $allowlist))); + + return in_array($tenantId, $allowlist, true); + } +} diff --git a/app/Models/AlertRuleDestination.php b/app/Models/AlertRuleDestination.php new file mode 100644 index 0000000..c6d0fcf --- /dev/null +++ b/app/Models/AlertRuleDestination.php @@ -0,0 +1,30 @@ +belongsTo(Workspace::class); + } + + public function alertRule(): BelongsTo + { + return $this->belongsTo(AlertRule::class); + } + + public function alertDestination(): BelongsTo + { + return $this->belongsTo(AlertDestination::class); + } +} diff --git a/app/Notifications/Alerts/EmailAlertNotification.php b/app/Notifications/Alerts/EmailAlertNotification.php new file mode 100644 index 0000000..521c82c --- /dev/null +++ b/app/Notifications/Alerts/EmailAlertNotification.php @@ -0,0 +1,52 @@ + $payload + */ + public function __construct( + private readonly AlertDelivery $delivery, + private readonly array $payload, + ) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $title = trim((string) ($this->payload['title'] ?? 'Alert')); + $body = trim((string) ($this->payload['body'] ?? 'A matching alert event was detected.')); + + if ($title === '') { + $title = 'Alert'; + } + + if ($body === '') { + $body = 'A matching alert event was detected.'; + } + + return (new MailMessage) + ->subject($title) + ->line($body) + ->line('Delivery ID: '.(int) $this->delivery->getKey()) + ->line('Event type: '.(string) $this->delivery->event_type) + ->line('Status: '.(string) $this->delivery->status); + } +} diff --git a/app/Policies/AlertDeliveryPolicy.php b/app/Policies/AlertDeliveryPolicy.php new file mode 100644 index 0000000..643eb34 --- /dev/null +++ b/app/Policies/AlertDeliveryPolicy.php @@ -0,0 +1,91 @@ +currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW); + } + + public function view(User $user, AlertDelivery $alertDelivery): bool|Response + { + $workspace = $this->currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + if ((int) $alertDelivery->workspace_id !== (int) $workspace->getKey()) { + return Response::denyAsNotFound(); + } + + $tenant = $alertDelivery->tenant; + + if (! $tenant instanceof Tenant) { + return Response::denyAsNotFound(); + } + + if (! $user->canAccessTenant($tenant)) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW); + } + + private function currentWorkspace(User $user): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return null; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + return null; + } + + return $workspace; + } + + private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $workspace, $capability) + ? Response::allow() + : Response::deny(); + } +} diff --git a/app/Policies/AlertDestinationPolicy.php b/app/Policies/AlertDestinationPolicy.php new file mode 100644 index 0000000..434352f --- /dev/null +++ b/app/Policies/AlertDestinationPolicy.php @@ -0,0 +1,106 @@ +currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW); + } + + public function view(User $user, AlertDestination $alertDestination): bool|Response + { + return $this->authorizeForRecordWorkspace($user, $alertDestination, Capabilities::ALERTS_VIEW); + } + + public function create(User $user): bool|Response + { + $workspace = $this->currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_MANAGE); + } + + public function update(User $user, AlertDestination $alertDestination): bool|Response + { + return $this->authorizeForRecordWorkspace($user, $alertDestination, Capabilities::ALERTS_MANAGE); + } + + public function delete(User $user, AlertDestination $alertDestination): bool|Response + { + return $this->authorizeForRecordWorkspace($user, $alertDestination, Capabilities::ALERTS_MANAGE); + } + + private function currentWorkspace(User $user): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return null; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + return null; + } + + return $workspace; + } + + private function authorizeForRecordWorkspace(User $user, AlertDestination $alertDestination, string $capability): bool|Response + { + $workspace = $this->currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + if ((int) $alertDestination->workspace_id !== (int) $workspace->getKey()) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, $capability); + } + + private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $workspace, $capability) + ? Response::allow() + : Response::deny(); + } +} diff --git a/app/Policies/AlertRulePolicy.php b/app/Policies/AlertRulePolicy.php new file mode 100644 index 0000000..077b39d --- /dev/null +++ b/app/Policies/AlertRulePolicy.php @@ -0,0 +1,106 @@ +currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW); + } + + public function view(User $user, AlertRule $alertRule): bool|Response + { + return $this->authorizeForRecordWorkspace($user, $alertRule, Capabilities::ALERTS_VIEW); + } + + public function create(User $user): bool|Response + { + $workspace = $this->currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_MANAGE); + } + + public function update(User $user, AlertRule $alertRule): bool|Response + { + return $this->authorizeForRecordWorkspace($user, $alertRule, Capabilities::ALERTS_MANAGE); + } + + public function delete(User $user, AlertRule $alertRule): bool|Response + { + return $this->authorizeForRecordWorkspace($user, $alertRule, Capabilities::ALERTS_MANAGE); + } + + private function currentWorkspace(User $user): ?Workspace + { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return null; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return null; + } + + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + return null; + } + + return $workspace; + } + + private function authorizeForRecordWorkspace(User $user, AlertRule $alertRule, string $capability): bool|Response + { + $workspace = $this->currentWorkspace($user); + + if (! $workspace instanceof Workspace) { + return Response::denyAsNotFound(); + } + + if ((int) $alertRule->workspace_id !== (int) $workspace->getKey()) { + return Response::denyAsNotFound(); + } + + return $this->authorizeForWorkspace($user, $workspace, $capability); + } + + private function authorizeForWorkspace(User $user, Workspace $workspace, string $capability): bool|Response + { + /** @var WorkspaceCapabilityResolver $resolver */ + $resolver = app(WorkspaceCapabilityResolver::class); + + if (! $resolver->isMember($user, $workspace)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $workspace, $capability) + ? Response::allow() + : Response::deny(); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index e4426cd..18b3f2c 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,12 +2,18 @@ namespace App\Providers; +use App\Models\AlertDelivery; +use App\Models\AlertDestination; +use App\Models\AlertRule; use App\Models\PlatformUser; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceSetting; +use App\Policies\AlertDeliveryPolicy; +use App\Policies\AlertDestinationPolicy; +use App\Policies\AlertRulePolicy; use App\Policies\ProviderConnectionPolicy; use App\Policies\WorkspaceSettingPolicy; use App\Services\Auth\CapabilityResolver; @@ -22,6 +28,9 @@ class AuthServiceProvider extends ServiceProvider protected $policies = [ ProviderConnection::class => ProviderConnectionPolicy::class, WorkspaceSetting::class => WorkspaceSettingPolicy::class, + AlertDestination::class => AlertDestinationPolicy::class, + AlertDelivery::class => AlertDeliveryPolicy::class, + AlertRule::class => AlertRulePolicy::class, ]; public function boot(): void diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 7575a55..5196825 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -9,6 +9,9 @@ use App\Filament\Pages\NoAccess; use App\Filament\Pages\Settings\WorkspaceSettings; use App\Filament\Pages\TenantRequiredPermissions; +use App\Filament\Resources\AlertDeliveryResource; +use App\Filament\Resources\AlertDestinationResource; +use App\Filament\Resources\AlertRuleResource; use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\PolicyResource; use App\Filament\Resources\ProviderConnectionResource; @@ -119,11 +122,6 @@ public function panel(Panel $panel): Panel ->icon('heroicon-o-queue-list') ->group('Monitoring') ->sort(10), - NavigationItem::make('Alerts') - ->url(fn (): string => route('admin.monitoring.alerts')) - ->icon('heroicon-o-bell-alert') - ->group('Monitoring') - ->sort(20), NavigationItem::make('Audit Log') ->url(fn (): string => route('admin.monitoring.audit-log')) ->icon('heroicon-o-clipboard-document-list') @@ -149,6 +147,9 @@ public function panel(Panel $panel): Panel PolicyResource::class, ProviderConnectionResource::class, InventoryItemResource::class, + AlertDestinationResource::class, + AlertRuleResource::class, + AlertDeliveryResource::class, WorkspaceResource::class, ]) ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') diff --git a/app/Providers/Filament/TenantPanelProvider.php b/app/Providers/Filament/TenantPanelProvider.php index 49e5166..e53fc41 100644 --- a/app/Providers/Filament/TenantPanelProvider.php +++ b/app/Providers/Filament/TenantPanelProvider.php @@ -47,7 +47,7 @@ public function panel(Panel $panel): Panel ->group('Monitoring') ->sort(10), NavigationItem::make('Alerts') - ->url(fn (): string => route('admin.monitoring.alerts')) + ->url(fn (): string => url('/admin/alerts')) ->icon('heroicon-o-bell-alert') ->group('Monitoring') ->sort(20), diff --git a/app/Services/Alerts/.gitkeep b/app/Services/Alerts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Services/Alerts/AlertDispatchService.php b/app/Services/Alerts/AlertDispatchService.php new file mode 100644 index 0000000..9ac9e43 --- /dev/null +++ b/app/Services/Alerts/AlertDispatchService.php @@ -0,0 +1,192 @@ + $event + */ + public function dispatchEvent(Workspace $workspace, array $event): int + { + $workspaceId = (int) $workspace->getKey(); + $tenantId = (int) ($event['tenant_id'] ?? 0); + $eventType = trim((string) ($event['event_type'] ?? '')); + + if ($workspaceId <= 0 || $tenantId <= 0 || $eventType === '') { + return 0; + } + + $tenant = Tenant::query() + ->whereKey($tenantId) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $tenant instanceof Tenant) { + return 0; + } + + $now = CarbonImmutable::now('UTC'); + $eventSeverity = $this->normalizeSeverity((string) ($event['severity'] ?? '')); + + $rules = AlertRule::query() + ->with(['destinations' => fn ($query) => $query->where('is_enabled', true)]) + ->where('workspace_id', $workspaceId) + ->where('is_enabled', true) + ->where('event_type', $eventType) + ->orderBy('id') + ->get(); + + $createdDeliveries = 0; + + foreach ($rules as $rule) { + if (! $rule->appliesToTenant($tenantId)) { + continue; + } + + if (! $this->meetsMinimumSeverity($eventSeverity, (string) $rule->minimum_severity)) { + continue; + } + + foreach ($rule->destinations as $destination) { + $fingerprintHash = $this->fingerprintService->hash($rule, $destination, $tenantId, $event); + + $isSuppressed = $this->shouldSuppress( + workspaceId: $workspaceId, + ruleId: (int) $rule->getKey(), + destinationId: (int) $destination->getKey(), + fingerprintHash: $fingerprintHash, + cooldownSeconds: (int) ($rule->cooldown_seconds ?? 0), + now: $now, + ); + + $sendAfter = null; + $status = AlertDelivery::STATUS_QUEUED; + + if ($isSuppressed) { + $status = AlertDelivery::STATUS_SUPPRESSED; + } else { + $deferUntil = $this->quietHoursService->deferUntil($rule, $workspace, $now); + + if ($deferUntil instanceof CarbonImmutable) { + $status = AlertDelivery::STATUS_DEFERRED; + $sendAfter = $deferUntil; + } + } + + AlertDelivery::query()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenantId, + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'event_type' => $eventType, + 'severity' => $eventSeverity, + 'status' => $status, + 'fingerprint_hash' => $fingerprintHash, + 'send_after' => $sendAfter, + 'attempt_count' => 0, + 'payload' => $this->buildPayload($event), + ]); + + $createdDeliveries++; + } + } + + return $createdDeliveries; + } + + private function normalizeSeverity(string $severity): string + { + $severity = strtolower(trim($severity)); + + return in_array($severity, ['low', 'medium', 'high', 'critical'], true) + ? $severity + : 'high'; + } + + private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool + { + $rank = [ + 'low' => 1, + 'medium' => 2, + 'high' => 3, + 'critical' => 4, + ]; + + $eventRank = $rank[$eventSeverity] ?? 0; + $minimumRank = $rank[strtolower(trim($minimumSeverity))] ?? 0; + + return $eventRank >= $minimumRank; + } + + private function shouldSuppress( + int $workspaceId, + int $ruleId, + int $destinationId, + string $fingerprintHash, + int $cooldownSeconds, + CarbonImmutable $now, + ): bool { + if ($cooldownSeconds <= 0) { + return false; + } + + $cutoff = $now->subSeconds($cooldownSeconds); + + return AlertDelivery::query() + ->where('workspace_id', $workspaceId) + ->where('alert_rule_id', $ruleId) + ->where('alert_destination_id', $destinationId) + ->where('fingerprint_hash', $fingerprintHash) + ->whereNotIn('status', [ + AlertDelivery::STATUS_SUPPRESSED, + AlertDelivery::STATUS_CANCELED, + ]) + ->where('created_at', '>=', $cutoff) + ->exists(); + } + + /** + * @param array $event + * @return array + */ + private function buildPayload(array $event): array + { + $title = trim((string) ($event['title'] ?? 'Alert')); + $body = trim((string) ($event['body'] ?? 'A matching alert event was detected.')); + + if ($title === '') { + $title = 'Alert'; + } + + if ($body === '') { + $body = 'A matching alert event was detected.'; + } + + $metadata = Arr::get($event, 'metadata', []); + + if (! is_array($metadata)) { + $metadata = []; + } + + return [ + 'title' => $title, + 'body' => $body, + 'metadata' => $metadata, + ]; + } +} diff --git a/app/Services/Alerts/AlertFingerprintService.php b/app/Services/Alerts/AlertFingerprintService.php new file mode 100644 index 0000000..1ececb0 --- /dev/null +++ b/app/Services/Alerts/AlertFingerprintService.php @@ -0,0 +1,52 @@ + $event + */ + public function hash(AlertRule $rule, AlertDestination $destination, int $tenantId, array $event): string + { + $fingerprintKey = trim((string) ($event['fingerprint_key'] ?? '')); + + if ($fingerprintKey === '') { + $fingerprintKey = trim((string) ($event['idempotency_key'] ?? '')); + } + + $payload = [ + 'workspace_id' => (int) $rule->workspace_id, + 'rule_id' => (int) $rule->getKey(), + 'destination_id' => (int) $destination->getKey(), + 'tenant_id' => $tenantId, + 'event_type' => trim((string) ($event['event_type'] ?? '')), + 'severity' => strtolower(trim((string) ($event['severity'] ?? ''))), + 'fingerprint_key' => $fingerprintKey, + ]; + + return hash('sha256', json_encode($this->normalizeArray($payload), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + } + + /** + * @param array $payload + * @return array + */ + private function normalizeArray(array $payload): array + { + ksort($payload); + + foreach ($payload as $key => $value) { + if (is_array($value)) { + $payload[$key] = $this->normalizeArray($value); + } + } + + return $payload; + } +} diff --git a/app/Services/Alerts/AlertQuietHoursService.php b/app/Services/Alerts/AlertQuietHoursService.php new file mode 100644 index 0000000..c7d9acc --- /dev/null +++ b/app/Services/Alerts/AlertQuietHoursService.php @@ -0,0 +1,132 @@ +quiet_hours_enabled) { + return null; + } + + $start = $this->parseTime((string) ($rule->quiet_hours_start ?? '')); + $end = $this->parseTime((string) ($rule->quiet_hours_end ?? '')); + + if ($start === null || $end === null) { + return null; + } + + $timezone = $this->resolveTimezone($rule, $workspace); + $localNow = ($now ?? CarbonImmutable::now($timezone))->setTimezone($timezone); + + if (! $this->isWithinQuietHours($localNow, $start, $end)) { + return null; + } + + $nextAllowedLocal = $this->nextAllowedAt($localNow, $start, $end); + + return $nextAllowedLocal->setTimezone('UTC'); + } + + /** + * @param array{hour:int,minute:int} $start + * @param array{hour:int,minute:int} $end + */ + private function isWithinQuietHours(CarbonImmutable $localNow, array $start, array $end): bool + { + $nowMinutes = ((int) $localNow->format('H') * 60) + (int) $localNow->format('i'); + $startMinutes = ($start['hour'] * 60) + $start['minute']; + $endMinutes = ($end['hour'] * 60) + $end['minute']; + + if ($startMinutes === $endMinutes) { + return true; + } + + if ($startMinutes < $endMinutes) { + return $nowMinutes >= $startMinutes && $nowMinutes < $endMinutes; + } + + return $nowMinutes >= $startMinutes || $nowMinutes < $endMinutes; + } + + /** + * @param array{hour:int,minute:int} $start + * @param array{hour:int,minute:int} $end + */ + private function nextAllowedAt(CarbonImmutable $localNow, array $start, array $end): CarbonImmutable + { + $nowMinutes = ((int) $localNow->format('H') * 60) + (int) $localNow->format('i'); + $startMinutes = ($start['hour'] * 60) + $start['minute']; + $endMinutes = ($end['hour'] * 60) + $end['minute']; + + if ($startMinutes === $endMinutes) { + return $this->atLocalTime($localNow->addDay(), $end); + } + + if ($startMinutes < $endMinutes) { + if ($nowMinutes >= $startMinutes && $nowMinutes < $endMinutes) { + return $this->atLocalTime($localNow, $end); + } + + return $localNow; + } + + if ($nowMinutes >= $startMinutes) { + return $this->atLocalTime($localNow->addDay(), $end); + } + + if ($nowMinutes < $endMinutes) { + return $this->atLocalTime($localNow, $end); + } + + return $localNow; + } + + /** + * @return array{hour:int,minute:int}|null + */ + private function parseTime(string $value): ?array + { + $value = trim($value); + + if (! preg_match('/^(?[01]\\d|2[0-3]):(?[0-5]\\d)$/', $value, $matches)) { + return null; + } + + return [ + 'hour' => (int) $matches['hour'], + 'minute' => (int) $matches['minute'], + ]; + } + + /** + * @param array{hour:int,minute:int} $time + */ + private function atLocalTime(CarbonImmutable $baseDateTime, array $time): CarbonImmutable + { + return $baseDateTime + ->setTime($time['hour'], $time['minute'], 0, 0); + } + + private function resolveTimezone(AlertRule $rule, Workspace $workspace): string + { + $ruleTimezone = trim((string) ($rule->quiet_hours_timezone ?? '')); + + if ($ruleTimezone !== '' && in_array($ruleTimezone, \DateTimeZone::listIdentifiers(), true)) { + return $ruleTimezone; + } + + return $this->workspaceTimezoneResolver->resolve($workspace); + } +} diff --git a/app/Services/Alerts/AlertSender.php b/app/Services/Alerts/AlertSender.php new file mode 100644 index 0000000..85837c0 --- /dev/null +++ b/app/Services/Alerts/AlertSender.php @@ -0,0 +1,96 @@ +destination; + + if (! $destination instanceof AlertDestination) { + throw new RuntimeException('Alert destination is missing.'); + } + + if (! (bool) $destination->is_enabled) { + throw new RuntimeException('Alert destination is disabled.'); + } + + $payload = is_array($delivery->payload) ? $delivery->payload : []; + + try { + if ($destination->type === AlertDestination::TYPE_TEAMS_WEBHOOK) { + $this->deliverTeams($destination, $payload); + + return; + } + + if ($destination->type === AlertDestination::TYPE_EMAIL) { + $this->deliverEmail($delivery, $destination, $payload); + + return; + } + } catch (Throwable $exception) { + throw new RuntimeException($this->channelFailureMessage((string) $destination->type), previous: $exception); + } + + throw new RuntimeException('Alert destination type is not supported.'); + } + + /** + * @param array $payload + */ + private function deliverTeams(AlertDestination $destination, array $payload): void + { + $config = is_array($destination->config) ? $destination->config : []; + $webhookUrl = trim((string) Arr::get($config, 'webhook_url', '')); + + if ($webhookUrl === '') { + throw new RuntimeException('Teams webhook destination is not configured.'); + } + + $this->teamsWebhookSender->send($webhookUrl, $payload); + } + + /** + * @param array $payload + */ + private function deliverEmail(AlertDelivery $delivery, AlertDestination $destination, array $payload): void + { + $config = is_array($destination->config) ? $destination->config : []; + $recipients = Arr::get($config, 'recipients', []); + $recipients = is_array($recipients) + ? array_values(array_unique(array_filter(array_map(static fn (mixed $value): string => trim((string) $value), $recipients)))) + : []; + + if ($recipients === []) { + throw new RuntimeException('Email destination has no recipients.'); + } + + Notification::route('mail', $recipients) + ->notify(new EmailAlertNotification($delivery, $payload)); + } + + private function channelFailureMessage(string $type): string + { + return match ($type) { + AlertDestination::TYPE_TEAMS_WEBHOOK => 'Teams delivery failed.', + AlertDestination::TYPE_EMAIL => 'Email delivery failed.', + default => 'Alert delivery failed.', + }; + } +} diff --git a/app/Services/Alerts/TeamsWebhookSender.php b/app/Services/Alerts/TeamsWebhookSender.php new file mode 100644 index 0000000..0f413e6 --- /dev/null +++ b/app/Services/Alerts/TeamsWebhookSender.php @@ -0,0 +1,58 @@ + $payload + */ + public function send(string $webhookUrl, array $payload): void + { + $webhookUrl = trim($webhookUrl); + + if ($webhookUrl === '') { + throw new RuntimeException('Teams webhook URL is not configured.'); + } + + $response = Http::timeout((int) config('tenantpilot.alerts.http_timeout_seconds', 10)) + ->asJson() + ->post($webhookUrl, [ + 'text' => $this->toTeamsTextPayload($payload), + ]); + + if ($response->successful()) { + return; + } + + throw new RuntimeException(sprintf( + 'Teams delivery failed with HTTP status %d.', + (int) $response->status(), + )); + } + + /** + * @param array $payload + */ + private function toTeamsTextPayload(array $payload): string + { + $title = trim((string) Arr::get($payload, 'title', 'Alert')); + $body = trim((string) Arr::get($payload, 'body', 'A matching alert event was detected.')); + + if ($title === '') { + $title = 'Alert'; + } + + if ($body === '') { + return $title; + } + + return $title."\n\n".$body; + } +} diff --git a/app/Services/Alerts/WorkspaceTimezoneResolver.php b/app/Services/Alerts/WorkspaceTimezoneResolver.php new file mode 100644 index 0000000..888e703 --- /dev/null +++ b/app/Services/Alerts/WorkspaceTimezoneResolver.php @@ -0,0 +1,68 @@ +normalizeTimezone($workspace->getAttribute('timezone')), + $this->settingTimezone($workspace, 'alerts', 'timezone'), + $this->settingTimezone($workspace, 'workspace', 'timezone'), + $this->settingTimezone($workspace, 'general', 'timezone'), + $this->normalizeTimezone(config('app.timezone')), + ]; + + foreach ($candidates as $candidate) { + if ($candidate !== null) { + return $candidate; + } + } + + return 'UTC'; + } + + private function settingTimezone(Workspace $workspace, string $domain, string $key): ?string + { + $setting = WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', $domain) + ->where('key', $key) + ->first(['value']); + + if (! $setting instanceof WorkspaceSetting) { + return null; + } + + return $this->normalizeTimezone($setting->getAttribute('value')); + } + + private function normalizeTimezone(mixed $value): ?string + { + if (is_array($value)) { + $value = $value['timezone'] ?? null; + } + + if (! is_string($value)) { + return null; + } + + $value = trim($value); + + if ($value === '') { + return null; + } + + if (! in_array($value, \DateTimeZone::listIdentifiers(), true)) { + return null; + } + + return $value; + } +} diff --git a/app/Services/Auth/WorkspaceRoleCapabilityMap.php b/app/Services/Auth/WorkspaceRoleCapabilityMap.php index 04ea914..3257ee8 100644 --- a/app/Services/Auth/WorkspaceRoleCapabilityMap.php +++ b/app/Services/Auth/WorkspaceRoleCapabilityMap.php @@ -34,6 +34,8 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE, Capabilities::WORKSPACE_SETTINGS_VIEW, Capabilities::WORKSPACE_SETTINGS_MANAGE, + Capabilities::ALERTS_VIEW, + Capabilities::ALERTS_MANAGE, ], WorkspaceRole::Manager->value => [ @@ -50,6 +52,8 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP, Capabilities::WORKSPACE_SETTINGS_VIEW, Capabilities::WORKSPACE_SETTINGS_MANAGE, + Capabilities::ALERTS_VIEW, + Capabilities::ALERTS_MANAGE, ], WorkspaceRole::Operator->value => [ @@ -61,11 +65,13 @@ class WorkspaceRoleCapabilityMap Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP, Capabilities::WORKSPACE_SETTINGS_VIEW, + Capabilities::ALERTS_VIEW, ], WorkspaceRole::Readonly->value => [ Capabilities::WORKSPACE_VIEW, Capabilities::WORKSPACE_SETTINGS_VIEW, + Capabilities::ALERTS_VIEW, ], ]; diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php index 240ebb3..4cbbb09 100644 --- a/app/Support/Audit/AuditActionId.php +++ b/app/Support/Audit/AuditActionId.php @@ -31,6 +31,18 @@ enum AuditActionId: string case VerificationCompleted = 'verification.completed'; case VerificationCheckAcknowledged = 'verification.check_acknowledged'; + case AlertDestinationCreated = 'alert_destination.created'; + case AlertDestinationUpdated = 'alert_destination.updated'; + case AlertDestinationDeleted = 'alert_destination.deleted'; + case AlertDestinationEnabled = 'alert_destination.enabled'; + case AlertDestinationDisabled = 'alert_destination.disabled'; + + case AlertRuleCreated = 'alert_rule.created'; + case AlertRuleUpdated = 'alert_rule.updated'; + case AlertRuleDeleted = 'alert_rule.deleted'; + case AlertRuleEnabled = 'alert_rule.enabled'; + case AlertRuleDisabled = 'alert_rule.disabled'; + case WorkspaceSettingUpdated = 'workspace_setting.updated'; case WorkspaceSettingReset = 'workspace_setting.reset'; } diff --git a/app/Support/Auth/Capabilities.php b/app/Support/Auth/Capabilities.php index b4bfbf9..1046dd4 100644 --- a/app/Support/Auth/Capabilities.php +++ b/app/Support/Auth/Capabilities.php @@ -51,6 +51,11 @@ class Capabilities public const WORKSPACE_SETTINGS_MANAGE = 'workspace_settings.manage'; + // Workspace alerts + public const ALERTS_VIEW = 'workspace_alerts.view'; + + public const ALERTS_MANAGE = 'workspace_alerts.manage'; + // Tenants public const TENANT_VIEW = 'tenant.view'; diff --git a/app/Support/Middleware/EnsureFilamentTenantSelected.php b/app/Support/Middleware/EnsureFilamentTenantSelected.php index 038a00b..6e78604 100644 --- a/app/Support/Middleware/EnsureFilamentTenantSelected.php +++ b/app/Support/Middleware/EnsureFilamentTenantSelected.php @@ -2,6 +2,9 @@ namespace App\Support\Middleware; +use App\Filament\Resources\AlertDeliveryResource; +use App\Filament\Resources\AlertDestinationResource; +use App\Filament\Resources\AlertRuleResource; use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; @@ -200,12 +203,36 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void ->group('Monitoring') ->sort(10), ) + ->item( + NavigationItem::make('Alert targets') + ->url(fn (): string => AlertDestinationResource::getUrl(panel: 'admin')) + ->icon('heroicon-o-bell-alert') + ->group('Monitoring') + ->sort(20) + ->visible(fn (): bool => AlertDestinationResource::canViewAny()), + ) + ->item( + NavigationItem::make('Alert rules') + ->url(fn (): string => AlertRuleResource::getUrl(panel: 'admin')) + ->icon('heroicon-o-funnel') + ->group('Monitoring') + ->sort(21) + ->visible(fn (): bool => AlertRuleResource::canViewAny()), + ) + ->item( + NavigationItem::make('Alert deliveries') + ->url(fn (): string => AlertDeliveryResource::getUrl(panel: 'admin')) + ->icon('heroicon-o-clock') + ->group('Monitoring') + ->sort(22) + ->visible(fn (): bool => AlertDeliveryResource::canViewAny()), + ) ->item( NavigationItem::make('Alerts') ->url(fn (): string => '/admin/alerts') ->icon('heroicon-o-bell-alert') ->group('Monitoring') - ->sort(20), + ->sort(23), ) ->item( NavigationItem::make('Audit Log') diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index 8bebac5..c7ab739 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -43,6 +43,8 @@ public static function labels(): array 'policy_version.prune' => 'Prune policy versions', 'policy_version.restore' => 'Restore policy versions', 'policy_version.force_delete' => 'Delete policy versions', + 'alerts.evaluate' => 'Alerts evaluation', + 'alerts.deliver' => 'Alerts delivery', ]; } @@ -69,6 +71,7 @@ public static function expectedDurationSeconds(string $operationType): ?int 'drift_generate_findings' => 240, 'assignments.fetch', 'assignments.restore' => 60, 'ops.reconcile_adapter_runs' => 120, + 'alerts.evaluate', 'alerts.deliver' => 120, default => null, }; } diff --git a/app/Support/Operations/OperationRunCapabilityResolver.php b/app/Support/Operations/OperationRunCapabilityResolver.php index 57791cb..0c7dce3 100644 --- a/app/Support/Operations/OperationRunCapabilityResolver.php +++ b/app/Support/Operations/OperationRunCapabilityResolver.php @@ -20,6 +20,7 @@ public function requiredCapabilityForType(string $operationType): ?string 'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN, 'restore.execute' => Capabilities::TENANT_MANAGE, 'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE, + 'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW, // Viewing verification reports should be possible for readonly members. // Starting verification is separately guarded by the verification service. diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 5467c5e..62c9ca3 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -338,6 +338,17 @@ ], ], + 'alerts' => [ + 'enabled' => (bool) env('TENANTPILOT_ALERTS_ENABLED', true), + 'evaluate_initial_lookback_minutes' => (int) env('TENANTPILOT_ALERTS_EVALUATE_INITIAL_LOOKBACK_MINUTES', 15), + 'delivery_retention_days' => (int) env('TENANTPILOT_ALERTS_DELIVERY_RETENTION_DAYS', 90), + 'delivery_max_attempts' => (int) env('TENANTPILOT_ALERTS_DELIVERY_MAX_ATTEMPTS', 3), + 'delivery_retry_base_seconds' => (int) env('TENANTPILOT_ALERTS_DELIVERY_RETRY_BASE_SECONDS', 60), + 'delivery_retry_max_seconds' => (int) env('TENANTPILOT_ALERTS_DELIVERY_RETRY_MAX_SECONDS', 900), + 'deliver_batch_size' => (int) env('TENANTPILOT_ALERTS_DELIVER_BATCH_SIZE', 200), + 'http_timeout_seconds' => (int) env('TENANTPILOT_ALERTS_HTTP_TIMEOUT_SECONDS', 10), + ], + 'display' => [ 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), diff --git a/database/factories/AlertDeliveryFactory.php b/database/factories/AlertDeliveryFactory.php new file mode 100644 index 0000000..080e917 --- /dev/null +++ b/database/factories/AlertDeliveryFactory.php @@ -0,0 +1,78 @@ + + */ +class AlertDeliveryFactory extends Factory +{ + protected $model = AlertDelivery::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'workspace_id' => function (array $attributes): int { + $tenantId = $attributes['tenant_id'] ?? null; + + if (! is_numeric($tenantId)) { + return (int) Workspace::factory()->create()->getKey(); + } + + $tenant = Tenant::query()->whereKey((int) $tenantId)->first(); + + if (! $tenant instanceof Tenant) { + return (int) Workspace::factory()->create()->getKey(); + } + + if ($tenant->workspace_id === null) { + $workspaceId = (int) Workspace::factory()->create()->getKey(); + $tenant->forceFill(['workspace_id' => $workspaceId])->save(); + + return $workspaceId; + } + + return (int) $tenant->workspace_id; + }, + 'alert_rule_id' => function (array $attributes): int { + $workspaceId = is_numeric($attributes['workspace_id'] ?? null) + ? (int) $attributes['workspace_id'] + : (int) Workspace::factory()->create()->getKey(); + + return (int) AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ])->getKey(); + }, + 'alert_destination_id' => function (array $attributes): int { + $workspaceId = is_numeric($attributes['workspace_id'] ?? null) + ? (int) $attributes['workspace_id'] + : (int) Workspace::factory()->create()->getKey(); + + return (int) AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ])->getKey(); + }, + 'event_type' => AlertRule::EVENT_HIGH_DRIFT, + 'severity' => 'high', + 'status' => AlertDelivery::STATUS_QUEUED, + 'fingerprint_hash' => hash('sha256', fake()->uuid()), + 'send_after' => null, + 'attempt_count' => 0, + 'last_error_code' => null, + 'last_error_message' => null, + 'sent_at' => null, + 'payload' => [ + 'title' => 'Alert', + 'body' => 'Delivery payload', + ], + ]; + } +} diff --git a/database/factories/AlertDestinationFactory.php b/database/factories/AlertDestinationFactory.php new file mode 100644 index 0000000..1dd5a7b --- /dev/null +++ b/database/factories/AlertDestinationFactory.php @@ -0,0 +1,40 @@ + + */ +class AlertDestinationFactory extends Factory +{ + protected $model = AlertDestination::class; + + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'name' => 'Destination '.fake()->unique()->word(), + 'type' => AlertDestination::TYPE_TEAMS_WEBHOOK, + 'is_enabled' => true, + 'config' => [ + 'webhook_url' => 'https://example.invalid/'.fake()->uuid(), + ], + ]; + } + + public function email(): static + { + return $this->state(fn (): array => [ + 'type' => AlertDestination::TYPE_EMAIL, + 'config' => [ + 'recipients' => [ + fake()->safeEmail(), + ], + ], + ]); + } +} diff --git a/database/factories/AlertRuleFactory.php b/database/factories/AlertRuleFactory.php new file mode 100644 index 0000000..50f64db --- /dev/null +++ b/database/factories/AlertRuleFactory.php @@ -0,0 +1,33 @@ + + */ +class AlertRuleFactory extends Factory +{ + protected $model = AlertRule::class; + + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'name' => 'Rule '.fake()->unique()->word(), + 'is_enabled' => true, + 'event_type' => AlertRule::EVENT_HIGH_DRIFT, + 'minimum_severity' => 'high', + 'tenant_scope_mode' => AlertRule::TENANT_SCOPE_ALL, + 'tenant_allowlist' => [], + 'cooldown_seconds' => 900, + 'quiet_hours_enabled' => false, + 'quiet_hours_start' => null, + 'quiet_hours_end' => null, + 'quiet_hours_timezone' => null, + ]; + } +} diff --git a/database/migrations/2026_02_16_230100_create_alert_destinations_table.php b/database/migrations/2026_02_16_230100_create_alert_destinations_table.php new file mode 100644 index 0000000..9730ba3 --- /dev/null +++ b/database/migrations/2026_02_16_230100_create_alert_destinations_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('type'); + $table->boolean('is_enabled')->default(true); + $table->text('config'); + $table->timestamps(); + + $table->index(['workspace_id', 'type']); + $table->index(['workspace_id', 'is_enabled']); + $table->unique(['workspace_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_destinations'); + } +}; diff --git a/database/migrations/2026_02_16_230200_create_alert_rules_table.php b/database/migrations/2026_02_16_230200_create_alert_rules_table.php new file mode 100644 index 0000000..b322b3b --- /dev/null +++ b/database/migrations/2026_02_16_230200_create_alert_rules_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->boolean('is_enabled')->default(true); + $table->string('event_type'); + $table->string('minimum_severity'); + $table->string('tenant_scope_mode')->default('all'); + $table->json('tenant_allowlist')->nullable(); + $table->unsignedInteger('cooldown_seconds')->nullable(); + $table->boolean('quiet_hours_enabled')->default(false); + $table->string('quiet_hours_start')->nullable(); + $table->string('quiet_hours_end')->nullable(); + $table->string('quiet_hours_timezone')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'event_type']); + $table->index(['workspace_id', 'is_enabled']); + $table->index(['workspace_id', 'tenant_scope_mode']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_rules'); + } +}; diff --git a/database/migrations/2026_02_16_230210_create_alert_rule_destinations_table.php b/database/migrations/2026_02_16_230210_create_alert_rule_destinations_table.php new file mode 100644 index 0000000..acd2ae1 --- /dev/null +++ b/database/migrations/2026_02_16_230210_create_alert_rule_destinations_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('alert_rule_id')->constrained()->cascadeOnDelete(); + $table->foreignId('alert_destination_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['alert_rule_id', 'alert_destination_id']); + $table->index(['workspace_id', 'alert_rule_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_rule_destinations'); + } +}; diff --git a/database/migrations/2026_02_16_230300_create_alert_deliveries_table.php b/database/migrations/2026_02_16_230300_create_alert_deliveries_table.php new file mode 100644 index 0000000..fbe7ed3 --- /dev/null +++ b/database/migrations/2026_02_16_230300_create_alert_deliveries_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('alert_rule_id')->constrained()->cascadeOnDelete(); + $table->foreignId('alert_destination_id')->constrained()->cascadeOnDelete(); + $table->string('event_type'); + $table->string('severity')->nullable(); + $table->string('status'); + $table->string('fingerprint_hash'); + $table->timestamp('send_after')->nullable(); + $table->unsignedInteger('attempt_count')->default(0); + $table->string('last_error_code')->nullable(); + $table->text('last_error_message')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->json('payload')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'created_at']); + $table->index(['workspace_id', 'status', 'send_after']); + $table->index(['workspace_id', 'tenant_id', 'created_at']); + $table->index(['workspace_id', 'alert_rule_id', 'fingerprint_hash']); + }); + } + + public function down(): void + { + Schema::dropIfExists('alert_deliveries'); + } +}; diff --git a/resources/views/filament/pages/monitoring/alerts.blade.php b/resources/views/filament/pages/monitoring/alerts.blade.php index 5838c48..3bbaf04 100644 --- a/resources/views/filament/pages/monitoring/alerts.blade.php +++ b/resources/views/filament/pages/monitoring/alerts.blade.php @@ -1,7 +1,33 @@ -
-
- Alerts is reserved for future work. + +
+
+ Configure alert targets and rules, then review delivery history. +
+ +
+ @if (\App\Filament\Resources\AlertDestinationResource::canViewAny()) + + Alert targets + + @endif + + @if (\App\Filament\Resources\AlertRuleResource::canViewAny()) + + Alert rules + + @endif + + @if (\App\Filament\Resources\AlertDeliveryResource::canViewAny()) + + Alert deliveries + + @endif + + + Audit Log + +
-
+ diff --git a/routes/console.php b/routes/console.php index ce2139d..b4827b1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -12,6 +12,10 @@ Schedule::command('tenantpilot:schedules:dispatch')->everyMinute(); Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute(); +Schedule::command('tenantpilot:alerts:dispatch') + ->everyMinute() + ->name('tenantpilot:alerts:dispatch') + ->withoutOverlapping(); Schedule::job(new PruneOldOperationRunsJob) ->daily() diff --git a/routes/web.php b/routes/web.php index c92d6c9..14ad6d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -183,19 +183,6 @@ })->name('admin.provider-connections.legacy-edit'); }); -Route::middleware([ - 'web', - 'panel:admin', - 'ensure-correct-guard:web', - DisableBladeIconComponents::class, - DispatchServingFilamentEvent::class, - FilamentAuthenticate::class, - 'ensure-workspace-selected', - 'ensure-filament-tenant-selected', -]) - ->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class) - ->name('admin.monitoring.alerts'); - Route::middleware([ 'web', 'panel:admin', diff --git a/specs/099-alerts-v1-teams-email/checklists/requirements.md b/specs/099-alerts-v1-teams-email/checklists/requirements.md new file mode 100644 index 0000000..7c2fb36 --- /dev/null +++ b/specs/099-alerts-v1-teams-email/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Alerts v1 (Teams + Email) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-16 +**Feature**: [specs/099-alerts-v1-teams-email/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The spec includes an explicit UI Action Matrix section because admin UI surfaces are in scope and this repo’s constitution requires it. No code-level implementation choices are included. diff --git a/specs/099-alerts-v1-teams-email/contracts/openapi.yaml b/specs/099-alerts-v1-teams-email/contracts/openapi.yaml new file mode 100644 index 0000000..edb3004 --- /dev/null +++ b/specs/099-alerts-v1-teams-email/contracts/openapi.yaml @@ -0,0 +1,291 @@ +openapi: 3.0.3 +info: + title: TenantPilot Alerts v1 (conceptual contract) + version: 0.1.0 + description: | + Documentation-only contract for Alerts v1. + + v1 is implemented via Filament (Livewire) UI surfaces, not as a public REST API. + This OpenAPI file captures the intended domain operations and payload shapes to + keep requirements explicit and support future API extraction. + +servers: + - url: https://example.invalid + +paths: + /workspaces/{workspaceId}/alerts/destinations: + get: + summary: List alert destinations + parameters: + - $ref: '#/components/parameters/WorkspaceId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AlertDestination' + post: + summary: Create alert destination + parameters: + - $ref: '#/components/parameters/WorkspaceId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AlertDestinationCreate' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/AlertDestination' + + /workspaces/{workspaceId}/alerts/destinations/{destinationId}: + get: + summary: Get alert destination + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/DestinationId' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AlertDestination' + patch: + summary: Update alert destination + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/DestinationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AlertDestinationUpdate' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AlertDestination' + delete: + summary: Delete alert destination + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/DestinationId' + responses: + '204': + description: No Content + + /workspaces/{workspaceId}/alerts/rules: + get: + summary: List alert rules + parameters: + - $ref: '#/components/parameters/WorkspaceId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AlertRule' + post: + summary: Create alert rule + parameters: + - $ref: '#/components/parameters/WorkspaceId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRuleCreate' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRule' + + /workspaces/{workspaceId}/alerts/rules/{ruleId}: + get: + summary: Get alert rule + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/RuleId' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRule' + patch: + summary: Update alert rule + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/RuleId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRuleUpdate' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AlertRule' + delete: + summary: Delete alert rule + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/RuleId' + responses: + '204': + description: No Content + + /workspaces/{workspaceId}/alerts/deliveries: + get: + summary: List alert deliveries + parameters: + - $ref: '#/components/parameters/WorkspaceId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AlertDelivery' + +components: + parameters: + WorkspaceId: + name: workspaceId + in: path + required: true + schema: + type: integer + DestinationId: + name: destinationId + in: path + required: true + schema: + type: integer + RuleId: + name: ruleId + in: path + required: true + schema: + type: integer + + schemas: + AlertDestination: + type: object + properties: + id: { type: integer } + workspace_id: { type: integer } + name: { type: string } + type: { type: string, enum: [teams_webhook, email] } + is_enabled: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + required: [id, workspace_id, name, type, is_enabled] + description: | + Destination configuration details are intentionally not included here + (secrets must not be exposed). + + AlertDestinationCreate: + type: object + properties: + name: { type: string } + type: { type: string, enum: [teams_webhook, email] } + teams_webhook_url: { type: string, format: uri } + email_recipients: + type: array + items: { type: string, format: email } + required: [name, type] + + AlertDestinationUpdate: + allOf: + - $ref: '#/components/schemas/AlertDestinationCreate' + + AlertRule: + type: object + properties: + id: { type: integer } + workspace_id: { type: integer } + name: { type: string } + is_enabled: { type: boolean } + event_type: { type: string, enum: [high_drift, compare_failed, sla_due] } + minimum_severity: { type: string, enum: [low, medium, high, critical] } + tenant_scope_mode: { type: string, enum: [all, allowlist] } + tenant_allowlist: + type: array + items: { type: integer } + cooldown_seconds: { type: integer, nullable: true } + quiet_hours_enabled: { type: boolean } + quiet_hours_start: { type: string, nullable: true, example: '22:00' } + quiet_hours_end: { type: string, nullable: true, example: '06:00' } + quiet_hours_timezone: { type: string, nullable: true, example: 'UTC' } + destination_ids: + type: array + items: { type: integer } + required: [id, workspace_id, name, is_enabled, event_type, minimum_severity, tenant_scope_mode] + + AlertRuleCreate: + type: object + properties: + name: { type: string } + is_enabled: { type: boolean } + event_type: { type: string, enum: [high_drift, compare_failed, sla_due] } + minimum_severity: { type: string, enum: [low, medium, high, critical] } + tenant_scope_mode: { type: string, enum: [all, allowlist] } + tenant_allowlist: + type: array + items: { type: integer } + cooldown_seconds: { type: integer, nullable: true } + quiet_hours_enabled: { type: boolean } + quiet_hours_start: { type: string, nullable: true } + quiet_hours_end: { type: string, nullable: true } + quiet_hours_timezone: { type: string, nullable: true } + destination_ids: + type: array + items: { type: integer } + required: [name, event_type, minimum_severity, tenant_scope_mode, destination_ids] + + AlertRuleUpdate: + allOf: + - $ref: '#/components/schemas/AlertRuleCreate' + + AlertDelivery: + type: object + properties: + id: { type: integer } + workspace_id: { type: integer } + tenant_id: { type: integer } + alert_rule_id: { type: integer } + alert_destination_id: { type: integer } + fingerprint_hash: { type: string } + status: { type: string, enum: [queued, deferred, sent, failed, suppressed, canceled] } + send_after: { type: string, format: date-time, nullable: true } + attempt_count: { type: integer } + last_error_code: { type: string, nullable: true } + last_error_message: { type: string, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + required: [id, workspace_id, tenant_id, alert_rule_id, alert_destination_id, fingerprint_hash, status] diff --git a/specs/099-alerts-v1-teams-email/data-model.md b/specs/099-alerts-v1-teams-email/data-model.md new file mode 100644 index 0000000..fc8a6f6 --- /dev/null +++ b/specs/099-alerts-v1-teams-email/data-model.md @@ -0,0 +1,116 @@ +# Data Model — 099 Alerts v1 (Teams + Email) + +Scope: **workspace-owned** configuration and **tenant-owned** delivery history. Deliveries are always tenant-scoped (`tenant_id` NOT NULL) and must only be listed/viewed for tenants the actor is entitled to (non-entitled tenants are treated as not found / 404 semantics). + +## Entities + +## AlertDestination (workspace-owned) + +Purpose: reusable delivery target. + +Fields: +- `id` +- `workspace_id` (FK, required) +- `name` (string, required) +- `type` (enum: `teams_webhook` | `email`, required) +- `is_enabled` (bool, default true) +- `config` (encrypted:array, required) + - for `teams_webhook`: `{ "webhook_url": "https://..." }` + - for `email`: `{ "recipients": ["a@example.com", "b@example.com"] }` +- timestamps + +Validation rules: +- `name`: required, max length +- `type`: required, in allowed values +- `config.webhook_url`: required if type is teams; must be URL +- `config.recipients`: required if type is email; array of valid email addresses; must be non-empty + +Security: +- `config` must never be logged or included in audit metadata. + +## AlertRule (workspace-owned) + +Purpose: routing + noise controls. + +Fields: +- `id` +- `workspace_id` (FK, required) +- `name` (string, required) +- `is_enabled` (bool, default true) +- `event_type` (enum: `high_drift` | `compare_failed` | `sla_due`, required) +- `minimum_severity` (enum: `low` | `medium` | `high` | `critical`, required) +- `tenant_scope_mode` (enum: `all` | `allowlist`, required) +- `tenant_allowlist` (array, default empty) +- `cooldown_seconds` (int, nullable) +- `quiet_hours_enabled` (bool, default false) +- `quiet_hours_start` (string, e.g. `22:00`, nullable) +- `quiet_hours_end` (string, e.g. `06:00`, nullable) +- `quiet_hours_timezone` (IANA TZ string, nullable) +- timestamps + +Validation rules: +- `name`: required +- `event_type`: required +- `minimum_severity`: required +- `tenant_allowlist`: required if `tenant_scope_mode=allowlist` +- quiet hours: + - if enabled: start/end required, valid HH:MM, timezone optional + +Notes: +- Quiet-hours timezone resolution: + - rule timezone if set + - else workspace timezone + - else `config('app.timezone')` + +## AlertRuleDestination (workspace-owned pivot) + +Fields: +- `id` +- `workspace_id` (FK, required) +- `alert_rule_id` (FK) +- `alert_destination_id` (FK) +- timestamps + +Constraints: +- Unique `(alert_rule_id, alert_destination_id)` + +## AlertDelivery (tenant-owned history) + +Purpose: immutable record of queued/sent/failed/deferred/suppressed deliveries. + +Fields: +- `id` +- `workspace_id` (FK, required) +- `tenant_id` (FK, required) +- `alert_rule_id` (FK, required) +- `alert_destination_id` (FK, required) +- `fingerprint_hash` (string, required) +- `status` (enum: `queued` | `deferred` | `sent` | `failed` | `suppressed` | `canceled`) +- `send_after` (timestamp, nullable) +- `attempt_count` (int, default 0) +- `last_error_code` (string, nullable) +- `last_error_message` (string, nullable; sanitized) +- `sent_at` (timestamp, nullable) +- timestamps + +Indexes: +- `(workspace_id, created_at)` for history listing +- `(workspace_id, status, send_after)` for dispatching due deferred deliveries +- `(workspace_id, alert_rule_id, fingerprint_hash)` for dedupe/cooldown checks + +Retention: +- Default prune: 90 days. + +## Relationships + +- `AlertRule` hasMany `AlertRuleDestination` and belongsToMany `AlertDestination`. +- `AlertDestination` belongsToMany `AlertRule`. +- `AlertDelivery` belongsTo `AlertRule`, belongsTo `AlertDestination`, and belongsTo `Tenant`. + +## State transitions + +`AlertDelivery.status` transitions: +- `queued` → `sent` | `failed` | `suppressed` | `canceled` +- `deferred` → `queued` (when window opens) → `sent` | `failed` … + +Terminal states: `sent`, `failed`, `suppressed`, `canceled`. diff --git a/specs/099-alerts-v1-teams-email/plan.md b/specs/099-alerts-v1-teams-email/plan.md new file mode 100644 index 0000000..5848adc --- /dev/null +++ b/specs/099-alerts-v1-teams-email/plan.md @@ -0,0 +1,217 @@ +# Implementation Plan: 099 — Alerts v1 (Teams + Email) + +**Branch**: `099-alerts-v1-teams-email` | **Date**: 2026-02-16 | **Spec**: `/specs/099-alerts-v1-teams-email/spec.md` +**Input**: Feature specification from `/specs/099-alerts-v1-teams-email/spec.md` + +## Summary + +Implement workspace-scoped alerting with: + +- **Destinations (Targets)**: Microsoft Teams incoming webhook and Email recipients. +- **Rules**: route by event type, minimum severity, and tenant scope. +- **Noise controls**: deterministic fingerprint dedupe, per-rule cooldown suppression, and quiet-hours deferral. +- **Delivery history**: read-only, includes `suppressed` entries. + +Delivery is queue-driven with bounded exponential backoff retries. All alert pages remain DB-only at render time and never expose destination secrets. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5 (Livewire v4.0+), Laravel Queue (database default) +**Storage**: PostgreSQL (Sail) +**Testing**: Pest v4 via `vendor/bin/sail artisan test --compact` +**Target Platform**: Laravel web app (Filament Admin) +**Project Type**: Web application +**Performance Goals**: Eligible alerts delivered within ~2 minutes outside quiet hours (SC-002) +**Constraints**: +- DB-only rendering for Targets/Rules/Deliveries pages (FR-015) +- No destination secrets in logs/audit payloads (FR-011) +- Retries use exponential backoff + bounded max attempts (FR-017) +**Scale/Scope**: Workspace-owned configuration + tenant-owned delivery history (90-day retention) (FR-016) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **Livewire/Filament**: Filament v5 implies Livewire v4.0+ (compliant). +- **Provider registration**: No new panel provider required; existing registration remains in `bootstrap/providers.php`. +- **RBAC semantics**: Enforce non-member → 404 (deny-as-not-found) and member missing capability → 403. +- **Capability registry**: Add `ALERTS_VIEW` and `ALERTS_MANAGE` to canonical registry; role maps reference only registry constants. +- **Destructive actions**: Deletes and other destructive-like actions use `->requiresConfirmation()` and execute via `->action(...)`. +- **Run observability**: Scheduled/queued scanning + deliveries create/reuse `OperationRun` for Monitoring → Operations visibility. +- **Safe logging**: Audit logging uses `WorkspaceAuditLogger` (sanitizes context) and never records webhook URLs / recipient lists. +- **Global search**: No new global search surfaces are required for v1; if enabled later, resources must have Edit/View pages and remain workspace-safe. + +Result: **PASS**, assuming the above constraints are implemented and covered by tests. + +## Project Structure + +### Documentation (this feature) + +```text +specs/099-alerts-v1-teams-email/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +└── tasks.md # created later by /speckit.tasks +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Pages/ +│ └── Resources/ +├── Jobs/ +├── Models/ +├── Policies/ +├── Services/ +│ ├── Audit/ +│ ├── Auth/ +│ └── Settings/ +└── Support/ + ├── Auth/ + └── Rbac/ + +database/ +└── migrations/ + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Use standard Laravel + Filament discovery conventions. Add Eloquent models + migrations for workspace-owned alert configuration + tenant-owned alert deliveries, queue jobs for evaluation + delivery, and Filament Resources/Pages under the existing Admin panel. + +## Phase 0 — Outline & Research (output: research.md) + +Unknowns / decisions to lock: + +- Teams delivery should use Laravel HTTP client (`Http::post()`) with timeouts and safe error capture. +- Email delivery should use Laravel mail/notifications and be queued. +- Quiet-hours timezone fallback: rule timezone if set; else workspace timezone; if no workspace timezone exists yet, fallback to `config('app.timezone')`. +- Secrets storage: use encrypted casts (`encrypted` / `encrypted:array`) for webhook URLs and recipient lists. +- Retries/backoff: use job `tries` and `backoff()` for exponential backoff with a max attempt cap. + +## Phase 1 — Design & Contracts (outputs: data-model.md, contracts/*, quickstart.md) + +### Data model + +Workspace-owned entities: + +- `AlertDestination`: + - `workspace_id` + - `name` + - `type` (`teams_webhook` | `email`) + - `is_enabled` + - `config` (encrypted array; contains webhook URL or recipient list) + +- `AlertRule`: + - `workspace_id` + - `name` + - `is_enabled` + - `event_type` (high_drift | compare_failed | sla_due) + - `minimum_severity` + - `tenant_scope_mode` (all | allowlist) + - `tenant_allowlist` (array of tenant IDs) + - `cooldown_seconds` + - `quiet_hours_enabled`, `quiet_hours_start`, `quiet_hours_end`, `quiet_hours_timezone` + +- `AlertRuleDestination` (pivot): `workspace_id`, `alert_rule_id`, `alert_destination_id` + +- `AlertDelivery` (history): + - `workspace_id` + - `tenant_id` + - `alert_rule_id`, `alert_destination_id` + - `fingerprint_hash` + - `status` (queued | deferred | sent | failed | suppressed | canceled) + - `send_after` (for quiet-hours deferral) + - `attempt_count`, `last_error_code`, `last_error_message` (sanitized) + - timestamps + +Retention: prune deliveries older than 90 days (default). + +### Contracts + +Create explicit schema/contracts for: + +- Alert rule/destination create/edit payloads (validation expectations) +- Delivery record shape (what UI displays) +- Domain event shapes used for fingerprinting (no secrets) + +### Filament surfaces + +- **Targets**: CRUD destinations. Confirm on delete. Never display secrets once saved. +- **Rules**: CRUD rules, enable/disable. Confirm destructive actions. +- **Deliveries**: read-only viewer. + +RBAC enforcement: + +- Page access: `ALERTS_VIEW`. +- Mutations: `ALERTS_MANAGE`. +- Non-member: deny-as-not-found (404) consistently. + - Non-member: deny-as-not-found (404) consistently. + - Deliveries are tenant-owned and MUST only be listed/viewable for tenants the actor is entitled to; non-entitled tenants are filtered and treated as not found (404 semantics). + - If a tenant-context is active in the current session, the Deliveries view SHOULD default-filter to that tenant. + +### Background processing (jobs + OperationRuns) + +- `alerts.evaluate` run: scans for new triggering events and creates `AlertDelivery` rows (including `suppressed`). +- `alerts.deliver` run: sends due deliveries (respecting `send_after`). + +Trigger sources (repo-grounded): + +- **High Drift**: derived from persisted drift findings (`Finding` records) with severity High/Critical where the finding is in `status=new` (unacknowledged). “Newly active/visible” means the finding first appears (a new `Finding` row is created), not that the same existing finding is re-alerted on every evaluation cycle. +- **Compare Failed**: derived from failed drift-generation operations (`OperationRun` where `type = drift_generate_findings` and `outcome = failed`). +- **SLA Due**: v1 implements this trigger as a safe no-op unless/until the underlying data model provides a due-date signal. + +Scheduling convention: + +- A scheduled console command (`tenantpilot:alerts:dispatch`) runs every minute (registered in `routes/console.php`) and dispatches the evaluate + deliver work idempotently. + +Idempotency: + +- Deterministic fingerprint; unique constraints where appropriate. +- Delivery send job transitions statuses atomically; if already terminal (`sent`/`failed`/`canceled`), it no-ops. + +### Audit logging + +All destination/rule mutations log via `WorkspaceAuditLogger` with redacted metadata: + +- Record IDs, names, types, enabled flags, rule criteria. +- Never include webhook URLs or recipient lists. + +## Phase 2 — Task Planning (outline; tasks.md comes next) + +1) Capabilities & policies +- Add `ALERTS_VIEW` / `ALERTS_MANAGE` to `App\Support\Auth\Capabilities`. +- Update `WorkspaceRoleCapabilityMap`. +- Add Policies for new models and enforce 404/403 semantics. + +2) Migrations + models +- Create migrations + Eloquent models for destinations/rules/pivot/deliveries. +- Add encrypted casts and safe `$hidden` where appropriate. + +3) Services +- Fingerprint builder +- Quiet hours evaluator +- Dispatcher to create deliveries and enqueue send jobs + +4) Jobs +- Evaluate triggers job +- Send delivery job with exponential backoff + max attempts + +5) Filament UI +- Implement Targets/Rules/Deliveries pages with action surfaces and confirmation. + +6) Tests (Pest) +- RBAC: 404 for non-members; 403 for members missing capability. +- Cooldown/dedupe: persists `suppressed` delivery history. +- Retry policy: transitions to `failed` after bounded attempts. + +## Complexity Tracking + +No constitution violations are required for this feature. diff --git a/specs/099-alerts-v1-teams-email/quickstart.md b/specs/099-alerts-v1-teams-email/quickstart.md new file mode 100644 index 0000000..4ed8a5b --- /dev/null +++ b/specs/099-alerts-v1-teams-email/quickstart.md @@ -0,0 +1,55 @@ +# Quickstart — 099 Alerts v1 (Teams + Email) + +This quickstart is for developers working on the Alerts v1 implementation. + +## Prereqs + +- Start Sail: `vendor/bin/sail up -d` +- Install deps (if needed): `vendor/bin/sail composer install` + +## Database + +- Run migrations: `vendor/bin/sail artisan migrate` + +## Queue + +Alerts delivery is queued. Ensure a worker is running: + +- `vendor/bin/sail artisan queue:work` + +Default queue connection is `database` (see `config/queue.php`). + +## Email configuration + +Configure mail in `.env` (examples): + +- `MAIL_MAILER=smtp` +- `MAIL_HOST=...` +- `MAIL_PORT=...` +- `MAIL_USERNAME=...` +- `MAIL_PASSWORD=...` +- `MAIL_ENCRYPTION=tls` +- `MAIL_FROM_ADDRESS=...` +- `MAIL_FROM_NAME=TenantPilot` + +## Teams webhook configuration + +Create a Teams incoming webhook URL and store it via the Alerts → Targets UI. + +Note: Webhook URLs are treated as secrets and must not appear in logs or audits. + +## Running tests + +Run focused tests as they’re added: + +- `vendor/bin/sail artisan test --compact --filter=Alerts` + +Or by file: + +- `vendor/bin/sail artisan test --compact tests/Feature/...` + +## Formatting + +Before finalizing changes: + +- `vendor/bin/sail bin pint --dirty` diff --git a/specs/099-alerts-v1-teams-email/research.md b/specs/099-alerts-v1-teams-email/research.md new file mode 100644 index 0000000..f4adc6b --- /dev/null +++ b/specs/099-alerts-v1-teams-email/research.md @@ -0,0 +1,47 @@ +# Research — 099 Alerts v1 (Teams + Email) + +This document resolves the Phase 0 technical unknowns from the implementation plan. + +## Decision: Delivery mechanism (Teams) + +- **Decision**: Use Laravel HTTP client (`Illuminate\Support\Facades\Http`) to POST JSON to a Teams incoming webhook URL. +- **Rationale**: The repo already uses the Laravel HTTP client for remote calls (e.g., Microsoft Graph). It provides timeouts and structured error handling, and it keeps delivery logic self-contained. +- **Alternatives considered**: + - Guzzle direct client: unnecessary since Laravel HTTP client is already available. + - Third-party Teams SDK: adds dependency surface; avoid without explicit need. + +## Decision: Delivery mechanism (Email) + +- **Decision**: Use Laravel mail/notification delivery and queue it. +- **Rationale**: Integrates with Laravel queue retries and provides a standard path for SMTP/mail providers. +- **Alternatives considered**: + - Direct SMTP calls: not aligned with framework patterns. + +## Decision: Retry + backoff policy + +- **Decision**: Implement bounded retries using queued jobs with exponential backoff. +- **Rationale**: Matches spec FR-017 and constitution guidance for transient failure handling. +- **Alternatives considered**: + - Retry loops inline: violates “start surfaces enqueue-only” and increases request latency. + +## Decision: Secrets storage and redaction + +- **Decision**: Store destination configuration (webhook URL, recipient list) using Laravel encrypted casts (`encrypted` / `encrypted:array`), and ensure audit/log context is sanitized. +- **Rationale**: The repo already uses encrypted casting for sensitive payloads (example: `ProviderCredential::$casts['payload' => 'encrypted:array']`). `WorkspaceAuditLogger` sanitizes metadata. +- **Alternatives considered**: + - Plaintext storage + hiding in UI: insufficient; secrets can leak to logs/DB dumps. + +## Decision: Quiet-hours timezone fallback + +- **Decision**: + 1) Use the rule’s `quiet_hours_timezone` when set. + 2) Else use a workspace-level timezone setting. + 3) If no workspace timezone exists yet, fallback to `config('app.timezone')`. +- **Rationale**: Implements spec FR-009 while remaining robust if workspace timezone is not yet modeled as a first-class setting. +- **Alternatives considered**: + - Always `UTC`: contradicts the clarified requirement (workspace fallback). + +## Notes / follow-ups + +- No existing mail delivery usage was found in `app/` at planning time; v1 will introduce the first alert-specific email delivery path. +- No existing Teams webhook sender was found; v1 will implement a minimal sender using Laravel HTTP client. diff --git a/specs/099-alerts-v1-teams-email/spec.md b/specs/099-alerts-v1-teams-email/spec.md new file mode 100644 index 0000000..64e4cb0 --- /dev/null +++ b/specs/099-alerts-v1-teams-email/spec.md @@ -0,0 +1,206 @@ +# Feature Specification: Alerts v1 (Teams + Email) + +**Feature Branch**: `099-alerts-v1-teams-email` +**Created**: 2026-02-16 +**Status**: Draft +**Input**: User description: "Alerts v1 (Microsoft Teams + Email)" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - Admin UI → Workspace → Monitoring → Alerts → Alert targets + - Admin UI → Workspace → Monitoring → Alerts → Alert rules + - Admin UI → Workspace → Monitoring → Alerts → Alert deliveries (read-only) +- **Data Ownership**: + - Workspace-owned alert configuration (rules + destinations) + - Tenant-owned alert delivery history (deliveries are always tenant-scoped) + - Deliveries are surfaced via workspace-context canonical UI routes, but MUST only reveal deliveries for tenants the actor is entitled to +- **Authorization Planes**: + - Admin panel `/admin` in **workspace-context** (workspace selected via session-based workspace context) + - This feature does **not** introduce tenant-context UI routes (no `/admin/t/{tenant}/...` pages for Alerts v1) +- **RBAC**: + - Workspace membership is required for any access (non-members are denied as not found / 404) + - Viewing alert configuration/history requires `ALERTS_VIEW` + - Creating/updating/enabling/disabling/deleting rules or destinations requires `ALERTS_MANAGE` + - Members without `ALERTS_VIEW` receive 403 for view-only access attempts + - Members without `ALERTS_MANAGE` receive 403 for mutation attempts; UI surfaces are disabled for them + - Viewing deliveries additionally requires tenant entitlement for each delivery’s tenant (non-entitled tenants are filtered and treated as not found / 404 semantics) + +## Clarifications + +### Session 2026-02-16 + +- Q: Should viewing the Alerts pages (Targets / Rules / Deliveries) require `ALERTS_VIEW`, or is workspace membership alone enough to view? → A: Viewing requires `ALERTS_VIEW` (members without it get 403). +- Q: When an event is suppressed due to cooldown/dedupe, should the system still create an entry in the delivery history (status = `suppressed`)? → A: Yes, create delivery history entries with `status=suppressed` (no send attempted). +- Q: Should `operation.compare_failed` fire only for the single canonical run type (baseline compare), or should v1 allow a per-rule run-type allowlist? → A: Fixed: only baseline compare failures (single canonical run type). +- Q: For quiet hours evaluation, what timezone should be used when a rule does not specify a timezone? → A: Fallback to workspace timezone. +- Q: When a Teams/email delivery attempt fails, which retry policy should v1 use? → A: Retry with exponential backoff up to a max attempts limit; then mark `failed`. + +### Assumptions & Dependencies + +- Drift findings and operational run outcomes already exist in the system and can be evaluated for alert triggers. +- Events are attributable to a workspace and (where applicable) a tenant so rules can apply tenant scoping. +- SLA-due alerts only apply if the underlying finding data includes a due date; otherwise this trigger is a no-op. + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Configure alert destinations (Priority: P1) + +As a workspace operator, I can define alert destinations (Microsoft Teams and/or email) that can later be reused by multiple alert rules. + +**Why this priority**: Without destinations, alerts cannot be delivered; this is the smallest useful slice. + +**Independent Test**: Create a destination, then confirm it is listed, viewable, and can be enabled/disabled. + +**Acceptance Scenarios**: + +1. **Given** I have `ALERTS_MANAGE`, **When** I create a Microsoft Teams destination with a name and webhook URL, **Then** the destination is saved and appears in the destinations list as enabled. +2. **Given** I have `ALERTS_MANAGE`, **When** I create an Email destination with one or more recipient addresses, **Then** the destination is saved and appears in the destinations list. +3. **Given** a destination exists, **When** I disable it, **Then** it is not used for future alert deliveries. + +--- + +### User Story 2 - Configure alert routing rules (Priority: P2) + +As a workspace manager, I can configure routing rules so that only relevant events (by type, severity, and tenant scope) generate alerts, and each rule can notify multiple destinations. + +**Why this priority**: Rules provide control over noise, scope, and who gets notified. + +**Independent Test**: Create a rule with at least one destination, then trigger one matching event and confirm exactly one delivery is queued per destination. + +**Acceptance Scenarios**: + +1. **Given** I have `ALERTS_MANAGE`, **When** I create a rule that matches a specific event type and minimum severity, **Then** the rule is saved and appears as enabled. +2. **Given** I configure a rule with tenant scope = allowlist, **When** an event from a non-allowlisted tenant occurs, **Then** no delivery is created for that rule. +3. **Given** a rule has multiple destinations assigned, **When** a matching event occurs, **Then** deliveries are created for each enabled destination. + +--- + +### User Story 3 - Deliver alerts safely (dedupe, cooldown, quiet hours) and review history (Priority: P3) + +As an operator, I receive timely notifications for important events without spam, and I can review what was sent (or failed) in a delivery history view. + +**Why this priority**: Alert quality and traceability are essential for governance and incident response. + +**Independent Test**: Trigger the same event twice within cooldown and confirm only one notification is sent; enable quiet hours and confirm delivery is deferred. + +**Acceptance Scenarios**: + +1. **Given** a rule has a cooldown configured, **When** the same event repeats within the cooldown window, **Then** later deliveries are suppressed for that rule. +2. **Given** quiet hours are enabled for a rule and the current time is within quiet hours (evaluated in the rule’s configured timezone or workspace timezone fallback), **When** a matching event occurs, **Then** a delivery is scheduled for the next allowed window rather than sent immediately. +3. **Given** I have `ALERTS_VIEW`, **When** I open the deliveries viewer, **Then** I can see delivery status and timestamps without exposing destination secrets. +4. **Given** an event is suppressed by cooldown, **When** I open the deliveries viewer, **Then** I can see a `suppressed` delivery entry that references the rule and destination (without exposing destination secrets). + +--- + +### Edge Cases + +- Quiet hours windows that cross midnight must still defer correctly to the next allowed time. +- Multiple background workers triggering the same event concurrently must not cause duplicate sends. +- A destination that is misconfigured (invalid webhook URL or invalid email address list) must fail safely and record a sanitized failure reason (no secrets). +- The UI must not make outbound network requests while rendering pages (no external calls during page load). +- SLA-due alerts are a no-op if the underlying data does not provide a due date yet (no errors; no false alerts). + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + +**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: +- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`), +- ensure any cross-plane access is deny-as-not-found (404), +- explicitly define 404 vs 403 semantics: + - non-member / not entitled to workspace scope OR tenant scope → 404 (deny-as-not-found) + - member but missing capability → 403 +- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, +- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), +- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics), +- ensure destructive-like actions require confirmation (`->requiresConfirmation()`), +- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated. + +**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange) +on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), +the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. + +**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page, +the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied. +If the contract is not satisfied, the spec MUST include an explicit exemption with rationale. + +### Functional Requirements + +- **FR-001 (Channels)**: The system MUST support alert delivery via Microsoft Teams (workspace-configured webhook destination) and via email (one or more recipient addresses per destination). +- **FR-002 (Workspace scoping)**: The system MUST scope alert rules and destinations to a workspace; rules/destinations MUST NOT be shared across workspaces. +- **FR-003 (Routing rules)**: The system MUST allow rules to filter alert generation by: + - event type + - minimum severity + - tenant scope (all tenants or allowlist) +- **FR-004 (Multiple destinations)**: The system MUST allow a rule to notify multiple destinations. +- **FR-005 (Event triggers v1)**: The system MUST support these trigger types: + - High Drift: when a new drift finding first appears (i.e., a new drift finding is created) with severity High or Critical, and it is in an unacknowledged/new state + - Compare Failed: when a baseline-compare operation run fails (fixed in v1; not configurable per rule) + - SLA Due: when a finding passes its due date and remains unresolved (if due date data is available) +- **FR-006 (Idempotency / dedupe)**: The system MUST prevent duplicate notifications for repeated occurrences of the same event for a given rule, using a deterministic event fingerprint that contains no secrets. +- **FR-007 (Cooldown)**: The system MUST support a per-rule cooldown window during which repeated fingerprints are suppressed. +- **FR-007a (Suppression visibility)**: When a notification is suppressed by cooldown/dedupe, the system MUST persist an entry in delivery history with `status=suppressed`. +- **FR-008 (Quiet hours)**: The system MUST support optional quiet hours per rule; events during quiet hours MUST be deferred to the next allowed time window. +- **FR-009 (Quiet hours timezone)**: Quiet hours MUST be evaluated in the rule’s configured timezone; if not set, the system MUST fallback to the workspace timezone. +- **FR-010 (Delivery history)**: The system MUST retain a delivery history view showing, at minimum: status (queued/deferred/sent/failed/suppressed/canceled), timestamps, event type, severity, tenant association, and the rule + destination used. +- **FR-011 (Safe logging)**: The system MUST NOT persist destination secrets (webhook URLs, email recipient lists) in logs, error messages, or audit payloads. +- **FR-012 (Auditability)**: The system MUST write auditable events for creation, updates, enable/disable, and deletion of rules and destinations. +- **FR-013 (Operations observability)**: Background work that scans for due alerts and performs alert delivery MUST be observable as operations runs with outcome and timestamps, so operators can diagnose failures. +- **FR-014 (RBAC semantics)**: Authorization MUST follow these semantics: + - non-member / not entitled to workspace scope → 404 + - member missing `ALERTS_VIEW` → 403 for viewing alert pages + - member missing `ALERTS_MANAGE` → 403 for create/update/delete/enable/disable +- **FR-015 (DB-only rendering)**: Alert management and delivery history pages MUST render without any outbound network requests. +- **FR-016 (Retention)**: Delivery history MUST be retained for 90 days by default. +- **FR-017 (Delivery retries)**: On delivery failure (Teams/email), the system MUST retry with exponential backoff up to a bounded maximum attempt limit; once the limit is reached, the delivery MUST be marked `failed`. + +## UI Action Matrix *(mandatory when Filament is changed)* + +If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below. + +For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?), +RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log. + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Alert Targets | Workspace → Monitoring → Alerts → Alert targets | Create target | Clickable row to Edit | Edit, More (Enable/Disable, Delete) | More (group rendered; no bulk mutations in v1) | Create target | None | Save, Cancel | Yes | Delete requires confirmation; secrets never shown/logged | +| Alert Rules | Workspace → Monitoring → Alerts → Alert rules | Create rule | Clickable row to Edit | Edit, More (Enable/Disable, Delete) | More (group rendered; no bulk mutations in v1) | Create rule | None | Save, Cancel | Yes | Enable/Disable and Delete are audited; both require confirmation | +| Alert Deliveries (read-only) | Workspace → Monitoring → Alerts → Alert deliveries | None | Clickable row to View | View | None | None | None | N/A | No | Read-only viewer; tenant entitlement filtering enforced | + +### Key Entities *(include if feature involves data)* + +- **Alert Destination**: A workspace-defined place to send notifications (Teams or email), which can be enabled/disabled. +- **Alert Rule**: A workspace-defined routing rule that decides which events should generate alerts and which destinations they should notify. +- **Alert Event**: A notable system occurrence (e.g., high drift, compare failure, SLA due) that may generate alerts. +- **Event Fingerprint**: A stable, deterministic identifier used to deduplicate repeated events per rule. +- **Alert Delivery**: A record of a planned or attempted notification send, including scheduling (quiet hours), status, and timestamps. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001 (Setup time)**: A workspace manager can create a destination and a rule and enable it in under 5 minutes. +- **SC-002 (Delivery timeliness)**: Outside quiet hours, at least 95% of eligible alerts are delivered within 2 minutes of the triggering event. +- **SC-003 (Noise control)**: Within a configured cooldown window, the same fingerprint does not generate more than one notification per rule. +- **SC-004 (Security hygiene)**: No destination secrets appear in application logs or audit payloads during normal operation or error cases. +- **SC-005 (Audit traceability)**: 100% of rule/destination create/update/enable/disable/delete actions are traceable via an audit record. diff --git a/specs/099-alerts-v1-teams-email/tasks.md b/specs/099-alerts-v1-teams-email/tasks.md new file mode 100644 index 0000000..d3fa0c6 --- /dev/null +++ b/specs/099-alerts-v1-teams-email/tasks.md @@ -0,0 +1,192 @@ +--- + +description: "Task list for 099 Alerts v1 (Teams + Email)" + +--- + +# Tasks: 099 — Alerts v1 (Teams + Email) + +**Input**: Design documents from `/specs/099-alerts-v1-teams-email/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +- Spec: `/specs/099-alerts-v1-teams-email/spec.md` +- Plan: `/specs/099-alerts-v1-teams-email/plan.md` +- Research: `/specs/099-alerts-v1-teams-email/research.md` +- Data model: `/specs/099-alerts-v1-teams-email/data-model.md` +- Contracts: `/specs/099-alerts-v1-teams-email/contracts/openapi.yaml` + +**Tests**: REQUIRED (Pest) — this feature changes runtime behavior. +**Operations**: Jobs that perform queued delivery MUST create/update canonical `OperationRun` records and show “View run” via the Monitoring hub. +**RBAC**: Enforce 404 vs 403 semantics and use capability registry constants (no raw strings). + +## Format: `[ID] [P?] [Story] Description` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Basic structure required by the plan + +- [X] T001 Create alerts namespace directories via keep-files `app/Services/Alerts/.gitkeep` and `app/Jobs/Alerts/.gitkeep` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: MUST complete before any user story work begins + +- [X] T002 Add Alerts capability constants to `app/Support/Auth/Capabilities.php` (`ALERTS_VIEW`, `ALERTS_MANAGE`) +- [X] T003 Update role → capability mapping in `app/Services/Auth/WorkspaceRoleCapabilityMap.php` for Alerts view/manage +- [X] T004 Add audit action IDs to `app/Support/Audit/AuditActionId.php` for Alerts (destination/rule create/update/delete + enable/disable) +- [X] T005 [P] Add operation type labels in `app/Support/OperationCatalog.php` (`alerts.evaluate`, `alerts.deliver`) +- [X] T006 [P] Implement workspace timezone resolver `app/Services/Alerts/WorkspaceTimezoneResolver.php` (fallback to `config('app.timezone')`) +- [X] T007 Update the Alerts “UI Action Matrix” in `specs/099-alerts-v1-teams-email/spec.md` for Targets/Rules/Deliveries surfaces (ensure it matches actual Filament actions) + +**Checkpoint**: Capabilities + audit IDs + operation type labels exist. + +--- + +## Phase 3: User Story 1 — Configure alert destinations (Priority: P1) 🎯 MVP + +**Goal**: Workspace members with manage permission can create Teams/email destinations. + +**Independent Test**: Create a Teams destination and an Email destination; ensure secrets are never revealed after save; list/edit/disable. + +### Tests for User Story 1 ⚠️ + +- [X] T008 [P] [US1] Add RBAC access tests (404 vs 403) in `tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php` +- [X] T009 [P] [US1] Add destination CRUD happy-path test in `tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php` + +### Implementation for User Story 1 + +- [X] T010 [P] [US1] Create migration `database/migrations/*_create_alert_destinations_table.php` (workspace_id, type, is_enabled, encrypted config, timestamps) +- [X] T011 [P] [US1] Create model `app/Models/AlertDestination.php` (workspace relationship; encrypted config cast; hide config) +- [X] T012 [P] [US1] Create policy `app/Policies/AlertDestinationPolicy.php` (404 non-member; 403 missing capability) +- [X] T013 [US1] Register policy mapping in `app/Providers/AuthServiceProvider.php` +- [X] T014 [US1] Implement Filament resource `app/Filament/Resources/AlertDestinationResource.php` (Targets; action-surface contract; max 2 visible row actions) +- [X] T015 [US1] Add Alerts navigation entry via Filament cluster (`app/Filament/Clusters/Monitoring/AlertsCluster.php`) and register Targets resource under it +- [X] T016 [US1] Add audited mutations in `app/Filament/Resources/AlertDestinationResource.php` using `app/Services/Audit/WorkspaceAuditLogger.php` + `app/Support/Audit/AuditActionId.php` (no secrets) +- [X] T017 [US1] Ensure destructive actions confirm in `app/Filament/Resources/AlertDestinationResource.php` (`->action(...)` + `->requiresConfirmation()`) + +**Checkpoint**: Destinations are functional and testable independently. + +--- + +## Phase 4: User Story 2 — Configure alert routing rules (Priority: P2) + +**Goal**: Workspace members with manage permission can define routing rules that send to destination(s). + +**Independent Test**: Create a rule, attach destinations, set tenant allowlist, and verify it is enforced during evaluation. + +### Tests for User Story 2 ⚠️ + +- [X] T018 [P] [US2] Add RBAC access tests (404 vs 403) in `tests/Feature/Filament/Alerts/AlertRuleAccessTest.php` +- [X] T019 [P] [US2] Add rule CRUD test (destinations attach) in `tests/Feature/Filament/Alerts/AlertRuleCrudTest.php` + +### Implementation for User Story 2 + +- [X] T020 [P] [US2] Create migrations `database/migrations/*_create_alert_rules_table.php` and `database/migrations/*_create_alert_rule_destinations_table.php` +- [X] T021 [P] [US2] Create models `app/Models/AlertRule.php` and `app/Models/AlertRuleDestination.php` (casts for allowlist + quiet-hours fields) +- [X] T022 [P] [US2] Create policy `app/Policies/AlertRulePolicy.php` (404 non-member; 403 missing capability) +- [X] T023 [US2] Register policy mapping in `app/Providers/AuthServiceProvider.php` +- [X] T024 [US2] Implement Filament resource `app/Filament/Resources/AlertRuleResource.php` (Rules; action-surface contract; destination picker) +- [X] T025 [US2] Implement enable/disable actions with audit logging in `app/Filament/Resources/AlertRuleResource.php` (use `->action(...)` and confirmations) +- [X] T026 [US2] Register Rules resource under Alerts cluster navigation (no manual `NavigationItem` duplicates) + +**Checkpoint**: Rules are functional and testable independently. + +--- + +## Phase 5: User Story 3 — Deliver alerts safely + review delivery history (Priority: P3) + +**Goal**: Queue-driven delivery with fingerprint dedupe, cooldown suppression, quiet-hours deferral, and a delivery history viewer. + +**Independent Test**: Trigger same event twice within cooldown → only one send + one `suppressed` record; quiet-hours → `deferred`; failures retry with exponential backoff then `failed`. + +### Tests for User Story 3 ⚠️ + +- [X] T027 [P] [US3] Add delivery viewer access test in `tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php` +- [X] T028 [P] [US3] Add fingerprint/cooldown suppression test in `tests/Unit/Alerts/AlertSuppressionTest.php` +- [X] T029 [P] [US3] Add quiet-hours deferral test in `tests/Unit/Alerts/AlertQuietHoursTest.php` +- [X] T030 [P] [US3] Add retry/backoff terminal failure test in `tests/Unit/Alerts/AlertRetryPolicyTest.php` + +### Implementation for User Story 3 + +- [X] T031 [P] [US3] Create deliveries migration `database/migrations/*_create_alert_deliveries_table.php` (workspace_id, tenant_id NOT NULL, status, fingerprint, send_after, attempt_count, last_error_code/message, indexes) +- [X] T032 [P] [US3] Create model `app/Models/AlertDelivery.php` (statuses incl. `suppressed`; prunable retention = 90 days default) +- [X] T033 [P] [US3] Create policy `app/Policies/AlertDeliveryPolicy.php` (view requires `ALERTS_VIEW`; enforce tenant entitlement; 404/403 semantics) +- [X] T034 [US3] Register policy mapping in `app/Providers/AuthServiceProvider.php` +- [X] T035 [P] [US3] Implement fingerprint + quiet-hours helpers `app/Services/Alerts/AlertFingerprintService.php` and `app/Services/Alerts/AlertQuietHoursService.php` +- [X] T036 [US3] Implement dispatcher `app/Services/Alerts/AlertDispatchService.php` (creates delivery rows; writes `suppressed` rows) with repo-grounded trigger sources: + - High Drift: from `Finding` (drift) severity high/critical where `status=new` (unacknowledged); “newly active/visible” means first appearance (new finding created) + - Compare Failed: from failed `OperationRun` where `type=drift_generate_findings` + - SLA Due: safe no-op until a due-date signal exists in persistence +- [X] T037 [P] [US3] Implement Teams sender `app/Services/Alerts/TeamsWebhookSender.php` (Laravel HTTP client; no secret logging) +- [X] T038 [P] [US3] Implement Email notification `app/Notifications/Alerts/EmailAlertNotification.php` (no secrets) +- [X] T039 [P] [US3] Implement evaluate job `app/Jobs/Alerts/EvaluateAlertsJob.php` (creates deliveries; records `OperationRun` type `alerts.evaluate`) +- [X] T040 [P] [US3] Create dispatch command `app/Console/Commands/TenantpilotDispatchAlerts.php` (`tenantpilot:alerts:dispatch`) that queues evaluation + delivery work idempotently +- [X] T041 [P] [US3] Wire scheduler in `routes/console.php` to run `tenantpilot:alerts:dispatch` every minute with `->withoutOverlapping()` +- [X] T042 [P] [US3] Implement delivery job `app/Jobs/Alerts/DeliverAlertsJob.php` (sends due deliveries; bounded retries + exponential backoff; records `OperationRun` type `alerts.deliver`) +- [X] T043 [P] [US3] Implement send service `app/Services/Alerts/AlertSender.php` (shared send orchestration for Teams/email; safe error capture; no secret logging) +- [X] T044 [US3] Add deliveries Filament resource `app/Filament/Resources/AlertDeliveryResource.php` (read-only; inspection affordance; no secrets; list/query must not reveal deliveries for non-entitled tenants) +- [X] T045 [US3] Register Deliveries resource under Alerts cluster navigation (no manual `NavigationItem` duplicates) + +**Checkpoint**: Delivery pipeline works with retries, suppression, quiet-hours deferral, and safe history. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T046 Update quickstart queue command in `specs/099-alerts-v1-teams-email/quickstart.md` (use `queue:work`) +- [X] T047 Run formatting `vendor/bin/sail bin pint --dirty` on `app/**` and `tests/**` +- [X] T048 Run focused tests `vendor/bin/sail artisan test --compact` for `tests/Feature/Filament/Alerts/**` and `tests/Unit/Alerts/**` + +## Phase 7: Navigation UX (Monitoring) + +- [X] T049 Restructure Alerts navigation under Monitoring (no Overview page; no content sub-navigation; Deliveries is default landing) +- [X] T050 Update OperateHubShell tests to use Alerts cluster landing and follow redirects + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Setup (Phase 1) → Foundational (Phase 2) → User stories → Polish + +### User Story Dependencies + +- US1 → US2 → US3 + +--- + +## Parallel Execution Examples + +### US1 + +- Tests: T008 + T009 +- Model/migration/policy: T010 + T011 + T012 + +### US2 + +- Tests: T018 + T019 +- Model/migrations/policy: T020 + T021 + T022 + +### US3 + +- Tests: T027–T030 +- Building blocks: T031 + T032 + T035 + T037 + T038 + T039–T041 + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1 + Phase 2 +2. Complete US1 +3. Validate via `tests/Feature/Filament/Alerts/AlertDestination*` + +### Incremental Delivery + +- Add US2 next, then US3; each story remains independently demoable. diff --git a/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php b/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php new file mode 100644 index 0000000..3dd37b9 --- /dev/null +++ b/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php @@ -0,0 +1,100 @@ +create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + $workspaceId = (int) $tenantA->workspace_id; + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenantA->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'event_type' => 'high_drift', + ]); + + AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenantB->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'event_type' => 'compare_failed', + ]); + + $this->actingAs($user) + ->get(AlertDeliveryResource::getUrl(panel: 'admin')) + ->assertOk() + ->assertSee('high_drift') + ->assertDontSee('compare_failed'); +}); + +it('returns 404 when a member from another workspace tries to view a delivery', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + $rule = AlertRule::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + $delivery = AlertDelivery::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + ]); + + $this->actingAs($user) + ->get(AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for members missing alerts view capability on deliveries index', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(AlertDeliveryResource::getUrl(panel: 'admin')) + ->assertForbidden(); +}); diff --git a/tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php b/tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php new file mode 100644 index 0000000..bf14966 --- /dev/null +++ b/tests/Feature/Filament/Alerts/AlertDestinationAccessTest.php @@ -0,0 +1,60 @@ +create(); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + $workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY); + expect($workspaceId)->not->toBe(0); + + $this->actingAs($user) + ->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for members missing alerts view capability', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(AlertDestinationResource::getUrl(panel: 'admin')) + ->assertForbidden(); +}); + +it('allows members with alerts view but forbids create for members without alerts manage', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user) + ->get(AlertDestinationResource::getUrl(panel: 'admin')) + ->assertOk(); + + $this->actingAs($user) + ->get(AlertDestinationResource::getUrl('create', panel: 'admin')) + ->assertForbidden(); +}); diff --git a/tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php b/tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php new file mode 100644 index 0000000..64d2225 --- /dev/null +++ b/tests/Feature/Filament/Alerts/AlertDestinationCrudTest.php @@ -0,0 +1,67 @@ +actingAs($user); + + Livewire::test(CreateAlertDestination::class) + ->fillForm([ + 'name' => 'Ops Teams', + 'type' => 'teams_webhook', + 'is_enabled' => true, + 'teams_webhook_url' => 'https://example.invalid/teams-webhook', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + Livewire::test(CreateAlertDestination::class) + ->fillForm([ + 'name' => 'Ops Email', + 'type' => 'email', + 'is_enabled' => true, + 'email_recipients' => ['ops@example.com', 'oncall@example.com'], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + expect(AlertDestination::query()->count())->toBe(2); + + $teams = AlertDestination::query()->where('name', 'Ops Teams')->first(); + $email = AlertDestination::query()->where('name', 'Ops Email')->first(); + + expect($teams)->not->toBeNull(); + expect($email)->not->toBeNull(); + + expect($teams->config['webhook_url'] ?? null)->toBe('https://example.invalid/teams-webhook'); + expect($email->config['recipients'] ?? null)->toBe(['ops@example.com', 'oncall@example.com']); + + expect(array_key_exists('config', $teams->toArray()))->toBeFalse(); + expect(array_key_exists('config', $email->toArray()))->toBeFalse(); + + $this->get(AlertDestinationResource::getUrl('edit', ['record' => $teams], panel: 'admin')) + ->assertOk() + ->assertDontSee('https://example.invalid/teams-webhook'); + + Livewire::test(EditAlertDestination::class, ['record' => $teams->getRouteKey()]) + ->fillForm([ + 'name' => 'Ops Teams Updated', + 'is_enabled' => false, + 'teams_webhook_url' => '', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $teams->refresh(); + + expect($teams->name)->toBe('Ops Teams Updated'); + expect((bool) $teams->is_enabled)->toBeFalse(); +}); diff --git a/tests/Feature/Filament/Alerts/AlertRuleAccessTest.php b/tests/Feature/Filament/Alerts/AlertRuleAccessTest.php new file mode 100644 index 0000000..c074cc0 --- /dev/null +++ b/tests/Feature/Filament/Alerts/AlertRuleAccessTest.php @@ -0,0 +1,57 @@ +create(); + $rule = AlertRule::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + $this->actingAs($user) + ->get(AlertRuleResource::getUrl('edit', ['record' => $rule], panel: 'admin')) + ->assertNotFound(); +}); + +it('returns 403 for members missing alerts view capability on rules index', function (): void { + $workspace = Workspace::factory()->create(); + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'readonly', + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + + $this->actingAs($user) + ->get(AlertRuleResource::getUrl(panel: 'admin')) + ->assertForbidden(); +}); + +it('allows members with alerts view but forbids create for members without alerts manage', function (): void { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user) + ->get(AlertRuleResource::getUrl(panel: 'admin')) + ->assertOk(); + + $this->actingAs($user) + ->get(AlertRuleResource::getUrl('create', panel: 'admin')) + ->assertForbidden(); +}); diff --git a/tests/Feature/Filament/Alerts/AlertRuleCrudTest.php b/tests/Feature/Filament/Alerts/AlertRuleCrudTest.php new file mode 100644 index 0000000..e16b770 --- /dev/null +++ b/tests/Feature/Filament/Alerts/AlertRuleCrudTest.php @@ -0,0 +1,66 @@ +get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY); + + $destinationA = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'name' => 'Teams destination', + ]); + $destinationB = AlertDestination::factory()->email()->create([ + 'workspace_id' => $workspaceId, + 'name' => 'Email destination', + ]); + + $this->actingAs($user); + + Livewire::test(CreateAlertRule::class) + ->fillForm([ + 'name' => 'Critical drift alerts', + 'is_enabled' => true, + 'event_type' => 'high_drift', + 'minimum_severity' => 'high', + 'tenant_scope_mode' => 'allowlist', + 'tenant_allowlist' => [(int) $tenant->getKey()], + 'cooldown_seconds' => 900, + 'quiet_hours_enabled' => true, + 'quiet_hours_start' => '22:00', + 'quiet_hours_end' => '06:00', + 'quiet_hours_timezone' => 'UTC', + 'destination_ids' => [(int) $destinationA->getKey(), (int) $destinationB->getKey()], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $rule = AlertRule::query()->where('name', 'Critical drift alerts')->first(); + expect($rule)->not->toBeNull(); + expect($rule->tenant_allowlist)->toBe([(int) $tenant->getKey()]); + expect($rule->destinations()->count())->toBe(2); + + Livewire::test(EditAlertRule::class, ['record' => $rule->getRouteKey()]) + ->fillForm([ + 'name' => 'Critical drift alerts updated', + 'is_enabled' => false, + 'destination_ids' => [(int) $destinationB->getKey()], + 'tenant_allowlist' => [], + 'tenant_scope_mode' => 'all', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $rule->refresh(); + expect($rule->name)->toBe('Critical drift alerts updated'); + expect((bool) $rule->is_enabled)->toBeFalse(); + expect($rule->tenant_scope_mode)->toBe('all'); + expect($rule->destinations()->pluck('alert_destinations.id')->all())->toBe([(int) $destinationB->getKey()]); +}); diff --git a/tests/Feature/Monitoring/MonitoringOperationsTest.php b/tests/Feature/Monitoring/MonitoringOperationsTest.php index 4162fd8..2f2fb13 100644 --- a/tests/Feature/Monitoring/MonitoringOperationsTest.php +++ b/tests/Feature/Monitoring/MonitoringOperationsTest.php @@ -38,8 +38,12 @@ ->assertOk(); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->followingRedirects() ->get('/admin/alerts') - ->assertOk(); + ->assertOk() + ->assertSee('Alert targets') + ->assertSee('Alert rules') + ->assertSee('Alert deliveries'); $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->get('/admin/audit-log') diff --git a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php index e712a4b..5eb6e17 100644 --- a/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php +++ b/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php @@ -148,6 +148,7 @@ $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) + ->followingRedirects() ->get('/admin/alerts') ->assertOk(); diff --git a/tests/Feature/OpsUx/OperateHubShellTest.php b/tests/Feature/OpsUx/OperateHubShellTest.php index 229cb67..cd3c953 100644 --- a/tests/Feature/OpsUx/OperateHubShellTest.php +++ b/tests/Feature/OpsUx/OperateHubShellTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Clusters\Monitoring\AlertsCluster; use App\Filament\Pages\TenantDashboard; use App\Filament\Resources\RestoreRunResource; use App\Models\AuditLog; @@ -47,7 +48,8 @@ ->assertSee('Scope: Workspace — all tenants'); $this->withSession($session) - ->get(route('admin.monitoring.alerts')) + ->followingRedirects() + ->get(AlertsCluster::getUrl(panel: 'admin')) ->assertOk() ->assertSee('Scope: Workspace — all tenants'); @@ -82,7 +84,13 @@ ->assertOk() ->assertSee('← Back to '.$tenant->name) ->assertSee(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant), false) - ->assertSee('Show all operations') + ->assertDontSee('Back to Operations'); + + $this->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + ])->followingRedirects() + ->get(AlertsCluster::getUrl(panel: 'admin')) + ->assertOk() ->assertDontSee('Back to Operations'); expect(substr_count((string) $response->getContent(), '← Back to '.$tenant->name))->toBe(1); @@ -253,7 +261,8 @@ ->assertOk(); $this->withSession($session) - ->get(route('admin.monitoring.alerts')) + ->followingRedirects() + ->get(AlertsCluster::getUrl(panel: 'admin')) ->assertOk(); $this->withSession($session) diff --git a/tests/Unit/Alerts/AlertQuietHoursTest.php b/tests/Unit/Alerts/AlertQuietHoursTest.php new file mode 100644 index 0000000..869fc6f --- /dev/null +++ b/tests/Unit/Alerts/AlertQuietHoursTest.php @@ -0,0 +1,63 @@ +workspace_id; + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'type' => AlertDestination::TYPE_TEAMS_WEBHOOK, + 'config' => ['webhook_url' => 'https://example.invalid/hook'], + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + 'event_type' => AlertRule::EVENT_HIGH_DRIFT, + 'minimum_severity' => 'high', + 'cooldown_seconds' => 0, + 'quiet_hours_enabled' => true, + 'quiet_hours_start' => '22:00', + 'quiet_hours_end' => '06:00', + 'quiet_hours_timezone' => 'UTC', + ]); + $rule->destinations()->syncWithPivotValues([(int) $destination->getKey()], ['workspace_id' => $workspaceId]); + + /** @var AlertDispatchService $dispatch */ + $dispatch = app(AlertDispatchService::class); + + $dispatch->dispatchEvent($rule->workspace, [ + 'event_type' => AlertRule::EVENT_HIGH_DRIFT, + 'tenant_id' => (int) $tenant->getKey(), + 'severity' => 'critical', + 'fingerprint_key' => 'finding:quiet-1', + 'title' => 'High drift detected', + 'body' => 'Quiet hours test', + ]); + + $delivery = AlertDelivery::query()->where('workspace_id', $workspaceId)->first(); + expect($delivery)->not->toBeNull(); + expect($delivery->status)->toBe(AlertDelivery::STATUS_DEFERRED); + expect($delivery->send_after)->not->toBeNull(); + expect($delivery->send_after?->greaterThan($now))->toBeTrue(); + } finally { + CarbonImmutable::setTestNow(); + } +}); diff --git a/tests/Unit/Alerts/AlertRetryPolicyTest.php b/tests/Unit/Alerts/AlertRetryPolicyTest.php new file mode 100644 index 0000000..5a9d863 --- /dev/null +++ b/tests/Unit/Alerts/AlertRetryPolicyTest.php @@ -0,0 +1,70 @@ +workspace_id; + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'type' => AlertDestination::TYPE_TEAMS_WEBHOOK, + 'config' => ['webhook_url' => 'https://example.invalid/hook'], + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + $rule->destinations()->syncWithPivotValues([(int) $destination->getKey()], ['workspace_id' => $workspaceId]); + + $delivery = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_QUEUED, + 'attempt_count' => 0, + 'send_after' => null, + ]); + + $sender = \Mockery::mock(AlertSender::class); + $sender->shouldReceive('send') + ->times(3) + ->andThrow(new RuntimeException('simulated sender failure')); + app()->instance(AlertSender::class, $sender); + + $runJob = static function (int $workspaceId): void { + $job = new DeliverAlertsJob($workspaceId); + app()->call([$job, 'handle']); + }; + + $runJob($workspaceId); + $delivery->refresh(); + expect($delivery->attempt_count)->toBe(1); + expect($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED); + expect($delivery->send_after)->not->toBeNull(); + + $delivery->forceFill(['send_after' => now()->subSecond()])->save(); + $runJob($workspaceId); + $delivery->refresh(); + expect($delivery->attempt_count)->toBe(2); + expect($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED); + expect($delivery->send_after)->not->toBeNull(); + + $delivery->forceFill(['send_after' => now()->subSecond()])->save(); + $runJob($workspaceId); + $delivery->refresh(); + expect($delivery->attempt_count)->toBe(3); + expect($delivery->status)->toBe(AlertDelivery::STATUS_FAILED); + expect($delivery->last_error_message)->toContain('simulated sender failure'); +}); diff --git a/tests/Unit/Alerts/AlertSuppressionTest.php b/tests/Unit/Alerts/AlertSuppressionTest.php new file mode 100644 index 0000000..3832b52 --- /dev/null +++ b/tests/Unit/Alerts/AlertSuppressionTest.php @@ -0,0 +1,57 @@ +workspace_id; + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'type' => AlertDestination::TYPE_TEAMS_WEBHOOK, + 'config' => ['webhook_url' => 'https://example.invalid/hook'], + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + 'event_type' => AlertRule::EVENT_HIGH_DRIFT, + 'minimum_severity' => 'high', + 'cooldown_seconds' => 3600, + ]); + $rule->destinations()->syncWithPivotValues([(int) $destination->getKey()], ['workspace_id' => $workspaceId]); + + /** @var AlertDispatchService $dispatch */ + $dispatch = app(AlertDispatchService::class); + + $event = [ + 'event_type' => AlertRule::EVENT_HIGH_DRIFT, + 'tenant_id' => (int) $tenant->getKey(), + 'severity' => 'critical', + 'fingerprint_key' => 'finding:123', + 'title' => 'High drift detected', + 'body' => 'Test drift', + ]; + + $dispatch->dispatchEvent($rule->workspace, $event); + $dispatch->dispatchEvent($rule->workspace, $event); + + $deliveries = AlertDelivery::query() + ->where('workspace_id', $workspaceId) + ->orderBy('id') + ->get(); + + expect($deliveries)->toHaveCount(2); + expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED); + expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED); +});