feat(alerts): Monitoring cluster + v1 resources (spec 099) (#121)

Implements spec `099-alerts-v1-teams-email`.

- Monitoring navigation: Alerts as a cluster under Monitoring; default landing is Alert deliveries.
- Tenant panel: Alerts points to `/admin/alerts` and the cluster navigation is hidden in tenant panel.
- Guard compliance: removes direct `Gate::` usage from Alert resources so `NoAdHocFilamentAuthPatternsTest` passes.

Verification:
- Full suite: `1348 passed, 7 skipped` (EXIT=0).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #121
This commit is contained in:
ahmido 2026-02-18 15:20:43 +00:00
parent c57f680f39
commit 3ed275cef3
72 changed files with 5513 additions and 32 deletions

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Alerts\DeliverAlertsJob;
use App\Jobs\Alerts\EvaluateAlertsJob;
use App\Models\Workspace;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class TenantpilotDispatchAlerts extends Command
{
protected $signature = 'tenantpilot:alerts:dispatch {--workspace=* : Limit dispatch to one or more workspace IDs}';
protected $description = 'Queue workspace-scoped alert evaluation and delivery jobs idempotently.';
public function handle(OperationRunService $operationRuns): int
{
if (! (bool) config('tenantpilot.alerts.enabled', true)) {
return self::SUCCESS;
}
$workspaceFilter = array_values(array_filter(array_map(
static fn (mixed $value): int => (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<int, int> $workspaceIds
* @return Collection<int, Workspace>
*/
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();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Filament\Clusters\Monitoring;
use BackedEnum;
use Filament\Clusters\Cluster;
use Filament\Facades\Filament;
use Filament\Pages\Enums\SubNavigationPosition;
use UnitEnum;
class AlertsCluster extends Cluster
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?int $navigationSort = 20;
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Start;
public static function shouldRegisterNavigation(): bool
{
return Filament::getCurrentPanel()?->getId() === 'admin';
}
}

View File

@ -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<Action>
*/

View File

@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Models\AlertDelivery;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class AlertDeliveryResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertDelivery::class;
protected static ?string $slug = 'alert-deliveries';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 1;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'id';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert deliveries';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', 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',
};
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDeliveryResource\Pages;
use App\Filament\Resources\AlertDeliveryResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Resources\Pages\ListRecords;
class ListAlertDeliveries extends ListRecords
{
protected static string $resource = AlertDeliveryResource::class;
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
}
}

View File

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

View File

@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertDestinationResource\Pages;
use App\Models\AlertDestination;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class AlertDestinationResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertDestination::class;
protected static ?string $slug = 'alert-destinations';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 3;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-bell-alert';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert targets';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', 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<string, mixed> $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<string, string>
*/
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<string, mixed> $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<string, mixed> $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(),
);
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
class CreateAlertDestination extends CreateRecord
{
protected static string $resource = AlertDestinationResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$workspaceId = app(\App\Support\Workspaces\WorkspaceContext::class)->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();
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
class EditAlertDestination extends EditRecord
{
protected static string $resource = AlertDestinationResource::class;
protected function mutateFormDataBeforeSave(array $data): array
{
$record = $this->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();
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertDestinationResource\Pages;
use App\Filament\Resources\AlertDestinationResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAlertDestinations extends ListRecords
{
protected static string $resource = AlertDestinationResource::class;
protected function getHeaderActions(): array
{
return [
...app(OperateHubShell::class)->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()),
];
}
}

View File

@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Clusters\Monitoring\AlertsCluster;
use App\Filament\Resources\AlertRuleResource\Pages;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use UnitEnum;
class AlertRuleResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = AlertRule::class;
protected static ?string $slug = 'alert-rules';
protected static ?string $cluster = AlertsCluster::class;
protected static ?int $navigationSort = 2;
protected static bool $isGloballySearchable = false;
protected static ?string $recordTitleAttribute = 'name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
protected static ?string $navigationLabel = 'Alert rules';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
public static function canViewAny(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('viewAny', AlertRule::class);
}
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->can('create', AlertRule::class);
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertRule) {
return false;
}
return $user->can('update', $record);
}
public static function canDelete(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User || ! $record instanceof AlertRule) {
return false;
}
return $user->can('delete', $record);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations are exposed for alert rules in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state create CTA.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides default save/cancel actions.');
}
public static function getEloquentQuery(): Builder
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return parent::getEloquentQuery()
->with('destinations')
->when(
$workspaceId !== null,
fn (Builder $query): Builder => $query->where('workspace_id', (int) $workspaceId),
)
->when(
$workspaceId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
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<string, mixed> $data
* @return array<string, mixed>
*/
public static function normalizePayload(array $data): array
{
$tenantAllowlist = Arr::wrap($data['tenant_allowlist'] ?? []);
$tenantAllowlist = array_values(array_unique(array_filter(array_map(static fn (mixed $value): int => (int) $value, $tenantAllowlist))));
if (($data['tenant_scope_mode'] ?? AlertRule::TENANT_SCOPE_ALL) !== AlertRule::TENANT_SCOPE_ALLOWLIST) {
$tenantAllowlist = [];
}
$quietHoursEnabled = (bool) ($data['quiet_hours_enabled'] ?? false);
$data['is_enabled'] = (bool) ($data['is_enabled'] ?? true);
$data['tenant_allowlist'] = $tenantAllowlist;
$data['cooldown_seconds'] = is_numeric($data['cooldown_seconds'] ?? null) ? (int) $data['cooldown_seconds'] : null;
$data['quiet_hours_enabled'] = $quietHoursEnabled;
if (! $quietHoursEnabled) {
$data['quiet_hours_start'] = null;
$data['quiet_hours_end'] = null;
$data['quiet_hours_timezone'] = null;
}
return $data;
}
/**
* @param array<int, int> $destinationIds
*/
public static function syncDestinations(AlertRule $record, array $destinationIds): void
{
$allowedDestinationIds = AlertDestination::query()
->where('workspace_id', (int) $record->workspace_id)
->whereIn('id', $destinationIds)
->pluck('id')
->map(static fn (mixed $value): int => (int) $value)
->all();
$record->destinations()->syncWithPivotValues(
array_values(array_unique($allowedDestinationIds)),
['workspace_id' => (int) $record->workspace_id],
);
}
/**
* @return array<string, string>
*/
public static function eventTypeOptions(): array
{
return [
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
AlertRule::EVENT_SLA_DUE => 'SLA due',
];
}
/**
* @return array<string, string>
*/
public static function severityOptions(): array
{
return [
'low' => 'Low',
'medium' => 'Medium',
'high' => 'High',
'critical' => 'Critical',
];
}
public static function eventTypeLabel(string $eventType): string
{
return self::eventTypeOptions()[$eventType] ?? ucfirst(str_replace('_', ' ', $eventType));
}
/**
* @return array<int, string>
*/
private static function destinationOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return AlertDestination::query()
->where('workspace_id', $workspaceId)
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<int, string>
*/
private static function tenantOptions(): array
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return [];
}
return Tenant::query()
->where('workspace_id', $workspaceId)
->where('status', 'active')
->orderBy('name')
->pluck('name', 'id')
->all();
}
/**
* @return array<string, string>
*/
private static function timezoneOptions(): array
{
$identifiers = \DateTimeZone::listIdentifiers();
sort($identifiers);
return array_combine($identifiers, $identifiers);
}
/**
* @param array<string, mixed> $metadata
*/
public static function audit(AlertRule $record, AuditActionId $actionId, array $metadata): void
{
$workspace = $record->workspace;
if ($workspace === null) {
return;
}
$actor = auth()->user();
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: $actionId->value,
context: [
'metadata' => $metadata,
],
actor: $actor instanceof User ? $actor : null,
resourceType: 'alert_rule',
resourceId: (string) $record->getKey(),
);
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertRule;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Arr;
class CreateAlertRule extends CreateRecord
{
protected static string $resource = AlertRuleResource::class;
/**
* @var array<int, int>
*/
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();
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertRule;
use App\Support\Audit\AuditActionId;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Arr;
class EditAlertRule extends EditRecord
{
protected static string $resource = AlertRuleResource::class;
/**
* @var array<int, int>
*/
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();
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AlertRuleResource\Pages;
use App\Filament\Resources\AlertRuleResource;
use App\Support\OperateHub\OperateHubShell;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAlertRules extends ListRecords
{
protected static string $resource = AlertRuleResource::class;
protected function getHeaderActions(): array
{
return [
...app(OperateHubShell::class)->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()),
];
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Alerts;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder;
class AlertsKpiHeader extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
/**
* @return array<Stat>
*/
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;
}
}

0
app/Jobs/Alerts/.gitkeep Normal file
View File

View File

@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Alerts;
use App\Models\AlertDelivery;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Alerts\AlertSender;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class DeliverAlertsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $workspaceId,
public ?int $operationRunId = null,
) {}
public function handle(AlertSender $alertSender, OperationRunService $operationRuns): void
{
$workspace = Workspace::query()->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);
}
}

View File

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Alerts;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Alerts\AlertDispatchService;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class EvaluateAlertsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $workspaceId,
public ?int $operationRunId = null,
) {}
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void
{
$workspace = Workspace::query()->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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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);
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Prunable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AlertDelivery extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
use Prunable;
public const string STATUS_QUEUED = 'queued';
public const string STATUS_DEFERRED = 'deferred';
public const string STATUS_SENT = 'sent';
public const string STATUS_FAILED = 'failed';
public const string STATUS_SUPPRESSED = 'suppressed';
public const string STATUS_CANCELED = 'canceled';
protected $guarded = [];
protected $casts = [
'send_after' => '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);
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AlertDestination extends Model
{
use HasFactory;
public const string TYPE_TEAMS_WEBHOOK = 'teams_webhook';
public const string TYPE_EMAIL = 'email';
protected $guarded = [];
protected $hidden = [
'config',
];
protected $casts = [
'is_enabled' => '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);
}
}

65
app/Models/AlertRule.php Normal file
View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AlertRule extends Model
{
use HasFactory;
public const string EVENT_HIGH_DRIFT = 'high_drift';
public const string EVENT_COMPARE_FAILED = 'compare_failed';
public const string EVENT_SLA_DUE = 'sla_due';
public const string TENANT_SCOPE_ALL = 'all';
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
protected $guarded = [];
protected $casts = [
'is_enabled' => '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);
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
class AlertRuleDestination extends Pivot
{
protected $table = 'alert_rule_destinations';
protected $guarded = [];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function alertRule(): BelongsTo
{
return $this->belongsTo(AlertRule::class);
}
public function alertDestination(): BelongsTo
{
return $this->belongsTo(AlertDestination::class);
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Notifications\Alerts;
use App\Models\AlertDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EmailAlertNotification extends Notification
{
use Queueable;
/**
* @param array<string, mixed> $payload
*/
public function __construct(
private readonly AlertDelivery $delivery,
private readonly array $payload,
) {}
/**
* @return array<int, string>
*/
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);
}
}

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\AlertDelivery;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Auth\Access\Response;
class AlertDeliveryPolicy
{
public function viewAny(User $user): bool|Response
{
$workspace = $this->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();
}
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\AlertDestination;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Auth\Access\Response;
class AlertDestinationPolicy
{
public function viewAny(User $user): bool|Response
{
$workspace = $this->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();
}
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\AlertRule;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Auth\Access\Response;
class AlertRulePolicy
{
public function viewAny(User $user): bool|Response
{
$workspace = $this->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();
}
}

View File

@ -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

View File

@ -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')

View File

@ -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),

View File

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Models\AlertDelivery;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\Workspace;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class AlertDispatchService
{
public function __construct(
private readonly AlertFingerprintService $fingerprintService,
private readonly AlertQuietHoursService $quietHoursService,
) {}
/**
* @param array<string, mixed> $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<string, mixed> $event
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Models\AlertDestination;
use App\Models\AlertRule;
class AlertFingerprintService
{
/**
* @param array<string, mixed> $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<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeArray(array $payload): array
{
ksort($payload);
foreach ($payload as $key => $value) {
if (is_array($value)) {
$payload[$key] = $this->normalizeArray($value);
}
}
return $payload;
}
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Models\AlertRule;
use App\Models\Workspace;
use Carbon\CarbonImmutable;
class AlertQuietHoursService
{
public function __construct(
private readonly WorkspaceTimezoneResolver $workspaceTimezoneResolver,
) {}
public function deferUntil(AlertRule $rule, Workspace $workspace, ?CarbonImmutable $now = null): ?CarbonImmutable
{
if (! (bool) $rule->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('/^(?<hour>[01]\\d|2[0-3]):(?<minute>[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);
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Notifications\Alerts\EmailAlertNotification;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Notification;
use RuntimeException;
use Throwable;
class AlertSender
{
public function __construct(
private readonly TeamsWebhookSender $teamsWebhookSender,
) {}
public function send(AlertDelivery $delivery): void
{
$destination = $delivery->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<string, mixed> $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<string, mixed> $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.',
};
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class TeamsWebhookSender
{
/**
* @param array<string, mixed> $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<string, mixed> $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;
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Alerts;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
class WorkspaceTimezoneResolver
{
public function resolve(Workspace $workspace): string
{
$candidates = [
$this->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;
}
}

View File

@ -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,
],
];

View File

@ -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';
}

View File

@ -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';

View File

@ -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')

View File

@ -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,
};
}

View File

@ -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.

View File

@ -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),

View File

@ -0,0 +1,78 @@
<?php
namespace Database\Factories;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<AlertDelivery>
*/
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',
],
];
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Database\Factories;
use App\Models\AlertDestination;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<AlertDestination>
*/
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(),
],
],
]);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\AlertRule;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<AlertRule>
*/
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,
];
}
}

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('alert_destinations', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('alert_rules', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('alert_rule_destinations', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('alert_deliveries', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -1,7 +1,33 @@
<x-filament-panels::page>
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Alerts is reserved for future work.
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
Configure alert targets and rules, then review delivery history.
</div>
<div class="flex flex-wrap gap-3">
@if (\App\Filament\Resources\AlertDestinationResource::canViewAny())
<x-filament::button tag="a" :href="\App\Filament\Resources\AlertDestinationResource::getUrl(panel: 'admin')">
Alert targets
</x-filament::button>
@endif
@if (\App\Filament\Resources\AlertRuleResource::canViewAny())
<x-filament::button tag="a" :href="\App\Filament\Resources\AlertRuleResource::getUrl(panel: 'admin')">
Alert rules
</x-filament::button>
@endif
@if (\App\Filament\Resources\AlertDeliveryResource::canViewAny())
<x-filament::button tag="a" :href="\App\Filament\Resources\AlertDeliveryResource::getUrl(panel: 'admin')">
Alert deliveries
</x-filament::button>
@endif
<x-filament::button tag="a" color="gray" :href="route('admin.monitoring.audit-log')">
Audit Log
</x-filament::button>
</div>
</div>
</div>
</x-filament::section>
</x-filament-panels::page>

View File

@ -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()

View File

@ -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',

View File

@ -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 repos constitution requires it. No code-level implementation choices are included.

View File

@ -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]

View File

@ -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`.

View File

@ -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.

View File

@ -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 theyre 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`

View File

@ -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 rules `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.

View File

@ -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 deliverys 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)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### 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 rules 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 rules 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.

View File

@ -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: T027T030
- Building blocks: T031 + T032 + T035 + T037 + T038 + T039T041
---
## 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.

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDeliveryResource;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
it('lists only deliveries for entitled tenants', function (): void {
[$user, $tenantA] = createUserWithTenant(role: 'readonly');
$tenantB = Tenant::factory()->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();
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDestinationResource;
use App\Models\AlertDestination;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
it('returns 404 when a workspace member tries to edit a destination from another workspace', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$otherWorkspace = Workspace::factory()->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();
});

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDestinationResource;
use App\Filament\Resources\AlertDestinationResource\Pages\CreateAlertDestination;
use App\Filament\Resources\AlertDestinationResource\Pages\EditAlertDestination;
use App\Models\AlertDestination;
use Livewire\Livewire;
it('creates teams and email destinations, hides secrets, and allows edit/disable', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->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();
});

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertRule;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
it('returns 404 when a workspace member tries to edit a rule from another workspace', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$otherWorkspace = Workspace::factory()->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();
});

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertRuleResource\Pages\CreateAlertRule;
use App\Filament\Resources\AlertRuleResource\Pages\EditAlertRule;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use Livewire\Livewire;
it('creates and edits alert rules with attached destinations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->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()]);
});

View File

@ -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')

View File

@ -148,6 +148,7 @@
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->followingRedirects()
->get('/admin/alerts')
->assertOk();

View File

@ -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)

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Services\Alerts\AlertDispatchService;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('defers delivery during quiet hours', function (): void {
$now = CarbonImmutable::parse('2026-02-16 23:30:00', 'UTC');
CarbonImmutable::setTestNow($now);
try {
[, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->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();
}
});

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Jobs\Alerts\DeliverAlertsJob;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Services\Alerts\AlertSender;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('marks delivery as failed after bounded retry attempts', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->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');
});

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Services\Alerts\AlertDispatchService;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('writes a suppressed delivery when cooldown fingerprint matches', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->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);
});