Compare commits
2 Commits
114-system
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| fdfb781144 | |||
| 0cf612826f |
@ -5,14 +5,13 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -58,6 +57,10 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
public ?string $lastComparedAt = null;
|
||||
|
||||
public ?string $lastComparedIso = null;
|
||||
|
||||
public ?string $failureReason = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -79,101 +82,24 @@ public static function canAccess(): bool
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->state = 'no_tenant';
|
||||
$this->message = 'No tenant selected.';
|
||||
|
||||
return;
|
||||
$this->refreshStats();
|
||||
}
|
||||
|
||||
$assignment = BaselineTenantAssignment::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->first();
|
||||
public function refreshStats(): void
|
||||
{
|
||||
$stats = BaselineCompareStats::forTenant(Tenant::current());
|
||||
|
||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||
$this->state = 'no_assignment';
|
||||
$this->message = 'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$profile = $assignment->baselineProfile;
|
||||
|
||||
if ($profile === null) {
|
||||
$this->state = 'no_assignment';
|
||||
$this->message = 'The assigned baseline profile no longer exists.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->profileName = (string) $profile->name;
|
||||
$this->profileId = (int) $profile->getKey();
|
||||
$this->snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||
|
||||
if ($this->snapshotId === null) {
|
||||
$this->state = 'no_snapshot';
|
||||
$this->message = 'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'baseline_compare')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
$this->state = 'comparing';
|
||||
$this->operationRunId = (int) $latestRun->getKey();
|
||||
$this->message = 'A baseline comparison is currently in progress.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
|
||||
$this->lastComparedAt = $latestRun->finished_at->diffForHumans();
|
||||
}
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$findingsQuery = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey);
|
||||
|
||||
$totalFindings = (int) (clone $findingsQuery)->count();
|
||||
|
||||
if ($totalFindings > 0) {
|
||||
$this->state = 'ready';
|
||||
$this->findingsCount = $totalFindings;
|
||||
$this->severityCounts = [
|
||||
'high' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_HIGH)->count(),
|
||||
'medium' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_MEDIUM)->count(),
|
||||
'low' => (int) (clone $findingsQuery)->where('severity', Finding::SEVERITY_LOW)->count(),
|
||||
];
|
||||
|
||||
if ($latestRun instanceof OperationRun) {
|
||||
$this->operationRunId = (int) $latestRun->getKey();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
|
||||
$this->state = 'ready';
|
||||
$this->findingsCount = 0;
|
||||
$this->operationRunId = (int) $latestRun->getKey();
|
||||
$this->message = 'No drift findings for this baseline comparison. The tenant matches the baseline.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->state = 'idle';
|
||||
$this->message = 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.';
|
||||
$this->state = $stats->state;
|
||||
$this->message = $stats->message;
|
||||
$this->profileName = $stats->profileName;
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
$this->lastComparedAt = $stats->lastComparedHuman;
|
||||
$this->lastComparedIso = $stats->lastComparedIso;
|
||||
$this->failureReason = $stats->failureReason;
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
@ -206,7 +132,7 @@ private function compareNowAction(): Action
|
||||
->modalHeading('Start baseline comparison')
|
||||
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
|
||||
->visible(fn (): bool => $this->canCompare())
|
||||
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready'], true))
|
||||
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
@ -47,12 +48,15 @@ class WorkspaceSettings extends Page
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
/**
|
||||
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
|
||||
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
||||
*/
|
||||
private const SETTING_FIELDS = [
|
||||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||||
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||
'baseline_severity_mapping' => ['domain' => 'baseline', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
||||
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
||||
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
||||
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
||||
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
||||
@ -79,6 +83,17 @@ class WorkspaceSettings extends Page
|
||||
'findings_sla_low' => 'low',
|
||||
];
|
||||
|
||||
/**
|
||||
* Baseline severity mapping is edited as explicit fields per change type.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const BASELINE_MAPPING_SUB_FIELDS = [
|
||||
'baseline_severity_missing_policy' => 'missing_policy',
|
||||
'baseline_severity_different_version' => 'different_version',
|
||||
'baseline_severity_unexpected_policy' => 'unexpected_policy',
|
||||
];
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
/**
|
||||
@ -206,6 +221,52 @@ public function content(Schema $schema): Schema
|
||||
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
|
||||
->hintAction($this->makeResetAction('drift_severity_mapping')),
|
||||
]),
|
||||
Section::make('Baseline settings')
|
||||
->key('baseline_section')
|
||||
->description($this->sectionDescription('baseline', 'Tune baseline drift severity mapping, alert threshold, and stale-finding auto-close behavior.'))
|
||||
->columns(2)
|
||||
->afterHeader([
|
||||
$this->makeResetAction('baseline_severity_mapping')->label('Reset mapping')->size('sm'),
|
||||
$this->makeResetAction('baseline_alert_min_severity')->label('Reset threshold')->size('sm'),
|
||||
$this->makeResetAction('baseline_auto_close_enabled')->label('Reset auto-close')->size('sm'),
|
||||
])
|
||||
->schema([
|
||||
Select::make('baseline_severity_missing_policy')
|
||||
->label('Missing policy severity')
|
||||
->options(self::severityOptions())
|
||||
->placeholder('Unset (uses default)')
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->baselineSeverityFieldHelperText('missing_policy')),
|
||||
Select::make('baseline_severity_different_version')
|
||||
->label('Different version severity')
|
||||
->options(self::severityOptions())
|
||||
->placeholder('Unset (uses default)')
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->baselineSeverityFieldHelperText('different_version')),
|
||||
Select::make('baseline_severity_unexpected_policy')
|
||||
->label('Unexpected policy severity')
|
||||
->options(self::severityOptions())
|
||||
->placeholder('Unset (uses default)')
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->baselineSeverityFieldHelperText('unexpected_policy')),
|
||||
Select::make('baseline_alert_min_severity')
|
||||
->label('Minimum alert severity')
|
||||
->options(self::severityOptions())
|
||||
->placeholder('Unset (uses default)')
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->helperTextFor('baseline_alert_min_severity')),
|
||||
Select::make('baseline_auto_close_enabled')
|
||||
->label('Auto-close stale drift')
|
||||
->options(self::booleanOptions())
|
||||
->placeholder('Unset (uses default)')
|
||||
->native(false)
|
||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||||
->helperText(fn (): string => $this->helperTextFor('baseline_auto_close_enabled')),
|
||||
]),
|
||||
Section::make('Findings settings')
|
||||
->key('findings_section')
|
||||
->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.'))
|
||||
@ -302,6 +363,7 @@ public function save(): void
|
||||
|
||||
$this->resetValidation();
|
||||
|
||||
$this->composeBaselineSeveritySubFieldsIntoData();
|
||||
$this->composeSlaSubFieldsIntoData();
|
||||
|
||||
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
|
||||
@ -422,6 +484,7 @@ private function loadFormState(): void
|
||||
: $this->formatValueForInput($field, $workspaceValue);
|
||||
}
|
||||
|
||||
$this->decomposeBaselineSeveritySubFields($data, $workspaceOverrides);
|
||||
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
|
||||
|
||||
$this->data = $data;
|
||||
@ -560,6 +623,36 @@ private function slaFieldHelperText(string $severity): string
|
||||
return sprintf('Effective: %d days.', $effectiveValue);
|
||||
}
|
||||
|
||||
private function baselineSeverityFieldHelperText(string $changeType): string
|
||||
{
|
||||
$resolved = $this->resolvedSettings['baseline_severity_mapping'] ?? null;
|
||||
|
||||
if (! is_array($resolved)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$effectiveValue = is_array($resolved['value'] ?? null)
|
||||
? (string) ($resolved['value'][$changeType] ?? '')
|
||||
: '';
|
||||
|
||||
$systemDefault = is_array($resolved['system_default'] ?? null)
|
||||
? (string) ($resolved['system_default'][$changeType] ?? '')
|
||||
: '';
|
||||
|
||||
if (! $this->hasWorkspaceOverride('baseline_severity_mapping')) {
|
||||
return sprintf('Default: %s.', $systemDefault);
|
||||
}
|
||||
|
||||
if (
|
||||
is_array($this->workspaceOverrideForField('baseline_severity_mapping'))
|
||||
&& array_key_exists($changeType, $this->workspaceOverrideForField('baseline_severity_mapping'))
|
||||
) {
|
||||
return sprintf('Effective: %s.', $effectiveValue);
|
||||
}
|
||||
|
||||
return sprintf('Unset. Effective value: %s (system default).', $effectiveValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
|
||||
*/
|
||||
@ -612,6 +705,16 @@ private function normalizedInputValues(): array
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field === 'baseline_severity_mapping') {
|
||||
foreach (array_keys(self::BASELINE_MAPPING_SUB_FIELDS) as $subField) {
|
||||
$validationErrors['data.'.$subField] = $messages !== []
|
||||
? $messages
|
||||
: ['Invalid value.'];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$validationErrors['data.'.$field] = $messages !== []
|
||||
? $messages
|
||||
: ['Invalid value.'];
|
||||
@ -834,6 +937,18 @@ private function formatValueForInput(string $field, mixed $value): mixed
|
||||
return is_string($encoded) ? $encoded : null;
|
||||
}
|
||||
|
||||
if ($setting['type'] === 'string') {
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
|
||||
if ($setting['type'] === 'bool') {
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (string) (int) $value : null;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
@ -851,6 +966,18 @@ private function formatValueForDisplay(string $field, mixed $value): string
|
||||
return is_string($encoded) ? $encoded : '{}';
|
||||
}
|
||||
|
||||
if ($setting['type'] === 'string') {
|
||||
return is_string($value) && $value !== '' ? $value : 'null';
|
||||
}
|
||||
|
||||
if ($setting['type'] === 'bool') {
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'enabled' : 'disabled';
|
||||
}
|
||||
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (string) (int) $value : 'null';
|
||||
}
|
||||
|
||||
@ -864,7 +991,7 @@ private function sourceLabel(string $source): string
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{domain: string, key: string, type: 'int'|'json'}
|
||||
* @return array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}
|
||||
*/
|
||||
private function settingForField(string $field): array
|
||||
{
|
||||
@ -894,6 +1021,23 @@ private function workspaceOverrideForField(string $field): mixed
|
||||
return $this->workspaceOverrides[$field] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose the baseline severity mapping JSON setting into explicit change-type sub-fields.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $workspaceOverrides
|
||||
*/
|
||||
private function decomposeBaselineSeveritySubFields(array &$data, array &$workspaceOverrides): void
|
||||
{
|
||||
$mappingOverride = $workspaceOverrides['baseline_severity_mapping'] ?? null;
|
||||
|
||||
foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) {
|
||||
$data[$subField] = is_array($mappingOverride) && isset($mappingOverride[$changeType])
|
||||
? (string) $mappingOverride[$changeType]
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose the findings_sla_days JSON setting into individual SLA sub-fields.
|
||||
*
|
||||
@ -913,6 +1057,26 @@ private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-compose baseline severity mapping sub-fields back into the baseline_severity_mapping data key before save.
|
||||
*/
|
||||
private function composeBaselineSeveritySubFieldsIntoData(): void
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach (self::BASELINE_MAPPING_SUB_FIELDS as $subField => $changeType) {
|
||||
$value = $this->data[$subField] ?? null;
|
||||
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$changeType] = strtolower(trim($value));
|
||||
}
|
||||
|
||||
$this->data['baseline_severity_mapping'] = $values !== [] ? $values : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-compose individual SLA sub-fields back into the findings_sla_days data key before save.
|
||||
*/
|
||||
@ -933,6 +1097,30 @@ private function composeSlaSubFieldsIntoData(): void
|
||||
$this->data['findings_sla_days'] = $hasAnyValue ? $values : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function severityOptions(): array
|
||||
{
|
||||
return [
|
||||
'low' => 'Low',
|
||||
'medium' => 'Medium',
|
||||
'high' => 'High',
|
||||
'critical' => 'Critical',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function booleanOptions(): array
|
||||
{
|
||||
return [
|
||||
'1' => 'Enabled',
|
||||
'0' => 'Disabled',
|
||||
];
|
||||
}
|
||||
|
||||
private function currentUserCanManage(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -378,6 +378,8 @@ public static function eventTypeOptions(): array
|
||||
return [
|
||||
AlertRule::EVENT_HIGH_DRIFT => 'High drift',
|
||||
AlertRule::EVENT_COMPARE_FAILED => 'Compare failed',
|
||||
AlertRule::EVENT_BASELINE_HIGH_DRIFT => 'Baseline drift',
|
||||
AlertRule::EVENT_BASELINE_COMPARE_FAILED => 'Baseline compare failed',
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||
|
||||
@ -42,6 +42,8 @@
|
||||
|
||||
class BaselineProfileResource extends Resource
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = BaselineProfile::class;
|
||||
|
||||
@ -4,16 +4,79 @@
|
||||
|
||||
namespace App\Filament\System\Pages;
|
||||
|
||||
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
|
||||
use App\Filament\System\Widgets\ControlTowerKpis;
|
||||
use App\Filament\System\Widgets\ControlTowerRecentFailures;
|
||||
use App\Filament\System\Widgets\ControlTowerTopOffenders;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Dashboard as BaseDashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Dashboard extends BaseDashboard
|
||||
{
|
||||
public string $window = SystemConsoleWindow::LastDay;
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'system', $tenant, $shouldGuessMissingParameters);
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::CONSOLE_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->window = SystemConsoleWindow::fromNullable((string) request()->query('window', $this->window))->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<Widget> | WidgetConfiguration>
|
||||
*/
|
||||
public function getWidgets(): array
|
||||
{
|
||||
return [
|
||||
ControlTowerHealthIndicator::class,
|
||||
ControlTowerKpis::class,
|
||||
ControlTowerTopOffenders::class,
|
||||
ControlTowerRecentFailures::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function getColumns(): int|array
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function selectedWindow(): SystemConsoleWindow
|
||||
{
|
||||
return SystemConsoleWindow::fromNullable($this->window);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
@ -27,6 +90,27 @@ protected function getHeaderActions(): array
|
||||
&& $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||
|
||||
return [
|
||||
Action::make('set_window')
|
||||
->label('Time window')
|
||||
->icon('heroicon-o-clock')
|
||||
->color('gray')
|
||||
->form([
|
||||
Select::make('window')
|
||||
->label('Window')
|
||||
->options(SystemConsoleWindow::options())
|
||||
->default($this->window)
|
||||
->required(),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$window = SystemConsoleWindow::fromNullable((string) ($data['window'] ?? null));
|
||||
|
||||
$this->window = $window->value;
|
||||
|
||||
$this->redirect(static::getUrl([
|
||||
'window' => $window->value,
|
||||
]));
|
||||
}),
|
||||
|
||||
Action::make('enter_break_glass')
|
||||
->label('Enter break-glass mode')
|
||||
->color('danger')
|
||||
|
||||
107
app/Filament/System/Pages/Directory/Tenants.php
Normal file
107
app/Filament/System/Pages/Directory/Tenants.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Tenants extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Tenants';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $slug = 'directory/tenants';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.tenants';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->query(function (): Builder {
|
||||
return Tenant::query()
|
||||
->with('workspace')
|
||||
->withCount([
|
||||
'providerConnections',
|
||||
'providerConnections as unhealthy_connections_count' => fn (Builder $query): Builder => $query->where('health_status', 'unhealthy'),
|
||||
'permissions as missing_permissions_count' => fn (Builder $query): Builder => $query->where('status', '!=', 'granted'),
|
||||
]);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
|
||||
TextColumn::make('health')
|
||||
->label('Health')
|
||||
->state(fn (Tenant $record): string => $this->healthForTenant($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::SystemHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::SystemHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::SystemHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::SystemHealth)),
|
||||
])
|
||||
->recordUrl(fn (Tenant $record): string => SystemDirectoryLinks::tenantDetail($record))
|
||||
->emptyStateHeading('No tenants found')
|
||||
->emptyStateDescription('Tenants will appear here as inventory is onboarded.');
|
||||
}
|
||||
|
||||
private function healthForTenant(Tenant $tenant): string
|
||||
{
|
||||
if ((string) $tenant->status === Tenant::STATUS_ARCHIVED) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if ((int) ($tenant->getAttribute('unhealthy_connections_count') ?? 0) > 0) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ((int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
if ((string) $tenant->status === Tenant::STATUS_ONBOARDING) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
95
app/Filament/System/Pages/Directory/ViewTenant.php
Normal file
95
app/Filament/System/Pages/Directory/ViewTenant.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewTenant extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'directory/tenants/{tenant}';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.view-tenant';
|
||||
|
||||
public Tenant $tenant;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(Tenant $tenant): void
|
||||
{
|
||||
$tenant->load('workspace');
|
||||
|
||||
$this->tenant = $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ProviderConnection>
|
||||
*/
|
||||
public function providerConnections(): Collection
|
||||
{
|
||||
return ProviderConnection::query()
|
||||
->where('tenant_id', (int) $this->tenant->getKey())
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('provider')
|
||||
->get(['id', 'provider', 'status', 'health_status', 'is_default', 'last_health_check_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TenantPermission>
|
||||
*/
|
||||
public function tenantPermissions(): Collection
|
||||
{
|
||||
return TenantPermission::query()
|
||||
->where('tenant_id', (int) $this->tenant->getKey())
|
||||
->orderBy('permission_key')
|
||||
->limit(20)
|
||||
->get(['id', 'permission_key', 'status', 'last_checked_at']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{id: int, label: string, started: string, url: string}>
|
||||
*/
|
||||
public function recentRuns(): Collection
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $this->tenant->getKey())
|
||||
->latest('id')
|
||||
->limit(8)
|
||||
->get(['id', 'type', 'created_at'])
|
||||
->map(fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'label' => OperationCatalog::label((string) $run->type),
|
||||
'started' => $run->created_at?->diffForHumans() ?? '—',
|
||||
'url' => SystemOperationRunLinks::view($run),
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminTenantUrl(): string
|
||||
{
|
||||
return SystemDirectoryLinks::adminTenant($this->tenant);
|
||||
}
|
||||
|
||||
public function runsUrl(): string
|
||||
{
|
||||
return SystemOperationRunLinks::index();
|
||||
}
|
||||
}
|
||||
82
app/Filament/System/Pages/Directory/ViewWorkspace.php
Normal file
82
app/Filament/System/Pages/Directory/ViewWorkspace.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ViewWorkspace extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'directory/workspaces/{workspace}';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.view-workspace';
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(Workspace $workspace): void
|
||||
{
|
||||
$workspace->loadCount('tenants');
|
||||
|
||||
$this->workspace = $workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Tenant>
|
||||
*/
|
||||
public function workspaceTenants(): Collection
|
||||
{
|
||||
return Tenant::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->orderBy('name')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'status', 'workspace_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{id: int, label: string, started: string, url: string}>
|
||||
*/
|
||||
public function recentRuns(): Collection
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->latest('id')
|
||||
->limit(8)
|
||||
->get(['id', 'type', 'created_at'])
|
||||
->map(fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'label' => OperationCatalog::label((string) $run->type),
|
||||
'started' => $run->created_at?->diffForHumans() ?? '—',
|
||||
'url' => SystemOperationRunLinks::view($run),
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminWorkspaceUrl(): string
|
||||
{
|
||||
return SystemDirectoryLinks::adminWorkspace($this->workspace);
|
||||
}
|
||||
|
||||
public function runsUrl(): string
|
||||
{
|
||||
return SystemOperationRunLinks::index();
|
||||
}
|
||||
}
|
||||
116
app/Filament/System/Pages/Directory/Workspaces.php
Normal file
116
app/Filament/System/Pages/Directory/Workspaces.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Directory;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Workspaces extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Workspaces';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $slug = 'directory/workspaces';
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.workspaces';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->query(function (): Builder {
|
||||
return Workspace::query()
|
||||
->withCount([
|
||||
'tenants',
|
||||
'tenants as onboarding_tenants_count' => fn (Builder $query): Builder => $query->where('status', Tenant::STATUS_ONBOARDING),
|
||||
]);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Workspace')
|
||||
->searchable(),
|
||||
TextColumn::make('tenants_count')
|
||||
->label('Tenants'),
|
||||
TextColumn::make('health')
|
||||
->label('Health')
|
||||
->state(fn (Workspace $record): string => $this->healthForWorkspace($record))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::SystemHealth))
|
||||
->color(BadgeRenderer::color(BadgeDomain::SystemHealth))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::SystemHealth))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::SystemHealth)),
|
||||
TextColumn::make('failed_runs_24h')
|
||||
->label('Failed (24h)')
|
||||
->state(fn (Workspace $record): int => (int) OperationRun::query()
|
||||
->where('workspace_id', (int) $record->getKey())
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count()),
|
||||
])
|
||||
->recordUrl(fn (Workspace $record): string => SystemDirectoryLinks::workspaceDetail($record))
|
||||
->emptyStateHeading('No workspaces found')
|
||||
->emptyStateDescription('Workspace inventory will appear here once workspaces are created.');
|
||||
}
|
||||
|
||||
private function healthForWorkspace(Workspace $workspace): string
|
||||
{
|
||||
$tenantsCount = (int) ($workspace->getAttribute('tenants_count') ?? 0);
|
||||
$onboardingTenantsCount = (int) ($workspace->getAttribute('onboarding_tenants_count') ?? 0);
|
||||
|
||||
if ($tenantsCount === 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$hasRecentFailures = OperationRun::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('created_at', '>=', now()->subDay())
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->exists();
|
||||
|
||||
if ($hasRecentFailures) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
if ($onboardingTenantsCount > 0) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
190
app/Filament/System/Pages/Ops/Failures.php
Normal file
190
app/Filament/System/Pages/Ops/Failures.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Failures extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Failures';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||
|
||||
protected static ?string $slug = 'ops/failures';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.failures';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$count = OperationRun::query()
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count();
|
||||
|
||||
return $count > 0 ? (string) $count : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): string|array|null
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->query(function (): Builder {
|
||||
return OperationRun::query()
|
||||
->with(['tenant', 'workspace'])
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Run')
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||
->actions([
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($record, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($record, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No failed runs found')
|
||||
->emptyStateDescription('Failed operations will appear here for triage.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@ -6,13 +6,16 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
@ -42,8 +45,8 @@ public static function canAccess(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
||||
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
@ -56,49 +59,114 @@ public function table(Table $table): Table
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->query(function (): Builder {
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
$workspaceId = $platformTenant instanceof Tenant ? (int) $platformTenant->workspace_id : null;
|
||||
|
||||
return OperationRun::query()
|
||||
->with('tenant')
|
||||
->when($workspaceId, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId))
|
||||
->when(! $workspaceId, fn (Builder $query): Builder => $query->whereRaw('1 = 0'))
|
||||
->where('type', FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
|
||||
->with(['tenant', 'workspace']);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Run')
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('scope')
|
||||
->label('Scope')
|
||||
->getStateUsing(function (OperationRun $record): string {
|
||||
$scope = (string) data_get($record->context, 'runbook.scope', 'unknown');
|
||||
$tenantName = $record->tenant instanceof Tenant ? $record->tenant->name : null;
|
||||
|
||||
if ($scope === 'single_tenant' && $tenantName) {
|
||||
return "Single tenant ({$tenantName})";
|
||||
}
|
||||
|
||||
return $scope === 'all_tenants' ? 'All tenants' : $scope;
|
||||
}),
|
||||
TextColumn::make('initiator_name')->label('Initiator'),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
||||
->toggleable(),
|
||||
TextColumn::make('initiator_name')->label('Initiator'),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($record, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(fn (OperationRun $record): string => SystemOperationRunLinks::view($record)),
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($record, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No operation runs yet')
|
||||
->emptyStateDescription('Runs from all workspaces will appear here when operations are queued.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
190
app/Filament/System/Pages/Ops/Stuck.php
Normal file
190
app/Filament/System/Pages/Ops/Stuck.php
Normal file
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Ops;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Stuck extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Stuck';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
|
||||
|
||||
protected static ?string $slug = 'ops/stuck';
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.stuck';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$count = app(StuckRunClassifier::class)
|
||||
->apply(OperationRun::query())
|
||||
->count();
|
||||
|
||||
return $count > 0 ? (string) $count : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): string|array|null
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->query(function (): Builder {
|
||||
return app(StuckRunClassifier::class)->apply(
|
||||
OperationRun::query()
|
||||
->with(['tenant', 'workspace'])
|
||||
);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('Run')
|
||||
->state(fn (OperationRun $record): string => '#'.$record->getKey()),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextColumn::make('stuck_class')
|
||||
->label('Stuck class')
|
||||
->state(function (OperationRun $record): string {
|
||||
$classification = app(StuckRunClassifier::class)->classify($record);
|
||||
|
||||
return $classification === OperationRunStatus::Queued->value ? 'Queued too long' : 'Running too long';
|
||||
}),
|
||||
TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->searchable(),
|
||||
TextColumn::make('workspace.name')
|
||||
->label('Workspace')
|
||||
->toggleable(),
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Tenantless')
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')->label('Started')->since(),
|
||||
])
|
||||
->recordUrl(fn (OperationRun $record): string => SystemOperationRunLinks::view($record))
|
||||
->actions([
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($record, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (OperationRun $record): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($record))
|
||||
->action(function (OperationRun $record, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($record, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (OperationRun $record, array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($record, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->emptyStateHeading('No stuck runs found')
|
||||
->emptyStateDescription('Queued and running runs outside thresholds will appear here.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,13 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\SystemConsole\OperationRunTriageService;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class ViewRun extends Page
|
||||
@ -29,26 +33,96 @@ public static function canAccess(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
|
||||
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
|
||||
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|
||||
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
$workspaceId = $platformTenant instanceof Tenant ? (int) $platformTenant->workspace_id : null;
|
||||
|
||||
$run->load('tenant');
|
||||
|
||||
if ($workspaceId === null || (int) $run->workspace_id !== $workspaceId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ((string) $run->type !== FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY) {
|
||||
abort(404);
|
||||
}
|
||||
$run->load(['tenant', 'workspace']);
|
||||
|
||||
$this->run = $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('go_to_runbooks')
|
||||
->label('Go to runbooks')
|
||||
->url(Runbooks::getUrl(panel: 'system')),
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canRetry($this->run))
|
||||
->action(function (OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$retryRun = $triageService->retry($this->run, $user);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $retryRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(SystemOperationRunLinks::view($retryRun)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations() && app(OperationRunTriageService::class)->canCancel($this->run))
|
||||
->action(function (OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->cancel($this->run, $user);
|
||||
|
||||
Notification::make()
|
||||
->title('Run cancelled')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('mark_investigated')
|
||||
->label('Mark investigated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->canManageOperations())
|
||||
->form([
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->minLength(5)
|
||||
->maxLength(500)
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (array $data, OperationRunTriageService $triageService): void {
|
||||
$user = $this->requireManageUser();
|
||||
$triageService->markInvestigated($this->run, $user, (string) ($data['reason'] ?? ''));
|
||||
|
||||
Notification::make()
|
||||
->title('Run marked as investigated')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private function canManageOperations(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE);
|
||||
}
|
||||
|
||||
private function requireManageUser(): PlatformUser
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
if (! $user instanceof PlatformUser || ! $user->hasCapability(PlatformCapabilities::OPERATIONS_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
namespace App\Filament\System\Pages;
|
||||
|
||||
use App\Filament\System\Widgets\RepairWorkspaceOwnersStats;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -18,9 +20,18 @@
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RepairWorkspaceOwners extends Page
|
||||
class RepairWorkspaceOwners extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
||||
|
||||
protected static ?string $navigationLabel = 'Repair workspace owners';
|
||||
@ -40,6 +51,102 @@ public static function canAccess(): bool
|
||||
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$total = Workspace::query()->count();
|
||||
$withOwners = WorkspaceMembership::query()
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->distinct('workspace_id')
|
||||
->count('workspace_id');
|
||||
|
||||
$ownerless = $total - $withOwners;
|
||||
|
||||
return $ownerless > 0 ? (string) $ownerless : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): string|array|null
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string<Widget>|WidgetConfiguration>
|
||||
*/
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
RepairWorkspaceOwnersStats::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->heading('Workspaces')
|
||||
->description('Current workspace ownership status.')
|
||||
->defaultSort('name', 'asc')
|
||||
->query(function (): Builder {
|
||||
return Workspace::query()
|
||||
->withCount([
|
||||
'memberships as owner_count' => function (Builder $query): void {
|
||||
$query->where('role', WorkspaceRole::Owner->value);
|
||||
},
|
||||
'memberships as member_count',
|
||||
'tenants as tenant_count',
|
||||
]);
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Workspace')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('owner_count')
|
||||
->label('Owners')
|
||||
->badge()
|
||||
->color(fn (int $state): string => $state > 0 ? 'success' : 'danger')
|
||||
->sortable(),
|
||||
TextColumn::make('member_count')
|
||||
->label('Members')
|
||||
->sortable(),
|
||||
TextColumn::make('tenant_count')
|
||||
->label('Tenants')
|
||||
->sortable(),
|
||||
TextColumn::make('updated_at')
|
||||
->label('Last activity')
|
||||
->since()
|
||||
->sortable(),
|
||||
])
|
||||
->emptyStateHeading('No workspaces')
|
||||
->emptyStateDescription('No workspaces exist in the system yet.')
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
|
||||
*/
|
||||
public function getRecentBreakGlassActions(): array
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('action', 'like', '%break_glass%')
|
||||
->orderByDesc('recorded_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (AuditLog $log): array => [
|
||||
'action' => (string) $log->action,
|
||||
'actor' => $log->actor_email ?: 'Unknown',
|
||||
'workspace' => $log->metadata['metadata']['workspace_id'] ?? null
|
||||
? Workspace::query()->whereKey((int) $log->metadata['metadata']['workspace_id'])->value('name')
|
||||
: null,
|
||||
'recorded_at' => $log->recorded_at?->diffForHumans() ?? 'Unknown',
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
@ -49,7 +156,8 @@ protected function getHeaderActions(): array
|
||||
|
||||
return [
|
||||
Action::make('assign_owner')
|
||||
->label('Assign owner (break-glass)')
|
||||
->label('Emergency: Assign Owner')
|
||||
->icon('heroicon-o-shield-exclamation')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Assign workspace owner')
|
||||
@ -163,7 +271,8 @@ protected function getHeaderActions(): array
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->disabled(fn (): bool => ! $breakGlass->isActive()),
|
||||
->disabled(fn (): bool => ! $breakGlass->isActive())
|
||||
->tooltip(fn (): ?string => ! $breakGlass->isActive() ? 'Activate break-glass mode on the Dashboard first.' : null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Filament/System/Pages/Security/AccessLogs.php
Normal file
73
app/Filament/System/Pages/Security/AccessLogs.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Pages\Security;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AccessLogs extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationLabel = 'Access logs';
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Security';
|
||||
|
||||
protected static ?string $slug = 'security/access-logs';
|
||||
|
||||
protected string $view = 'filament.system.pages.security.access-logs';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
return $user instanceof PlatformUser
|
||||
&& $user->hasCapability(PlatformCapabilities::CONSOLE_VIEW);
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('recorded_at', 'desc')
|
||||
->query(function (): Builder {
|
||||
return AuditLog::query()
|
||||
->where(function (Builder $query): void {
|
||||
$query
|
||||
->where('action', 'platform.auth.login')
|
||||
->orWhere('action', 'like', 'platform.break_glass.%');
|
||||
});
|
||||
})
|
||||
->columns([
|
||||
TextColumn::make('recorded_at')
|
||||
->label('Recorded')
|
||||
->since(),
|
||||
TextColumn::make('action')
|
||||
->label('Action')
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (?string $state): string => $state === 'failure' ? 'danger' : 'success'),
|
||||
TextColumn::make('actor_email')
|
||||
->label('Actor')
|
||||
->formatStateUsing(fn (?string $state): string => $state ?: 'Unknown'),
|
||||
])
|
||||
->emptyStateHeading('No access logs found')
|
||||
->emptyStateDescription('Platform login and break-glass events will appear here.');
|
||||
}
|
||||
}
|
||||
73
app/Filament/System/Widgets/ControlTowerHealthIndicator.php
Normal file
73
app/Filament/System/Widgets/ControlTowerHealthIndicator.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class ControlTowerHealthIndicator extends Widget
|
||||
{
|
||||
protected string $view = 'filament.system.widgets.control-tower-health-indicator';
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array{level: string, color: string, icon: string, label: string, failed: int, stuck: int}
|
||||
*/
|
||||
public function getHealthData(): array
|
||||
{
|
||||
$now = CarbonImmutable::now();
|
||||
$last24h = $now->subHours(24);
|
||||
|
||||
$failedRuns = OperationRun::query()
|
||||
->where('created_at', '>=', $last24h)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count();
|
||||
|
||||
$stuckRuns = app(StuckRunClassifier::class)
|
||||
->apply(OperationRun::query())
|
||||
->count();
|
||||
|
||||
if ($failedRuns > 0 || $stuckRuns > 0) {
|
||||
$level = ($failedRuns >= 5 || $stuckRuns >= 3) ? 'critical' : 'warning';
|
||||
} else {
|
||||
$level = 'healthy';
|
||||
}
|
||||
|
||||
return match ($level) {
|
||||
'critical' => [
|
||||
'level' => 'critical',
|
||||
'color' => 'danger',
|
||||
'icon' => 'heroicon-o-x-circle',
|
||||
'label' => 'Critical',
|
||||
'failed' => $failedRuns,
|
||||
'stuck' => $stuckRuns,
|
||||
],
|
||||
'warning' => [
|
||||
'level' => 'warning',
|
||||
'color' => 'warning',
|
||||
'icon' => 'heroicon-o-exclamation-triangle',
|
||||
'label' => 'Attention needed',
|
||||
'failed' => $failedRuns,
|
||||
'stuck' => $stuckRuns,
|
||||
],
|
||||
default => [
|
||||
'level' => 'healthy',
|
||||
'color' => 'success',
|
||||
'icon' => 'heroicon-o-check-circle',
|
||||
'label' => 'All systems healthy',
|
||||
'failed' => 0,
|
||||
'stuck' => 0,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
65
app/Filament/System/Widgets/ControlTowerKpis.php
Normal file
65
app/Filament/System/Widgets/ControlTowerKpis.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class ControlTowerKpis extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
$baseQuery = OperationRun::query()->where('created_at', '>=', $start);
|
||||
|
||||
$totalRuns = (clone $baseQuery)->count();
|
||||
|
||||
$activeRuns = (clone $baseQuery)
|
||||
->whereIn('status', [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
])
|
||||
->count();
|
||||
|
||||
$failedRuns = (clone $baseQuery)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->count();
|
||||
|
||||
$stuckRuns = app(StuckRunClassifier::class)
|
||||
->apply((clone $baseQuery))
|
||||
->count();
|
||||
|
||||
return [
|
||||
Stat::make('Runs in window', $totalRuns)
|
||||
->description($window::options()[$window->value] ?? 'Last 24 hours')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
Stat::make('Active', $activeRuns)
|
||||
->color($activeRuns > 0 ? 'warning' : 'gray')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
Stat::make('Failed', $failedRuns)
|
||||
->color($failedRuns > 0 ? 'danger' : 'gray')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
Stat::make('Stuck', $stuckRuns)
|
||||
->color($stuckRuns > 0 ? 'danger' : 'gray')
|
||||
->url(SystemOperationRunLinks::index()),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Filament/System/Widgets/ControlTowerRecentFailures.php
Normal file
61
app/Filament/System/Widgets/ControlTowerRecentFailures.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ControlTowerRecentFailures extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.system.widgets.control-tower-recent-failures';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
/** @var Collection<int, OperationRun> $runs */
|
||||
$runs = OperationRun::query()
|
||||
->with('tenant')
|
||||
->where('created_at', '>=', $start)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->latest('id')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'windowLabel' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
||||
'runs' => $runs->map(function (OperationRun $run): array {
|
||||
$failureSummary = is_array($run->failure_summary) ? $run->failure_summary : [];
|
||||
$primaryFailure = is_array($failureSummary[0] ?? null) ? $failureSummary[0] : [];
|
||||
$failureMessage = trim((string) ($primaryFailure['message'] ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) $run->getKey(),
|
||||
'operation' => OperationCatalog::label((string) $run->type),
|
||||
'tenant' => $run->tenant?->name ?? 'Tenantless',
|
||||
'created_at' => $run->created_at?->diffForHumans() ?? '—',
|
||||
'failure_message' => $failureMessage !== '' ? $failureMessage : 'No failure details available',
|
||||
'url' => SystemOperationRunLinks::view($run),
|
||||
];
|
||||
}),
|
||||
'runsUrl' => SystemOperationRunLinks::index(),
|
||||
];
|
||||
}
|
||||
}
|
||||
91
app/Filament/System/Widgets/ControlTowerTopOffenders.php
Normal file
91
app/Filament/System/Widgets/ControlTowerTopOffenders.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ControlTowerTopOffenders extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected string $view = 'filament.system.widgets.control-tower-top-offenders';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
|
||||
$start = $window->startAt();
|
||||
|
||||
/** @var Collection<int, OperationRun> $grouped */
|
||||
$grouped = OperationRun::query()
|
||||
->selectRaw('workspace_id, tenant_id, type, COUNT(*) AS failed_count')
|
||||
->where('created_at', '>=', $start)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Failed->value)
|
||||
->groupBy('workspace_id', 'tenant_id', 'type')
|
||||
->orderByDesc('failed_count')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$workspaceIds = $grouped
|
||||
->pluck('workspace_id')
|
||||
->filter(fn ($value): bool => is_numeric($value))
|
||||
->map(fn ($value): int => (int) $value)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$tenantIds = $grouped
|
||||
->pluck('tenant_id')
|
||||
->filter(fn ($value): bool => is_numeric($value))
|
||||
->map(fn ($value): int => (int) $value)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$workspaceNames = Workspace::query()
|
||||
->whereIn('id', $workspaceIds)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
|
||||
$tenantNames = Tenant::query()
|
||||
->whereIn('id', $tenantIds)
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
|
||||
return [
|
||||
'windowLabel' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
|
||||
'offenders' => $grouped->map(function (OperationRun $record) use ($workspaceNames, $tenantNames): array {
|
||||
$workspaceId = is_numeric($record->workspace_id) ? (int) $record->workspace_id : null;
|
||||
$tenantId = is_numeric($record->tenant_id) ? (int) $record->tenant_id : null;
|
||||
|
||||
return [
|
||||
'workspace_label' => $workspaceId !== null
|
||||
? ($workspaceNames[$workspaceId] ?? ('Workspace #'.$workspaceId))
|
||||
: 'Unknown workspace',
|
||||
'tenant_label' => $tenantId !== null
|
||||
? ($tenantNames[$tenantId] ?? ('Tenant #'.$tenantId))
|
||||
: 'Tenantless',
|
||||
'operation_label' => OperationCatalog::label((string) $record->type),
|
||||
'failed_count' => (int) $record->getAttribute('failed_count'),
|
||||
];
|
||||
}),
|
||||
'runsUrl' => SystemOperationRunLinks::index(),
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Filament/System/Widgets/RepairWorkspaceOwnersStats.php
Normal file
50
app/Filament/System/Widgets/RepairWorkspaceOwnersStats.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\System\Widgets;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class RepairWorkspaceOwnersStats extends StatsOverviewWidget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
/**
|
||||
* @return array<Stat>
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$totalWorkspaces = Workspace::query()->count();
|
||||
|
||||
$workspacesWithOwners = WorkspaceMembership::query()
|
||||
->where('role', WorkspaceRole::Owner->value)
|
||||
->distinct('workspace_id')
|
||||
->count('workspace_id');
|
||||
|
||||
$ownerlessWorkspaces = $totalWorkspaces - $workspacesWithOwners;
|
||||
|
||||
$totalMembers = WorkspaceMembership::query()->count();
|
||||
|
||||
return [
|
||||
Stat::make('Total workspaces', $totalWorkspaces)
|
||||
->color('gray')
|
||||
->icon('heroicon-o-rectangle-stack'),
|
||||
Stat::make('Healthy (has owner)', $workspacesWithOwners)
|
||||
->color($workspacesWithOwners > 0 ? 'success' : 'gray')
|
||||
->icon('heroicon-o-check-circle'),
|
||||
Stat::make('Ownerless', $ownerlessWorkspaces)
|
||||
->color($ownerlessWorkspaces > 0 ? 'danger' : 'success')
|
||||
->icon($ownerlessWorkspaces > 0 ? 'heroicon-o-exclamation-triangle' : 'heroicon-o-check-circle'),
|
||||
Stat::make('Total memberships', $totalMembers)
|
||||
->color('gray')
|
||||
->icon('heroicon-o-users'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,10 +4,8 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
@ -39,52 +37,20 @@ protected function getViewData(): array
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$assignment = BaselineTenantAssignment::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->with('baselineProfile')
|
||||
->first();
|
||||
$stats = BaselineCompareStats::forWidget($tenant);
|
||||
|
||||
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
|
||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$profile = $assignment->baselineProfile;
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$findingsQuery = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('status', Finding::STATUS_NEW);
|
||||
|
||||
$findingsCount = (int) (clone $findingsQuery)->count();
|
||||
$highCount = (int) (clone $findingsQuery)
|
||||
->where('severity', Finding::SEVERITY_HIGH)
|
||||
->count();
|
||||
$mediumCount = (int) (clone $findingsQuery)
|
||||
->where('severity', Finding::SEVERITY_MEDIUM)
|
||||
->count();
|
||||
$lowCount = (int) (clone $findingsQuery)
|
||||
->where('severity', Finding::SEVERITY_LOW)
|
||||
->count();
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'baseline_compare')
|
||||
->where('context->baseline_profile_id', (string) $profile->getKey())
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'hasAssignment' => true,
|
||||
'profileName' => (string) $profile->name,
|
||||
'findingsCount' => $findingsCount,
|
||||
'highCount' => $highCount,
|
||||
'mediumCount' => $mediumCount,
|
||||
'lowCount' => $lowCount,
|
||||
'lastComparedAt' => $latestRun?->finished_at?->diffForHumans(),
|
||||
'profileName' => $stats->profileName,
|
||||
'findingsCount' => $stats->findingsCount ?? 0,
|
||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
||||
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
||||
'lastComparedAt' => $stats->lastComparedHuman,
|
||||
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||
];
|
||||
}
|
||||
|
||||
@ -10,14 +10,17 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
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 Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
class EvaluateAlertsJob implements ShouldQueue
|
||||
@ -58,8 +61,10 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
||||
try {
|
||||
$events = [
|
||||
...$this->highDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->baselineHighDriftEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->slaDueEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->compareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->baselineCompareFailedEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||
];
|
||||
@ -206,6 +211,84 @@ private function highDriftEvents(int $workspaceId, CarbonImmutable $windowStart)
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function baselineHighDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$minimumSeverity = $this->baselineAlertMinimumSeverity($workspaceId);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('source', 'baseline.compare')
|
||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
|
||||
->where(function ($query) use ($windowStart): void {
|
||||
$query
|
||||
->where(function ($statusQuery) use ($windowStart): void {
|
||||
$statusQuery
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->where('created_at', '>', $windowStart);
|
||||
})
|
||||
->orWhere(function ($statusQuery) use ($windowStart): void {
|
||||
$statusQuery
|
||||
->where('status', Finding::STATUS_REOPENED)
|
||||
->where(function ($reopenedQuery) use ($windowStart): void {
|
||||
$reopenedQuery
|
||||
->where('reopened_at', '>', $windowStart)
|
||||
->orWhere(function ($fallbackQuery) use ($windowStart): void {
|
||||
$fallbackQuery
|
||||
->whereNull('reopened_at')
|
||||
->where('updated_at', '>', $windowStart);
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
$severity = strtolower(trim((string) $finding->severity));
|
||||
|
||||
if (! $this->meetsMinimumSeverity($severity, $minimumSeverity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fingerprint = trim((string) $finding->fingerprint);
|
||||
$changeType = strtolower(trim((string) Arr::get($finding->evidence_jsonb, 'change_type', '')));
|
||||
|
||||
if ($fingerprint === '' || ! in_array($changeType, [
|
||||
'missing_policy',
|
||||
'different_version',
|
||||
'unexpected_policy',
|
||||
], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = [
|
||||
'event_type' => AlertRule::EVENT_BASELINE_HIGH_DRIFT,
|
||||
'tenant_id' => (int) $finding->tenant_id,
|
||||
'severity' => $severity,
|
||||
'fingerprint_key' => 'finding_fingerprint:'.$fingerprint,
|
||||
'title' => 'Baseline drift detected',
|
||||
'body' => sprintf(
|
||||
'Baseline finding %d requires attention (%s).',
|
||||
(int) $finding->getKey(),
|
||||
str_replace('_', ' ', $changeType),
|
||||
),
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'finding_fingerprint' => $fingerprint,
|
||||
'change_type' => $changeType,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@ -236,7 +319,51 @@ private function compareFailedEvents(int $workspaceId, CarbonImmutable $windowSt
|
||||
'severity' => 'high',
|
||||
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
|
||||
'title' => 'Drift compare failed',
|
||||
'body' => $this->firstFailureMessage($failedRun),
|
||||
'body' => $this->firstFailureMessage($failedRun, 'A drift compare operation run failed.'),
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $failedRun->getKey(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function baselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$failedRuns = OperationRun::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereNotNull('tenant_id')
|
||||
->where('type', OperationRunType::BaselineCompare->value)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Failed->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->whereNotNull('completed_at')
|
||||
->where('completed_at', '>', $windowStart)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$events = [];
|
||||
|
||||
foreach ($failedRuns as $failedRun) {
|
||||
$tenantId = (int) ($failedRun->tenant_id ?? 0);
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$events[] = [
|
||||
'event_type' => AlertRule::EVENT_BASELINE_COMPARE_FAILED,
|
||||
'tenant_id' => $tenantId,
|
||||
'severity' => 'high',
|
||||
'fingerprint_key' => 'operation_run:'.(int) $failedRun->getKey(),
|
||||
'title' => 'Baseline compare failed',
|
||||
'body' => $this->firstFailureMessage($failedRun, 'A baseline compare operation run failed.'),
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $failedRun->getKey(),
|
||||
],
|
||||
@ -370,7 +497,7 @@ private function slaDueEvents(int $workspaceId, CarbonImmutable $windowStart): a
|
||||
return $events;
|
||||
}
|
||||
|
||||
private function firstFailureMessage(OperationRun $run): string
|
||||
private function firstFailureMessage(OperationRun $run, string $defaultMessage): string
|
||||
{
|
||||
$failures = is_array($run->failure_summary) ? $run->failure_summary : [];
|
||||
|
||||
@ -386,7 +513,52 @@ private function firstFailureMessage(OperationRun $run): string
|
||||
}
|
||||
}
|
||||
|
||||
return 'A drift compare operation run failed.';
|
||||
return $defaultMessage;
|
||||
}
|
||||
|
||||
private function baselineAlertMinimumSeverity(int $workspaceId): string
|
||||
{
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Finding::SEVERITY_HIGH;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = app(SettingsResolver::class)->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: 'baseline',
|
||||
key: 'alert_min_severity',
|
||||
);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return Finding::SEVERITY_HIGH;
|
||||
}
|
||||
|
||||
$severity = strtolower(trim((string) $value));
|
||||
|
||||
return array_key_exists($severity, $this->severityRank())
|
||||
? $severity
|
||||
: Finding::SEVERITY_HIGH;
|
||||
}
|
||||
|
||||
private function meetsMinimumSeverity(string $eventSeverity, string $minimumSeverity): bool
|
||||
{
|
||||
$rank = $this->severityRank();
|
||||
|
||||
return ($rank[$eventSeverity] ?? 0) >= ($rank[$minimumSeverity] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function severityRank(): array
|
||||
{
|
||||
return [
|
||||
Finding::SEVERITY_LOW => 1,
|
||||
Finding::SEVERITY_MEDIUM => 2,
|
||||
Finding::SEVERITY_HIGH => 3,
|
||||
Finding::SEVERITY_CRITICAL => 4,
|
||||
];
|
||||
}
|
||||
|
||||
private function sanitizeErrorMessage(Throwable $exception): string
|
||||
|
||||
@ -12,13 +12,18 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -51,7 +56,12 @@ public function handle(
|
||||
BaselineSnapshotIdentity $snapshotIdentity,
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
?SettingsResolver $settingsResolver = null,
|
||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||
): void {
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
||||
|
||||
@ -74,6 +84,12 @@ public function handle(
|
||||
throw new RuntimeException("Tenant #{$this->operationRun->tenant_id} not found.");
|
||||
}
|
||||
|
||||
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new RuntimeException("Workspace #{$tenant->workspace_id} not found.");
|
||||
}
|
||||
|
||||
$initiator = $this->operationRun->user_id
|
||||
? User::query()->find($this->operationRun->user_id)
|
||||
: null;
|
||||
@ -83,12 +99,17 @@ public function handle(
|
||||
|
||||
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
|
||||
|
||||
$baselineItems = $this->loadBaselineItems($snapshotId);
|
||||
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity);
|
||||
$baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope);
|
||||
$latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant);
|
||||
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId);
|
||||
|
||||
$driftResults = $this->computeDrift($baselineItems, $currentItems);
|
||||
$driftResults = $this->computeDrift(
|
||||
$baselineItems,
|
||||
$currentItems,
|
||||
$this->resolveSeverityMapping($workspace, $settingsResolver),
|
||||
);
|
||||
|
||||
$upsertedCount = $this->upsertFindings(
|
||||
$upsertResult = $this->upsertFindings(
|
||||
$driftHasher,
|
||||
$tenant,
|
||||
$profile,
|
||||
@ -101,11 +122,14 @@ public function handle(
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
'succeeded' => $upsertedCount,
|
||||
'failed' => count($driftResults) - $upsertedCount,
|
||||
'succeeded' => (int) $upsertResult['processed_count'],
|
||||
'failed' => 0,
|
||||
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
|
||||
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
|
||||
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
|
||||
'findings_created' => (int) $upsertResult['created_count'],
|
||||
'findings_reopened' => (int) $upsertResult['reopened_count'],
|
||||
'findings_unchanged' => (int) $upsertResult['unchanged_count'],
|
||||
];
|
||||
|
||||
$operationRunService->updateRun(
|
||||
@ -115,10 +139,31 @@ public function handle(
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
|
||||
$resolvedCount = 0;
|
||||
|
||||
if ($baselineAutoCloseService->shouldAutoClose($tenant, $this->operationRun)) {
|
||||
$resolvedCount = $baselineAutoCloseService->resolveStaleFindings(
|
||||
tenant: $tenant,
|
||||
baselineProfileId: (int) $profile->getKey(),
|
||||
seenFingerprints: $upsertResult['seen_fingerprints'],
|
||||
currentOperationRunId: (int) $this->operationRun->getKey(),
|
||||
);
|
||||
|
||||
$summaryCounts['findings_resolved'] = $resolvedCount;
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: $summaryCounts,
|
||||
);
|
||||
}
|
||||
|
||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$updatedContext['result'] = [
|
||||
'findings_total' => count($driftResults),
|
||||
'findings_upserted' => $upsertedCount,
|
||||
'findings_upserted' => (int) $upsertResult['processed_count'],
|
||||
'findings_resolved' => $resolvedCount,
|
||||
'severity_breakdown' => $severityBreakdown,
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
@ -131,12 +176,18 @@ public function handle(
|
||||
*
|
||||
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
|
||||
*/
|
||||
private function loadBaselineItems(int $snapshotId): array
|
||||
private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', $snapshotId)
|
||||
$query = BaselineSnapshotItem::query()
|
||||
->where('baseline_snapshot_id', $snapshotId);
|
||||
|
||||
if (! $scope->isEmpty()) {
|
||||
$query->whereIn('policy_type', $scope->policyTypes);
|
||||
}
|
||||
|
||||
$query
|
||||
->orderBy('id')
|
||||
->chunk(500, function ($snapshotItems) use (&$items): void {
|
||||
foreach ($snapshotItems as $item) {
|
||||
@ -163,10 +214,15 @@ private function loadCurrentInventory(
|
||||
Tenant $tenant,
|
||||
BaselineScope $scope,
|
||||
BaselineSnapshotIdentity $snapshotIdentity,
|
||||
?int $latestInventorySyncRunId = null,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $tenant->getKey());
|
||||
|
||||
if (is_int($latestInventorySyncRunId) && $latestInventorySyncRunId > 0) {
|
||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||
}
|
||||
|
||||
if (! $scope->isEmpty()) {
|
||||
$query->whereIn('policy_type', $scope->policyTypes);
|
||||
}
|
||||
@ -197,14 +253,30 @@ private function loadCurrentInventory(
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
|
||||
{
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', OperationRunType::InventorySync->value)
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->first(['id']);
|
||||
|
||||
$runId = $run?->getKey();
|
||||
|
||||
return is_numeric($runId) ? (int) $runId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare baseline items vs current inventory and produce drift results.
|
||||
*
|
||||
* @param array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
||||
* @param array<string, array{subject_external_id: string, policy_type: string, current_hash: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, string> $severityMapping
|
||||
* @return array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>
|
||||
*/
|
||||
private function computeDrift(array $baselineItems, array $currentItems): array
|
||||
private function computeDrift(array $baselineItems, array $currentItems, array $severityMapping): array
|
||||
{
|
||||
$drift = [];
|
||||
|
||||
@ -212,7 +284,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
||||
if (! array_key_exists($key, $currentItems)) {
|
||||
$drift[] = [
|
||||
'change_type' => 'missing_policy',
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
@ -233,7 +305,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
||||
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
|
||||
$drift[] = [
|
||||
'change_type' => 'different_version',
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'different_version'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'policy_type' => $baselineItem['policy_type'],
|
||||
@ -254,7 +326,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
||||
if (! array_key_exists($key, $baselineItems)) {
|
||||
$drift[] = [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'severity' => Finding::SEVERITY_LOW,
|
||||
'severity' => $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'policy_type' => $currentItem['policy_type'],
|
||||
@ -276,6 +348,7 @@ private function computeDrift(array $baselineItems, array $currentItems): array
|
||||
* Upsert drift findings using stable fingerprints.
|
||||
*
|
||||
* @param array<int, array{change_type: string, severity: string, subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}> $driftResults
|
||||
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
|
||||
*/
|
||||
private function upsertFindings(
|
||||
DriftHasher $driftHasher,
|
||||
@ -283,9 +356,14 @@ private function upsertFindings(
|
||||
BaselineProfile $profile,
|
||||
string $scopeKey,
|
||||
array $driftResults,
|
||||
): int {
|
||||
$upsertedCount = 0;
|
||||
): array {
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$observedAt = CarbonImmutable::now();
|
||||
$processedCount = 0;
|
||||
$createdCount = 0;
|
||||
$reopenedCount = 0;
|
||||
$unchangedCount = 0;
|
||||
$seenFingerprints = [];
|
||||
|
||||
foreach ($driftResults as $driftItem) {
|
||||
$fingerprint = $driftHasher->fingerprint(
|
||||
@ -298,29 +376,99 @@ private function upsertFindings(
|
||||
currentHash: $driftItem['current_hash'],
|
||||
);
|
||||
|
||||
Finding::query()->updateOrCreate(
|
||||
[
|
||||
$seenFingerprints[] = $fingerprint;
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('fingerprint', $fingerprint)
|
||||
->first();
|
||||
|
||||
$isNewFinding = ! $finding instanceof Finding;
|
||||
|
||||
if ($isNewFinding) {
|
||||
$finding = new Finding;
|
||||
} else {
|
||||
$this->observeFinding(
|
||||
finding: $finding,
|
||||
observedAt: $observedAt,
|
||||
currentOperationRunId: (int) $this->operationRun->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'tenant_id' => $tenantId,
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
[
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'subject_type' => $driftItem['subject_type'],
|
||||
'subject_external_id' => $driftItem['subject_external_id'],
|
||||
'severity' => $driftItem['severity'],
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'fingerprint' => $fingerprint,
|
||||
'evidence_jsonb' => $driftItem['evidence'],
|
||||
'baseline_operation_run_id' => null,
|
||||
'current_operation_run_id' => (int) $this->operationRun->getKey(),
|
||||
],
|
||||
);
|
||||
]);
|
||||
|
||||
$upsertedCount++;
|
||||
if ($isNewFinding) {
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'reopened_at' => null,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
'first_seen_at' => $observedAt,
|
||||
'last_seen_at' => $observedAt,
|
||||
'times_seen' => 1,
|
||||
]);
|
||||
|
||||
$createdCount++;
|
||||
} elseif (Finding::isTerminalStatus($finding->status)) {
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'reopened_at' => now(),
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => null,
|
||||
'closed_reason' => null,
|
||||
'closed_by_user_id' => null,
|
||||
]);
|
||||
|
||||
$reopenedCount++;
|
||||
} else {
|
||||
$unchangedCount++;
|
||||
}
|
||||
|
||||
return $upsertedCount;
|
||||
$finding->save();
|
||||
$processedCount++;
|
||||
}
|
||||
|
||||
return [
|
||||
'processed_count' => $processedCount,
|
||||
'created_count' => $createdCount,
|
||||
'reopened_count' => $reopenedCount,
|
||||
'unchanged_count' => $unchangedCount,
|
||||
'seen_fingerprints' => array_values(array_unique($seenFingerprints)),
|
||||
];
|
||||
}
|
||||
|
||||
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
|
||||
{
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $observedAt;
|
||||
}
|
||||
|
||||
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
|
||||
$finding->last_seen_at = $observedAt;
|
||||
}
|
||||
|
||||
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
|
||||
|
||||
if ((int) ($finding->current_operation_run_id ?? 0) !== $currentOperationRunId) {
|
||||
$finding->times_seen = max(0, $timesSeen) + 1;
|
||||
} elseif ($timesSeen < 1) {
|
||||
$finding->times_seen = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -339,6 +487,44 @@ private function countBySeverity(array $driftResults): array
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveSeverityMapping(Workspace $workspace, SettingsResolver $settingsResolver): array
|
||||
{
|
||||
try {
|
||||
$mapping = $settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: 'baseline',
|
||||
key: 'severity_mapping',
|
||||
);
|
||||
} catch (\InvalidArgumentException) {
|
||||
// Settings keys are registry-backed; if this key is missing (e.g. during rollout),
|
||||
// fall back to built-in defaults rather than failing the entire compare run.
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($mapping) ? $mapping : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $severityMapping
|
||||
*/
|
||||
private function severityForChangeType(array $severityMapping, string $changeType): string
|
||||
{
|
||||
$severity = $severityMapping[$changeType] ?? null;
|
||||
|
||||
if (! is_string($severity) || $severity === '') {
|
||||
return match ($changeType) {
|
||||
'missing_policy' => Finding::SEVERITY_HIGH,
|
||||
'different_version' => Finding::SEVERITY_MEDIUM,
|
||||
default => Finding::SEVERITY_LOW,
|
||||
};
|
||||
}
|
||||
|
||||
return $severity;
|
||||
}
|
||||
|
||||
private function auditStarted(
|
||||
AuditLogger $auditLogger,
|
||||
Tenant $tenant,
|
||||
|
||||
@ -18,6 +18,10 @@ class AlertRule extends Model
|
||||
|
||||
public const string EVENT_COMPARE_FAILED = 'compare_failed';
|
||||
|
||||
public const string EVENT_BASELINE_HIGH_DRIFT = 'baseline_high_drift';
|
||||
|
||||
public const string EVENT_BASELINE_COMPARE_FAILED = 'baseline_compare_failed';
|
||||
|
||||
public const string EVENT_SLA_DUE = 'sla_due';
|
||||
|
||||
public const string EVENT_PERMISSION_MISSING = 'permission_missing';
|
||||
|
||||
121
app/Services/Baselines/BaselineAutoCloseService.php
Normal file
121
app/Services/Baselines/BaselineAutoCloseService.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
final class BaselineAutoCloseService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsResolver $settingsResolver,
|
||||
) {}
|
||||
|
||||
public function shouldAutoClose(Tenant $tenant, OperationRun $run): bool
|
||||
{
|
||||
if ($run->status !== OperationRunStatus::Completed->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($run->outcome !== OperationRunOutcome::Succeeded->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
|
||||
if (
|
||||
! array_key_exists('total', $summaryCounts)
|
||||
|| ! array_key_exists('processed', $summaryCounts)
|
||||
|| ! array_key_exists('failed', $summaryCounts)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$total = (int) $summaryCounts['total'];
|
||||
$processed = (int) $summaryCounts['processed'];
|
||||
$failed = (int) $summaryCounts['failed'];
|
||||
|
||||
if ($processed !== $total || $failed !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = $this->resolveWorkspace($tenant);
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return (bool) $this->settingsResolver->resolveValue(
|
||||
workspace: $workspace,
|
||||
domain: 'baseline',
|
||||
key: 'auto_close_enabled',
|
||||
);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $seenFingerprints
|
||||
*/
|
||||
public function resolveStaleFindings(
|
||||
Tenant $tenant,
|
||||
int $baselineProfileId,
|
||||
array $seenFingerprints,
|
||||
int $currentOperationRunId,
|
||||
): int {
|
||||
$scopeKey = 'baseline_profile:'.$baselineProfileId;
|
||||
$resolvedAt = now();
|
||||
$resolvedCount = 0;
|
||||
|
||||
$query = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->orderBy('id');
|
||||
|
||||
if ($seenFingerprints !== []) {
|
||||
$query->whereNotIn('fingerprint', array_values(array_unique($seenFingerprints)));
|
||||
}
|
||||
|
||||
$query->chunkById(100, function ($findings) use (&$resolvedCount, $resolvedAt, $currentOperationRunId): void {
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $resolvedAt,
|
||||
'resolved_reason' => 'no_longer_drifting',
|
||||
'current_operation_run_id' => $currentOperationRunId,
|
||||
])->save();
|
||||
|
||||
$resolvedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return $resolvedCount;
|
||||
}
|
||||
|
||||
private function resolveWorkspace(Tenant $tenant): ?Workspace
|
||||
{
|
||||
$workspaceId = (int) ($tenant->workspace_id ?? 0);
|
||||
|
||||
if ($workspaceId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
final class BaselineCaptureService
|
||||
{
|
||||
@ -45,7 +46,7 @@ public function startCapture(
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
tenant: $sourceTenant,
|
||||
type: 'baseline_capture',
|
||||
type: OperationRunType::BaselineCapture->value,
|
||||
identityInputs: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
],
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
final class BaselineCompareService
|
||||
{
|
||||
@ -67,7 +68,7 @@ public function startCompare(
|
||||
|
||||
$run = $this->runs->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_compare',
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
],
|
||||
|
||||
202
app/Services/SystemConsole/OperationRunTriageService.php
Normal file
202
app/Services/SystemConsole/OperationRunTriageService.php
Normal file
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SystemConsole;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class OperationRunTriageService
|
||||
{
|
||||
private const RETRYABLE_TYPES = [
|
||||
'inventory_sync',
|
||||
'policy.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'drift_generate_findings',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
];
|
||||
|
||||
private const CANCELABLE_TYPES = [
|
||||
'inventory_sync',
|
||||
'policy.sync',
|
||||
'policy.sync_one',
|
||||
'entra_group_sync',
|
||||
'drift_generate_findings',
|
||||
'findings.lifecycle.backfill',
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly SystemConsoleAuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function canRetry(OperationRun $run): bool
|
||||
{
|
||||
return (string) $run->status === OperationRunStatus::Completed->value
|
||||
&& (string) $run->outcome === OperationRunOutcome::Failed->value
|
||||
&& in_array((string) $run->type, self::RETRYABLE_TYPES, true);
|
||||
}
|
||||
|
||||
public function canCancel(OperationRun $run): bool
|
||||
{
|
||||
return in_array((string) $run->status, [
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
], true)
|
||||
&& in_array((string) $run->type, self::CANCELABLE_TYPES, true);
|
||||
}
|
||||
|
||||
public function retry(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
{
|
||||
if (! $this->canRetry($run)) {
|
||||
throw new InvalidArgumentException('Operation run is not retryable.');
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
is_array($context['triage'] ?? null) ? $context['triage'] : [],
|
||||
[
|
||||
'retry_of_run_id' => (int) $run->getKey(),
|
||||
'retried_at' => now()->toISOString(),
|
||||
'retried_by' => [
|
||||
'platform_user_id' => (int) $actor->getKey(),
|
||||
'name' => $actor->name,
|
||||
'email' => $actor->email,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$retryRun = OperationRun::query()->create([
|
||||
'workspace_id' => (int) $run->workspace_id,
|
||||
'tenant_id' => $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
'user_id' => null,
|
||||
'initiator_name' => $actor->name ?? 'Platform operator',
|
||||
'type' => (string) $run->type,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', 'retry|'.$run->getKey().'|'.now()->format('U.u').'|'.bin2hex(random_bytes(8))),
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => $context,
|
||||
'started_at' => null,
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
actor: $actor,
|
||||
action: 'platform.system_console.retry',
|
||||
metadata: [
|
||||
'source_run_id' => (int) $run->getKey(),
|
||||
'new_run_id' => (int) $retryRun->getKey(),
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $retryRun,
|
||||
);
|
||||
|
||||
return $retryRun;
|
||||
}
|
||||
|
||||
public function cancel(OperationRun $run, PlatformUser $actor): OperationRun
|
||||
{
|
||||
if (! $this->canCancel($run)) {
|
||||
throw new InvalidArgumentException('Operation run is not cancelable.');
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
is_array($context['triage'] ?? null) ? $context['triage'] : [],
|
||||
[
|
||||
'cancelled_at' => now()->toISOString(),
|
||||
'cancelled_by' => [
|
||||
'platform_user_id' => (int) $actor->getKey(),
|
||||
'name' => $actor->name,
|
||||
'email' => $actor->email,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$run->update([
|
||||
'context' => $context,
|
||||
]);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$cancelledRun = $this->operationRunService->updateRun(
|
||||
$run,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'run.cancelled',
|
||||
'message' => 'Run cancelled by platform operator triage action.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->auditLogger->log(
|
||||
actor: $actor,
|
||||
action: 'platform.system_console.cancel',
|
||||
metadata: [
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $cancelledRun,
|
||||
);
|
||||
|
||||
return $cancelledRun;
|
||||
}
|
||||
|
||||
public function markInvestigated(OperationRun $run, PlatformUser $actor, string $reason): OperationRun
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if (mb_strlen($reason) < 5 || mb_strlen($reason) > 500) {
|
||||
throw new InvalidArgumentException('Investigation reason must be between 5 and 500 characters.');
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$context['triage'] = array_merge(
|
||||
is_array($context['triage'] ?? null) ? $context['triage'] : [],
|
||||
[
|
||||
'investigated' => [
|
||||
'reason' => $reason,
|
||||
'investigated_at' => now()->toISOString(),
|
||||
'investigated_by' => [
|
||||
'platform_user_id' => (int) $actor->getKey(),
|
||||
'name' => $actor->name,
|
||||
'email' => $actor->email,
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$run->update([
|
||||
'context' => $context,
|
||||
]);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$this->auditLogger->log(
|
||||
actor: $actor,
|
||||
action: 'platform.system_console.mark_investigated',
|
||||
metadata: [
|
||||
'reason' => $reason,
|
||||
'operation_type' => (string) $run->type,
|
||||
],
|
||||
run: $run,
|
||||
);
|
||||
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
54
app/Services/SystemConsole/SystemConsoleAuditLogger.php
Normal file
54
app/Services/SystemConsole/SystemConsoleAuditLogger.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\SystemConsole;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
|
||||
final class SystemConsoleAuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly BreakGlassSession $breakGlassSession,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function log(
|
||||
PlatformUser $actor,
|
||||
string $action,
|
||||
string $status = 'success',
|
||||
array $metadata = [],
|
||||
?OperationRun $run = null,
|
||||
): void {
|
||||
$tenant = Tenant::query()->where('external_id', 'platform')->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata['break_glass_active'] = $this->breakGlassSession->isActive();
|
||||
|
||||
if ($run instanceof OperationRun) {
|
||||
$metadata['operation_run_id'] = (int) $run->getKey();
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: trim($action),
|
||||
context: ['metadata' => $metadata],
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
status: trim($status),
|
||||
resourceType: $run instanceof OperationRun ? 'operation_run' : null,
|
||||
resourceId: $run instanceof OperationRun ? (string) $run->getKey() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,14 @@ class PlatformCapabilities
|
||||
|
||||
public const USE_BREAK_GLASS = 'platform.use_break_glass';
|
||||
|
||||
public const CONSOLE_VIEW = 'platform.console.view';
|
||||
|
||||
public const DIRECTORY_VIEW = 'platform.directory.view';
|
||||
|
||||
public const OPERATIONS_VIEW = 'platform.operations.view';
|
||||
|
||||
public const OPERATIONS_MANAGE = 'platform.operations.manage';
|
||||
|
||||
public const OPS_VIEW = 'platform.ops.view';
|
||||
|
||||
public const RUNBOOKS_VIEW = 'platform.runbooks.view';
|
||||
|
||||
@ -42,6 +42,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -34,4 +34,5 @@ enum BadgeDomain: string
|
||||
case BaselineProfileStatus = 'baseline_profile_status';
|
||||
case FindingType = 'finding_type';
|
||||
case ReviewPackStatus = 'review_pack_status';
|
||||
case SystemHealth = 'system_health';
|
||||
}
|
||||
|
||||
25
app/Support/Badges/Domains/SystemHealthBadge.php
Normal file
25
app/Support/Badges/Domains/SystemHealthBadge.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class SystemHealthBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
|
||||
'warn' => new BadgeSpec('Warn', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'critical' => new BadgeSpec('Critical', 'danger', 'heroicon-m-x-circle'),
|
||||
'unknown' => BadgeSpec::unknown(),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
271
app/Support/Baselines/BaselineCompareStats.php
Normal file
271
app/Support/Baselines/BaselineCompareStats.php
Normal file
@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
final class BaselineCompareStats
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $severityCounts
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $state,
|
||||
public readonly ?string $message,
|
||||
public readonly ?string $profileName,
|
||||
public readonly ?int $profileId,
|
||||
public readonly ?int $snapshotId,
|
||||
public readonly ?int $operationRunId,
|
||||
public readonly ?int $findingsCount,
|
||||
public readonly array $severityCounts,
|
||||
public readonly ?string $lastComparedHuman,
|
||||
public readonly ?string $lastComparedIso,
|
||||
public readonly ?string $failureReason,
|
||||
) {}
|
||||
|
||||
public static function forTenant(?Tenant $tenant): self
|
||||
{
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return self::empty('no_tenant', 'No tenant selected.');
|
||||
}
|
||||
|
||||
$assignment = BaselineTenantAssignment::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->first();
|
||||
|
||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||
return self::empty(
|
||||
'no_assignment',
|
||||
'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.',
|
||||
);
|
||||
}
|
||||
|
||||
$profile = $assignment->baselineProfile;
|
||||
|
||||
if (! $profile instanceof BaselineProfile) {
|
||||
return self::empty(
|
||||
'no_assignment',
|
||||
'The assigned baseline profile no longer exists.',
|
||||
);
|
||||
}
|
||||
|
||||
$profileName = (string) $profile->name;
|
||||
$profileId = (int) $profile->getKey();
|
||||
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
||||
|
||||
if ($snapshotId === null) {
|
||||
return self::empty(
|
||||
'no_snapshot',
|
||||
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
);
|
||||
}
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'baseline_compare')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
// Active run (queued/running)
|
||||
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
||||
return new self(
|
||||
state: 'comparing',
|
||||
message: 'A baseline comparison is currently in progress.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: null,
|
||||
lastComparedIso: null,
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Failed run — explicit error state
|
||||
if ($latestRun instanceof OperationRun && $latestRun->outcome === 'failed') {
|
||||
$failureSummary = is_array($latestRun->failure_summary) ? $latestRun->failure_summary : [];
|
||||
$failureReason = $failureSummary['message']
|
||||
?? $failureSummary['reason']
|
||||
?? 'The comparison job failed. Check the run details for more information.';
|
||||
|
||||
return new self(
|
||||
state: 'failed',
|
||||
message: (string) $failureReason,
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
|
||||
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
|
||||
failureReason: (string) $failureReason,
|
||||
);
|
||||
}
|
||||
|
||||
$lastComparedHuman = null;
|
||||
$lastComparedIso = null;
|
||||
|
||||
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
|
||||
$lastComparedHuman = $latestRun->finished_at->diffForHumans();
|
||||
$lastComparedIso = $latestRun->finished_at->toIso8601String();
|
||||
}
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
// Single grouped query instead of 4 separate COUNT queries
|
||||
$severityRows = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->selectRaw('severity, count(*) as cnt')
|
||||
->groupBy('severity')
|
||||
->pluck('cnt', 'severity');
|
||||
|
||||
$totalFindings = (int) $severityRows->sum();
|
||||
$severityCounts = [
|
||||
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
|
||||
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
|
||||
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
|
||||
];
|
||||
|
||||
if ($totalFindings > 0) {
|
||||
return new self(
|
||||
state: 'ready',
|
||||
message: null,
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
|
||||
return new self(
|
||||
state: 'ready',
|
||||
message: 'No open drift findings for this baseline comparison. The tenant matches the baseline.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
operationRunId: (int) $latestRun->getKey(),
|
||||
findingsCount: 0,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
return new self(
|
||||
state: 'idle',
|
||||
message: 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.',
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: $snapshotId,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: $severityCounts,
|
||||
lastComparedHuman: $lastComparedHuman,
|
||||
lastComparedIso: $lastComparedIso,
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DTO for widget consumption (only open/new findings).
|
||||
*/
|
||||
public static function forWidget(?Tenant $tenant): self
|
||||
{
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return self::empty('no_tenant', null);
|
||||
}
|
||||
|
||||
$assignment = BaselineTenantAssignment::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->with('baselineProfile')
|
||||
->first();
|
||||
|
||||
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
|
||||
return self::empty('no_assignment', null);
|
||||
}
|
||||
|
||||
$profile = $assignment->baselineProfile;
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$severityRows = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->selectRaw('severity, count(*) as cnt')
|
||||
->groupBy('severity')
|
||||
->pluck('cnt', 'severity');
|
||||
|
||||
$totalFindings = (int) $severityRows->sum();
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'baseline_compare')
|
||||
->where('context->baseline_profile_id', (string) $profile->getKey())
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->first();
|
||||
|
||||
return new self(
|
||||
state: $totalFindings > 0 ? 'ready' : 'idle',
|
||||
message: null,
|
||||
profileName: (string) $profile->name,
|
||||
profileId: (int) $profile->getKey(),
|
||||
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
||||
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
||||
findingsCount: $totalFindings,
|
||||
severityCounts: [
|
||||
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
|
||||
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
|
||||
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
|
||||
],
|
||||
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
||||
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
|
||||
private static function empty(
|
||||
string $state,
|
||||
?string $message,
|
||||
?string $profileName = null,
|
||||
?int $profileId = null,
|
||||
): self {
|
||||
return new self(
|
||||
state: $state,
|
||||
message: $message,
|
||||
profileName: $profileName,
|
||||
profileId: $profileId,
|
||||
snapshotId: null,
|
||||
operationRunId: null,
|
||||
findingsCount: null,
|
||||
severityCounts: [],
|
||||
lastComparedHuman: null,
|
||||
lastComparedIso: null,
|
||||
failureReason: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
enum OperationRunType: string
|
||||
{
|
||||
case BaselineCapture = 'baseline_capture';
|
||||
case BaselineCompare = 'baseline_compare';
|
||||
case InventorySync = 'inventory_sync';
|
||||
case PolicySync = 'policy.sync';
|
||||
case PolicySyncOne = 'policy.sync_one';
|
||||
|
||||
@ -80,6 +80,74 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value),
|
||||
));
|
||||
|
||||
$this->register(new SettingDefinition(
|
||||
domain: 'baseline',
|
||||
key: 'severity_mapping',
|
||||
type: 'json',
|
||||
systemDefault: self::defaultBaselineSeverityMapping(),
|
||||
rules: [
|
||||
'required',
|
||||
'array',
|
||||
static function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (! is_array($value)) {
|
||||
$fail('The baseline severity mapping must be a JSON object.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$supportedKeys = array_fill_keys(self::supportedBaselineChangeTypes(), true);
|
||||
|
||||
foreach ($value as $changeType => $severity) {
|
||||
if (! is_string($changeType) || ! isset($supportedKeys[$changeType])) {
|
||||
$fail(sprintf(
|
||||
'Baseline severity mapping keys must be one of: %s.',
|
||||
implode(', ', self::supportedBaselineChangeTypes()),
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($severity)) {
|
||||
$fail(sprintf('Severity for "%s" must be a string.', $changeType));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedSeverity = strtolower($severity);
|
||||
|
||||
if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) {
|
||||
$fail(sprintf(
|
||||
'Severity for "%s" must be one of: %s.',
|
||||
$changeType,
|
||||
implode(', ', self::supportedFindingSeverities()),
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
normalizer: static fn (mixed $value): array => self::normalizeBaselineSeverityMapping($value),
|
||||
));
|
||||
|
||||
$this->register(new SettingDefinition(
|
||||
domain: 'baseline',
|
||||
key: 'alert_min_severity',
|
||||
type: 'string',
|
||||
systemDefault: Finding::SEVERITY_HIGH,
|
||||
rules: ['required', 'string', 'in:low,medium,high,critical'],
|
||||
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
|
||||
));
|
||||
|
||||
$this->register(new SettingDefinition(
|
||||
domain: 'baseline',
|
||||
key: 'auto_close_enabled',
|
||||
type: 'bool',
|
||||
systemDefault: true,
|
||||
rules: ['required', 'boolean'],
|
||||
normalizer: static fn (mixed $value): bool => filter_var($value, FILTER_VALIDATE_BOOL),
|
||||
));
|
||||
|
||||
$this->register(new SettingDefinition(
|
||||
domain: 'findings',
|
||||
key: 'sla_days',
|
||||
@ -223,6 +291,61 @@ private static function normalizeSeverityMapping(mixed $value): array
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function supportedBaselineChangeTypes(): array
|
||||
{
|
||||
return [
|
||||
'different_version',
|
||||
'missing_policy',
|
||||
'unexpected_policy',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function defaultBaselineSeverityMapping(): array
|
||||
{
|
||||
return [
|
||||
'different_version' => Finding::SEVERITY_MEDIUM,
|
||||
'missing_policy' => Finding::SEVERITY_HIGH,
|
||||
'unexpected_policy' => Finding::SEVERITY_LOW,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function normalizeBaselineSeverityMapping(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$supportedKeys = array_fill_keys(self::supportedBaselineChangeTypes(), true);
|
||||
|
||||
foreach ($value as $changeType => $severity) {
|
||||
if (! is_string($changeType) || ! isset($supportedKeys[$changeType]) || ! is_string($severity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[$changeType] = strtolower($severity);
|
||||
}
|
||||
|
||||
$ordered = [];
|
||||
|
||||
foreach (self::defaultBaselineSeverityMapping() as $changeType => $_severity) {
|
||||
if (array_key_exists($changeType, $normalized)) {
|
||||
$ordered[$changeType] = $normalized[$changeType];
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
|
||||
53
app/Support/System/SystemDirectoryLinks.php
Normal file
53
app/Support/System/SystemDirectoryLinks.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\System;
|
||||
|
||||
use App\Filament\System\Pages\Directory\Tenants;
|
||||
use App\Filament\System\Pages\Directory\ViewTenant;
|
||||
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
||||
use App\Filament\System\Pages\Directory\Workspaces;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
|
||||
final class SystemDirectoryLinks
|
||||
{
|
||||
public static function workspacesIndex(): string
|
||||
{
|
||||
return Workspaces::getUrl(panel: 'system');
|
||||
}
|
||||
|
||||
public static function workspaceDetail(Workspace|int $workspace): string
|
||||
{
|
||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
||||
|
||||
return ViewWorkspace::getUrl(['workspace' => $workspaceId], panel: 'system');
|
||||
}
|
||||
|
||||
public static function tenantsIndex(): string
|
||||
{
|
||||
return Tenants::getUrl(panel: 'system');
|
||||
}
|
||||
|
||||
public static function tenantDetail(Tenant|int $tenant): string
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant;
|
||||
|
||||
return ViewTenant::getUrl(['tenant' => $tenantId], panel: 'system');
|
||||
}
|
||||
|
||||
public static function adminWorkspace(Workspace|int $workspace): string
|
||||
{
|
||||
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
|
||||
|
||||
return route('filament.admin.resources.workspaces.view', ['record' => $workspaceId]);
|
||||
}
|
||||
|
||||
public static function adminTenant(Tenant|int $tenant): string
|
||||
{
|
||||
$tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant;
|
||||
|
||||
return route('filament.admin.resources.tenants.view', ['record' => $tenantId]);
|
||||
}
|
||||
}
|
||||
86
app/Support/SystemConsole/StuckRunClassifier.php
Normal file
86
app/Support/SystemConsole/StuckRunClassifier.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\SystemConsole;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class StuckRunClassifier
|
||||
{
|
||||
public function __construct(
|
||||
private readonly int $queuedThresholdMinutes = 0,
|
||||
private readonly int $runningThresholdMinutes = 0,
|
||||
) {}
|
||||
|
||||
public function queuedThresholdMinutes(): int
|
||||
{
|
||||
if ($this->queuedThresholdMinutes > 0) {
|
||||
return $this->queuedThresholdMinutes;
|
||||
}
|
||||
|
||||
return max(1, (int) config('tenantpilot.system_console.stuck_thresholds.queued_minutes', 15));
|
||||
}
|
||||
|
||||
public function runningThresholdMinutes(): int
|
||||
{
|
||||
if ($this->runningThresholdMinutes > 0) {
|
||||
return $this->runningThresholdMinutes;
|
||||
}
|
||||
|
||||
return max(1, (int) config('tenantpilot.system_console.stuck_thresholds.running_minutes', 30));
|
||||
}
|
||||
|
||||
public function apply(Builder $query, ?CarbonImmutable $now = null): Builder
|
||||
{
|
||||
$now ??= CarbonImmutable::now();
|
||||
|
||||
$queuedCutoff = $now->subMinutes($this->queuedThresholdMinutes());
|
||||
$runningCutoff = $now->subMinutes($this->runningThresholdMinutes());
|
||||
|
||||
return $query->where(function (Builder $stuckQuery) use ($queuedCutoff, $runningCutoff): void {
|
||||
$stuckQuery
|
||||
->where(function (Builder $queuedQuery) use ($queuedCutoff): void {
|
||||
$queuedQuery
|
||||
->where('status', OperationRunStatus::Queued->value)
|
||||
->whereNull('started_at')
|
||||
->where('created_at', '<=', $queuedCutoff);
|
||||
})
|
||||
->orWhere(function (Builder $runningQuery) use ($runningCutoff): void {
|
||||
$runningQuery
|
||||
->where('status', OperationRunStatus::Running->value)
|
||||
->where('started_at', '<=', $runningCutoff);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function classify(OperationRun $run, ?CarbonImmutable $now = null): ?string
|
||||
{
|
||||
$now ??= CarbonImmutable::now();
|
||||
|
||||
if ($run->status === OperationRunStatus::Queued->value) {
|
||||
if ($run->started_at !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($run->created_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $run->created_at->lte($now->subMinutes($this->queuedThresholdMinutes()))
|
||||
? OperationRunStatus::Queued->value
|
||||
: null;
|
||||
}
|
||||
|
||||
if ($run->status === OperationRunStatus::Running->value && $run->started_at !== null) {
|
||||
return $run->started_at->lte($now->subMinutes($this->runningThresholdMinutes()))
|
||||
? OperationRunStatus::Running->value
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
57
app/Support/SystemConsole/SystemConsoleWindow.php
Normal file
57
app/Support/SystemConsole/SystemConsoleWindow.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\SystemConsole;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class SystemConsoleWindow
|
||||
{
|
||||
public const LastHour = '1h';
|
||||
|
||||
public const LastDay = '24h';
|
||||
|
||||
public const LastWeek = '7d';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $value,
|
||||
private readonly int $minutes,
|
||||
) {}
|
||||
|
||||
public static function fromNullable(?string $window): self
|
||||
{
|
||||
return match (trim((string) $window)) {
|
||||
self::LastHour => new self(self::LastHour, 60),
|
||||
self::LastWeek => new self(self::LastWeek, 10080),
|
||||
default => new self(self::LastDay, 1440),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return [
|
||||
self::LastHour => 'Last hour',
|
||||
self::LastDay => 'Last 24 hours',
|
||||
self::LastWeek => 'Last 7 days',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowed(): array
|
||||
{
|
||||
return array_keys(self::options());
|
||||
}
|
||||
|
||||
public function startAt(?CarbonImmutable $now = null): CarbonImmutable
|
||||
{
|
||||
$now ??= CarbonImmutable::now();
|
||||
|
||||
return $now->subMinutes($this->minutes);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,13 @@
|
||||
'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15),
|
||||
],
|
||||
|
||||
'system_console' => [
|
||||
'stuck_thresholds' => [
|
||||
'queued_minutes' => (int) env('TENANTPILOT_SYSTEM_CONSOLE_STUCK_QUEUED_MINUTES', 15),
|
||||
'running_minutes' => (int) env('TENANTPILOT_SYSTEM_CONSOLE_STUCK_RUNNING_MINUTES', 30),
|
||||
],
|
||||
],
|
||||
|
||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||
|
||||
'supported_policy_types' => [
|
||||
|
||||
@ -34,6 +34,10 @@ public function run(): void
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::USE_BREAK_GLASS,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
PlatformCapabilities::OPERATIONS_MANAGE,
|
||||
PlatformCapabilities::OPS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
<x-filament::page>
|
||||
{{-- Auto-refresh while comparison is running --}}
|
||||
@if ($state === 'comparing')
|
||||
<div wire:poll.5s="refreshStats"></div>
|
||||
@endif
|
||||
|
||||
{{-- Row 1: Stats Overview --}}
|
||||
@if (in_array($state, ['ready', 'idle', 'comparing']))
|
||||
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{{-- Stat: Assigned Baseline --}}
|
||||
<x-filament::section>
|
||||
@ -19,9 +24,13 @@
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Findings</div>
|
||||
@if ($state === 'failed')
|
||||
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
|
||||
@else
|
||||
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
|
||||
{{ $findingsCount ?? 0 }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($state === 'comparing')
|
||||
<div class="flex items-center gap-1 text-sm text-info-600 dark:text-info-400">
|
||||
<x-filament::loading-indicator class="h-3 w-3" />
|
||||
@ -37,7 +46,7 @@
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Compared</div>
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||
<div class="text-lg font-semibold text-gray-950 dark:text-white" @if ($lastComparedIso) title="{{ $lastComparedIso }}" @endif>
|
||||
{{ $lastComparedAt ?? 'Never' }}
|
||||
</div>
|
||||
@if ($this->getRunUrl())
|
||||
@ -50,6 +59,37 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Failed run banner --}}
|
||||
@if ($state === 'failed')
|
||||
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-heroicon-s-x-circle class="h-6 w-6 shrink-0 text-danger-600 dark:text-danger-400" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-base font-semibold text-danger-800 dark:text-danger-200">
|
||||
Comparison Failed
|
||||
</div>
|
||||
<div class="text-sm text-danger-700 dark:text-danger-300">
|
||||
{{ $failureReason ?? 'The last baseline comparison failed. Review the run details or retry.' }}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
@if ($this->getRunUrl())
|
||||
<x-filament::button
|
||||
:href="$this->getRunUrl()"
|
||||
tag="a"
|
||||
color="danger"
|
||||
outlined
|
||||
icon="heroicon-o-queue-list"
|
||||
size="sm"
|
||||
>
|
||||
View failed run
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Critical drift banner --}}
|
||||
@if ($state === 'ready' && ($severityCounts['high'] ?? 0) > 0)
|
||||
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,129 @@
|
||||
@php
|
||||
/** @var \App\Models\Tenant $tenant */
|
||||
$tenant = $this->tenant;
|
||||
$providerConnections = $this->providerConnections();
|
||||
$permissions = $this->tenantPermissions();
|
||||
$runs = $this->recentRuns();
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
{{ $tenant->name }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Workspace: {{ $tenant->workspace?->name ?? 'Unknown' }}
|
||||
</x-slot>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->color"
|
||||
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->icon"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($tenant->external_id)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">External ID: {{ $tenant->external_id }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open in /admin
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Connectivity signals
|
||||
</x-slot>
|
||||
|
||||
@if ($providerConnections->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No provider connections found.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($providerConnections as $connection)
|
||||
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-medium text-gray-950 dark:text-white">{{ $connection->provider }}</span>
|
||||
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->label }}
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($connection->is_default)
|
||||
<x-filament::badge color="info">Default</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Permission signals
|
||||
</x-slot>
|
||||
|
||||
@if ($permissions->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No cached permission checks available.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($permissions as $permission)
|
||||
<div class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<span class="font-medium text-gray-950 dark:text-white">{{ $permission->permission_key }}</span>
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantPermissionStatus, (string) $permission->status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantPermissionStatus, (string) $permission->status)->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recent operations
|
||||
</x-slot>
|
||||
|
||||
@if ($runs->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No recent operation runs for this tenant.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($runs as $run)
|
||||
<a
|
||||
href="{{ $run['url'] }}"
|
||||
class="block rounded-lg border border-gray-200 px-4 py-3 hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-950 dark:text-white">#{{ $run['id'] }} · {{ $run['label'] }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $run['started'] }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->runsUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open operations runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,89 @@
|
||||
@php
|
||||
/** @var \App\Models\Workspace $workspace */
|
||||
$workspace = $this->workspace;
|
||||
$tenants = $this->workspaceTenants();
|
||||
$runs = $this->recentRuns();
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
{{ $workspace->name }}
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Workspace #{{ (int) $workspace->getKey() }}
|
||||
</x-slot>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tenants</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-950 dark:text-white">{{ number_format((int) $workspace->tenants_count) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->adminWorkspaceUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open in /admin
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Tenants summary
|
||||
</x-slot>
|
||||
|
||||
@if ($tenants->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No tenants are attached to this workspace.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($tenants as $tenant)
|
||||
<a
|
||||
href="{{ \App\Support\System\SystemDirectoryLinks::tenantDetail($tenant) }}"
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 px-4 py-3 hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<span class="font-medium text-gray-950 dark:text-white">{{ $tenant->name }}</span>
|
||||
<x-filament::badge
|
||||
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->color"
|
||||
>
|
||||
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::TenantStatus, (string) $tenant->status)->label }}
|
||||
</x-filament::badge>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recent operations
|
||||
</x-slot>
|
||||
|
||||
@if ($runs->isEmpty())
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No recent operation runs for this workspace.</p>
|
||||
@else
|
||||
<div class="space-y-2">
|
||||
@foreach ($runs as $run)
|
||||
<a
|
||||
href="{{ $run['url'] }}"
|
||||
class="block rounded-lg border border-gray-200 px-4 py-3 hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-950 dark:text-white">#{{ $run['id'] }} · {{ $run['label'] }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $run['started'] }}</span>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$this->runsUrl()" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open operations runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -2,13 +2,6 @@
|
||||
/** @var \App\Models\OperationRun $run */
|
||||
$run = $this->run;
|
||||
|
||||
$scope = (string) data_get($run->context, 'runbook.scope', 'unknown');
|
||||
$targetTenantId = data_get($run->context, 'runbook.target_tenant_id');
|
||||
$reasonCode = data_get($run->context, 'reason.reason_code');
|
||||
$reasonText = data_get($run->context, 'reason.reason_text');
|
||||
|
||||
$platformInitiator = data_get($run->context, 'platform_initiator', []);
|
||||
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||
(string) $run->status,
|
||||
@ -21,13 +14,12 @@
|
||||
)
|
||||
: null;
|
||||
|
||||
$summaryCounts = $run->summary_counts;
|
||||
$hasSummary = is_array($summaryCounts) && count($summaryCounts) > 0;
|
||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
$hasSummary = count($summaryCounts) > 0;
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
{{-- Run header --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Run #{{ (int) $run->getKey() }}
|
||||
@ -57,23 +49,18 @@
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Key details --}}
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Scope</dt>
|
||||
<dd class="mt-1">
|
||||
@if ($scope === 'single_tenant')
|
||||
<x-filament::badge color="info" size="sm">
|
||||
Single tenant {{ is_numeric($targetTenantId) ? '#'.(int) $targetTenantId : '' }}
|
||||
</x-filament::badge>
|
||||
@elseif ($scope === 'all_tenants')
|
||||
<x-filament::badge color="warning" size="sm">
|
||||
All tenants
|
||||
</x-filament::badge>
|
||||
@else
|
||||
<span class="text-sm font-medium text-gray-950 dark:text-white">{{ $scope }}</span>
|
||||
@endif
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Workspace</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->workspace?->name ?? 'Unknown workspace' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tenant</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $run->tenant?->name ?? 'Tenantless' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@ -95,38 +82,26 @@
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Initiator</dt>
|
||||
<dd class="mt-1 text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ (string) ($run->initiator_name ?? '—') }}
|
||||
@if (is_array($platformInitiator) && ($platformInitiator['email'] ?? null))
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ (string) $platformInitiator['email'] }}</div>
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Runbooks</dt>
|
||||
<dd class="mt-1 text-sm">
|
||||
<x-filament::link href="{{ \App\Filament\System\Pages\Ops\Runbooks::getUrl(panel: 'system') }}">
|
||||
Go to runbooks
|
||||
</x-filament::link>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{{-- Reason --}}
|
||||
@if (is_string($reasonCode) && is_string($reasonText) && trim($reasonCode) !== '' && trim($reasonText) !== '')
|
||||
<div class="flex items-start gap-3 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<x-heroicon-m-document-text class="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Reason</span>
|
||||
<div class="mt-1 text-sm text-gray-950 dark:text-white">
|
||||
<x-filament::badge color="gray" size="sm">{{ $reasonCode }}</x-filament::badge>
|
||||
<span class="ml-1">{{ $reasonText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Summary counts --}}
|
||||
@if ($hasSummary)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Summary counts
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
@foreach ($summaryCounts as $key => $value)
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
@ -139,20 +114,9 @@
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="cursor-pointer text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
Show raw JSON
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
@include('filament.partials.json-viewer', ['value' => $summaryCounts])
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Failures --}}
|
||||
@if (! empty($run->failure_summary))
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@ -165,15 +129,5 @@
|
||||
@include('filament.partials.json-viewer', ['value' => $run->failure_summary])
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- Context --}}
|
||||
<x-filament::section collapsible :collapsed="true">
|
||||
<x-slot name="heading">
|
||||
Context (raw)
|
||||
</x-slot>
|
||||
|
||||
@include('filament.partials.json-viewer', ['value' => $run->context ?? []])
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
{{-- Stats widgets --}}
|
||||
@if (method_exists($this, 'getHeaderWidgets'))
|
||||
<x-filament-widgets::widgets
|
||||
:widgets="$this->getVisibleHeaderWidgets()"
|
||||
:columns="1"
|
||||
/>
|
||||
@endif
|
||||
|
||||
{{-- Purpose box --}}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
<p class="font-medium">Purpose</p>
|
||||
@ -9,5 +18,50 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Workspace table --}}
|
||||
{{ $this->table }}
|
||||
|
||||
{{-- Recent break-glass actions --}}
|
||||
@php
|
||||
$recentActions = $this->getRecentBreakGlassActions();
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recent break-glass actions
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Last 10 break-glass audit log entries.
|
||||
</x-slot>
|
||||
|
||||
@if (empty($recentActions))
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-center text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||
No break-glass actions recorded yet.
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
@foreach ($recentActions as $entry)
|
||||
<div class="flex items-center justify-between gap-4 px-1 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $entry['action'] }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
by {{ $entry['actor'] }}
|
||||
@if ($entry['workspace'])
|
||||
· Workspace: {{ $entry['workspace'] }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $entry['recorded_at'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,41 @@
|
||||
<x-filament-widgets::widget>
|
||||
@php
|
||||
$health = $this->getHealthData();
|
||||
@endphp
|
||||
|
||||
<div @class([
|
||||
'flex items-center gap-3 rounded-xl border px-4 py-3',
|
||||
'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-950/30' => $health['level'] === 'healthy',
|
||||
'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-950/30' => $health['level'] === 'warning',
|
||||
'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-950/30' => $health['level'] === 'critical',
|
||||
])>
|
||||
<x-filament::icon
|
||||
:icon="$health['icon']"
|
||||
@class([
|
||||
'h-8 w-8',
|
||||
'text-green-600 dark:text-green-400' => $health['level'] === 'healthy',
|
||||
'text-yellow-600 dark:text-yellow-400' => $health['level'] === 'warning',
|
||||
'text-red-600 dark:text-red-400' => $health['level'] === 'critical',
|
||||
])
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div @class([
|
||||
'text-sm font-semibold',
|
||||
'text-green-800 dark:text-green-200' => $health['level'] === 'healthy',
|
||||
'text-yellow-800 dark:text-yellow-200' => $health['level'] === 'warning',
|
||||
'text-red-800 dark:text-red-200' => $health['level'] === 'critical',
|
||||
])>
|
||||
{{ $health['label'] }}
|
||||
</div>
|
||||
@if ($health['level'] !== 'healthy')
|
||||
<div class="mt-0.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $health['failed'] }} failed · {{ $health['stuck'] }} stuck (last 24h)
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
No failures or stuck runs in the last 24 hours
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-filament-widgets::widget>
|
||||
@ -0,0 +1,41 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Recently failed operations
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Latest failures in {{ $windowLabel }}. Click any run for the canonical detail view.
|
||||
</x-slot>
|
||||
|
||||
@if ($runs->isEmpty())
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||
No failed operations in the selected time window.
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($runs as $run)
|
||||
<a
|
||||
href="{{ $run['url'] }}"
|
||||
class="block rounded-lg border border-gray-200 px-4 py-3 transition hover:border-primary-400 hover:bg-gray-50 dark:border-white/10 dark:hover:border-primary-500 dark:hover:bg-white/5"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="font-medium text-gray-950 dark:text-white">
|
||||
#{{ $run['id'] }} · {{ $run['operation'] }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ $run['created_at'] }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ $run['tenant'] }}</div>
|
||||
<div class="mt-2 text-sm text-danger-700 dark:text-danger-400">{{ $run['failure_message'] }}</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$runsUrl" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open all runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
@ -0,0 +1,46 @@
|
||||
<x-filament-widgets::widget>
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Top offenders
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="description">
|
||||
Highest failed-operation clusters in {{ $windowLabel }}.
|
||||
</x-slot>
|
||||
|
||||
@if ($offenders->isEmpty())
|
||||
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
|
||||
No failed operations in the selected time window.
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-white/10">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
<th class="px-3 py-2">Workspace</th>
|
||||
<th class="px-3 py-2">Tenant</th>
|
||||
<th class="px-3 py-2">Operation</th>
|
||||
<th class="px-3 py-2 text-right">Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
@foreach ($offenders as $offender)
|
||||
<tr>
|
||||
<td class="px-3 py-2 font-medium text-gray-950 dark:text-white">{{ $offender['workspace_label'] }}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{{ $offender['tenant_label'] }}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{{ $offender['operation_label'] }}</td>
|
||||
<td class="px-3 py-2 text-right font-semibold text-danger-600 dark:text-danger-400">{{ number_format($offender['failed_count']) }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4">
|
||||
<x-filament::link :href="$runsUrl" icon="heroicon-m-arrow-top-right-on-square">
|
||||
Open all runs
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-widgets::widget>
|
||||
@ -0,0 +1,39 @@
|
||||
# Specification Quality Checklist: System Console Control Tower
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-27
|
||||
**Feature**: [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
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
|
||||
- Validation pass: Spec + tasks updated after consistency review to:
|
||||
- Disambiguate what “Audit log?” means in the UI Action Matrix (Access Logs surface, not per-page view logging).
|
||||
- Lock v1 scope: raw error/context drilldowns are not present; export is deferred.
|
||||
- Ensure Runbooks navigation/shortcuts (FR-007) is explicitly verified in tasks.
|
||||
@ -0,0 +1,372 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: System Console Control Tower (Spec 114)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Planning contract for System Console Control Tower read models.
|
||||
|
||||
NOTE: Filament/Livewire pages render server-side. This OpenAPI file documents
|
||||
the intended query surfaces as if they were JSON endpoints to keep fields
|
||||
and filtering semantics explicit during implementation.
|
||||
servers:
|
||||
- url: /system
|
||||
paths:
|
||||
/dashboard:
|
||||
get:
|
||||
summary: Control Tower KPIs
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
description: KPI + top offenders
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ControlTowerResponse'
|
||||
/directory/workspaces:
|
||||
get:
|
||||
summary: Workspaces directory
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: health
|
||||
schema:
|
||||
type: string
|
||||
enum: [ok, warn, critical, unknown]
|
||||
responses:
|
||||
'200':
|
||||
description: Workspaces list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkspaceListResponse'
|
||||
/directory/workspaces/{workspaceId}:
|
||||
get:
|
||||
summary: Workspace detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: workspaceId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkspaceDetailResponse'
|
||||
/directory/tenants:
|
||||
get:
|
||||
summary: Tenants directory
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: workspace_id
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Tenants list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantListResponse'
|
||||
/directory/tenants/{tenantId}:
|
||||
get:
|
||||
summary: Tenant detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: tenantId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantDetailResponse'
|
||||
/ops/runs:
|
||||
get:
|
||||
summary: Global operation runs
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [queued, running, completed]
|
||||
- in: query
|
||||
name: outcome
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: type
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: workspace_id
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: tenant_id
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Runs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunListResponse'
|
||||
/ops/runs/{runId}:
|
||||
get:
|
||||
summary: Canonical run detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: runId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Run detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunDetailResponse'
|
||||
/ops/failures:
|
||||
get:
|
||||
summary: Failed runs (prefilter)
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
responses:
|
||||
'200':
|
||||
description: Failed runs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunListResponse'
|
||||
/ops/stuck:
|
||||
get:
|
||||
summary: Stuck runs (prefilter)
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
responses:
|
||||
'200':
|
||||
description: Stuck runs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RunListResponse'
|
||||
/security/access-logs:
|
||||
get:
|
||||
summary: Access logs
|
||||
parameters:
|
||||
- in: query
|
||||
name: window
|
||||
schema:
|
||||
type: string
|
||||
enum: [1h, 24h, 7d]
|
||||
- in: query
|
||||
name: actor_id
|
||||
schema: { type: integer }
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [success, failure]
|
||||
responses:
|
||||
'200':
|
||||
description: Access logs list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AccessLogListResponse'
|
||||
components:
|
||||
schemas:
|
||||
ControlTowerResponse:
|
||||
type: object
|
||||
required: [window, kpis, top_offenders]
|
||||
properties:
|
||||
window: { type: string }
|
||||
kpis:
|
||||
type: object
|
||||
additionalProperties: { type: integer }
|
||||
top_offenders:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [dimension, id, label, failed_count]
|
||||
properties:
|
||||
dimension: { type: string, enum: [tenant, workspace, run_type] }
|
||||
id: { type: integer }
|
||||
label: { type: string }
|
||||
failed_count: { type: integer }
|
||||
WorkspaceListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkspaceSummary'
|
||||
WorkspaceDetailResponse:
|
||||
type: object
|
||||
required: [workspace]
|
||||
properties:
|
||||
workspace:
|
||||
$ref: '#/components/schemas/WorkspaceSummary'
|
||||
tenants:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantSummary'
|
||||
TenantListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantSummary'
|
||||
TenantDetailResponse:
|
||||
type: object
|
||||
required: [tenant]
|
||||
properties:
|
||||
tenant:
|
||||
$ref: '#/components/schemas/TenantSummary'
|
||||
provider_connections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ProviderConnectionSummary'
|
||||
permissions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TenantPermissionSummary'
|
||||
recent_runs:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RunSummary'
|
||||
RunListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RunSummary'
|
||||
RunDetailResponse:
|
||||
type: object
|
||||
required: [run]
|
||||
properties:
|
||||
run:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RunSummary'
|
||||
- type: object
|
||||
properties:
|
||||
summary_counts:
|
||||
type: object
|
||||
additionalProperties: { type: integer }
|
||||
failure_summary:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RunFailure'
|
||||
context:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
AccessLogListResponse:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AccessLogEntry'
|
||||
WorkspaceSummary:
|
||||
type: object
|
||||
required: [id, name, slug]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
slug: { type: string }
|
||||
tenant_count: { type: integer }
|
||||
health: { type: string, enum: [ok, warn, critical, unknown] }
|
||||
last_activity_at: { type: string, format: date-time, nullable: true }
|
||||
TenantSummary:
|
||||
type: object
|
||||
required: [id, external_id, name, workspace_id]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
external_id: { type: string }
|
||||
name: { type: string }
|
||||
workspace_id: { type: integer }
|
||||
status: { type: string }
|
||||
environment: { type: string, nullable: true }
|
||||
health: { type: string, enum: [ok, warn, critical, unknown] }
|
||||
last_activity_at: { type: string, format: date-time, nullable: true }
|
||||
ProviderConnectionSummary:
|
||||
type: object
|
||||
required: [provider, is_default]
|
||||
properties:
|
||||
provider: { type: string }
|
||||
is_default: { type: boolean }
|
||||
last_health_check_at: { type: string, format: date-time, nullable: true }
|
||||
health: { type: string, nullable: true }
|
||||
TenantPermissionSummary:
|
||||
type: object
|
||||
required: [key, status]
|
||||
properties:
|
||||
key: { type: string }
|
||||
status: { type: string }
|
||||
last_checked_at: { type: string, format: date-time, nullable: true }
|
||||
RunSummary:
|
||||
type: object
|
||||
required: [id, workspace_id, type, status, outcome, created_at]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
workspace_id: { type: integer }
|
||||
tenant_id: { type: integer, nullable: true }
|
||||
type: { type: string }
|
||||
status: { type: string }
|
||||
outcome: { type: string }
|
||||
initiator_name: { type: string }
|
||||
created_at: { type: string, format: date-time }
|
||||
started_at: { type: string, format: date-time, nullable: true }
|
||||
completed_at: { type: string, format: date-time, nullable: true }
|
||||
RunFailure:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: { type: string }
|
||||
reason_code: { type: string, nullable: true }
|
||||
message: { type: string }
|
||||
AccessLogEntry:
|
||||
type: object
|
||||
required: [id, recorded_at, action, status]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
recorded_at: { type: string, format: date-time }
|
||||
action: { type: string }
|
||||
status: { type: string }
|
||||
actor_id: { type: integer, nullable: true }
|
||||
actor_email: { type: string, nullable: true }
|
||||
actor_name: { type: string, nullable: true }
|
||||
ip: { type: string, nullable: true }
|
||||
user_agent: { type: string, nullable: true }
|
||||
85
specs/114-system-console-control-tower/data-model.md
Normal file
85
specs/114-system-console-control-tower/data-model.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Phase 1 — Data Model (Spec 114: System Console Control Tower)
|
||||
|
||||
This feature is primarily **read-only UI** over existing platform/ops metadata.
|
||||
|
||||
## Entities (existing)
|
||||
|
||||
### Workspace (`workspaces`)
|
||||
- Purpose: group tenants; scope boundary for tenant plane.
|
||||
- Relevant fields: `id`, `name`, `slug`, timestamps.
|
||||
- Relationships:
|
||||
- `Workspace::tenants()`
|
||||
- `Workspace::memberships()`
|
||||
|
||||
### Tenant (`tenants`)
|
||||
- Purpose: customer tenant inventory + onboarding/health metadata.
|
||||
- Relevant fields (high level):
|
||||
- `id`, `external_id`, `name`, `workspace_id`, `status`, `environment`
|
||||
- RBAC signals: `rbac_last_checked_at`, `rbac_last_setup_at`, `rbac_canary_results`, `rbac_last_warnings`
|
||||
- `metadata` (array)
|
||||
- Relationships:
|
||||
- `Tenant::providerConnections()` → `provider_connections`
|
||||
- `Tenant::permissions()` → `tenant_permissions`
|
||||
- `Tenant::auditLogs()` → `audit_logs`
|
||||
|
||||
### ProviderConnection (`provider_connections`)
|
||||
- Purpose: connectivity + health-check metadata for external provider.
|
||||
- Relevant fields: `provider`, `is_default`, `scopes_granted`, `last_health_check_at`, `metadata`.
|
||||
|
||||
### TenantPermission (`tenant_permissions`)
|
||||
- Purpose: cached/recorded permission checks.
|
||||
- Relevant fields: `key` (permission name), `status`, `details`, `last_checked_at`.
|
||||
|
||||
### OperationRun (`operation_runs`)
|
||||
- Purpose: canonical operations observability record (non-negotiable per constitution).
|
||||
- Relevant fields:
|
||||
- identity/scope: `workspace_id` (NOT NULL), `tenant_id` (nullable), `user_id` (nullable), `initiator_name`
|
||||
- lifecycle: `type`, `status` (`queued|running|completed`), `outcome` (`pending|succeeded|failed|canceled|…`)
|
||||
- audit UX: `summary_counts` (numeric-only keys), `failure_summary` (sanitized bounded array), `context` (sanitized/limited)
|
||||
- timing: `created_at`, `started_at`, `completed_at`
|
||||
|
||||
### AuditLog (`audit_logs`)
|
||||
- Purpose: security/audit trail.
|
||||
- Relevant fields: `workspace_id`, `tenant_id` (nullable), `actor_*`, `action`, `status`, `metadata` (sanitized), `recorded_at`.
|
||||
- System console relevant actions (already emitted):
|
||||
- `platform.auth.login`
|
||||
- `platform.break_glass.enter|exit|expired`
|
||||
|
||||
## Derived/Computed concepts (new, no new table)
|
||||
|
||||
### Time window
|
||||
- Enumerated: `1h`, `24h` (default), `7d`.
|
||||
- Used for Control Tower and for failures/stuck scoping.
|
||||
|
||||
### “Stuck” run classification
|
||||
- Definition: a run is “stuck” when:
|
||||
- `status=queued` and `created_at <= now() - queued_threshold_minutes` AND `started_at IS NULL`, OR
|
||||
- `status=running` and `started_at <= now() - running_threshold_minutes`
|
||||
- Thresholds are configurable (v1):
|
||||
- `system_console.stuck_thresholds.queued_minutes`
|
||||
- `system_console.stuck_thresholds.running_minutes`
|
||||
|
||||
### Tenant/workspace health badge
|
||||
- “Worst wins” aggregation over signals:
|
||||
- Tenant status (active/onboarding/archived)
|
||||
- Provider connection health/status
|
||||
- Permission status
|
||||
- Recent failed/stuck runs within the time window
|
||||
- Display-only; does not mutate state.
|
||||
|
||||
## Validation rules
|
||||
- Any operator-provided reason/note (break-glass, mark investigated): min length 5, max length 500.
|
||||
- Filters: only allow known enum values (time window, run status/outcome).
|
||||
|
||||
## Storage/indexing plan (Phase 2 tasks will implement)
|
||||
- `operation_runs`:
|
||||
- Indexes to support windowed queries and grouping:
|
||||
- `(workspace_id, created_at)`
|
||||
- `(tenant_id, created_at)`
|
||||
- optional: `(status, outcome, created_at)` and `(type, created_at)` depending on explain plans
|
||||
- `audit_logs`:
|
||||
- `(action, recorded_at)` and `(actor_id, recorded_at)` for Access Logs filters
|
||||
|
||||
## Notes on data minimization
|
||||
- Use `RunFailureSanitizer` + `SummaryCountsNormalizer` contracts.
|
||||
- Avoid rendering raw `context` by default; when displayed, cap size and redact sensitive keys.
|
||||
167
specs/114-system-console-control-tower/plan.md
Normal file
167
specs/114-system-console-control-tower/plan.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Implementation Plan: System Console Control Tower (Spec 114)
|
||||
|
||||
**Branch**: `114-system-console-control-tower` | **Date**: 2026-02-27
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a platform-only `/system` Control Tower that provides:
|
||||
|
||||
- Global health KPIs + top offenders (windowed)
|
||||
- Cross-workspace Directory (workspaces + tenants) with health signals
|
||||
- Global Operations triage (runs + failures + stuck) with canonical run detail
|
||||
- Minimal Access Logs (platform auth + break-glass)
|
||||
|
||||
Approach: extend the existing Filament System panel and reuse existing read models (`OperationRun`, `AuditLog`, `Tenant`, `Workspace`) with DB-only queries and strict data minimization/sanitization.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5 (Livewire v4), Pest v4, Laravel Sail
|
||||
**Storage**: PostgreSQL
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Web (Filament/Livewire)
|
||||
**Project Type**: web
|
||||
**Performance Goals**: p95 < 1.0s for `/system` list/index pages at typical volumes
|
||||
**Constraints**: DB-only at render time; strict data minimization; no cross-plane session bridging
|
||||
**Scale/Scope**: cross-workspace platform operator views; growing `operation_runs` volumes
|
||||
|
||||
**Non-negotiables**
|
||||
|
||||
- `/system` is a separate plane from `/admin`.
|
||||
- Wrong plane / unauthenticated: behave as “not found” (404).
|
||||
- Platform user missing capability: forbidden (403).
|
||||
- DB-only at render time for `/system` pages (no Microsoft Graph calls while rendering).
|
||||
- Data minimization: no secrets/tokens; failures and audit context are sanitized.
|
||||
- Mutating actions are confirmed + audited.
|
||||
|
||||
**Spec source**: `specs/114-system-console-control-tower/spec.md`
|
||||
|
||||
## Constitution Check (Pre-design)
|
||||
|
||||
PASS.
|
||||
|
||||
- Inventory-first + read/write separation: this feature is read-first; v1 manages ops with strict guardrails.
|
||||
- Graph contract isolation: no render-time Graph calls; any future sync work goes through existing Graph client contracts.
|
||||
- Deterministic capabilities: capability checks use a registry (no raw strings).
|
||||
- RBAC-UX semantics: 404 vs 403 behavior preserved.
|
||||
- Ops observability: reuse `OperationRun` lifecycle via `OperationRunService`.
|
||||
- Data minimization: `RunFailureSanitizer` + `AuditContextSanitizer` are the contract.
|
||||
- Filament action safety: destructive/mutating actions require confirmation.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/114-system-console-control-tower/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── contracts/
|
||||
└── system-console-control-tower.openapi.yaml
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ └── System/
|
||||
│ └── Pages/
|
||||
├── Models/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
config/
|
||||
database/
|
||||
routes/
|
||||
tests/
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. System Console features live as Filament Pages under `app/Filament/System/Pages` using existing Eloquent models.
|
||||
|
||||
## Phase 0 — Research (Complete)
|
||||
|
||||
Output artifact:
|
||||
|
||||
- `specs/114-system-console-control-tower/research.md`
|
||||
|
||||
Resolved items:
|
||||
|
||||
- System panel already exists and is isolated by guard + session cookie middleware.
|
||||
- Existing audit stream already captures platform auth and break-glass events.
|
||||
- Existing ops primitives (`OperationRun`, sanitizers, links) are sufficient and should be reused.
|
||||
|
||||
## Phase 1 — Design & Contracts (Complete)
|
||||
|
||||
Output artifacts:
|
||||
|
||||
- `specs/114-system-console-control-tower/data-model.md`
|
||||
- `specs/114-system-console-control-tower/contracts/system-console-control-tower.openapi.yaml`
|
||||
- `specs/114-system-console-control-tower/quickstart.md`
|
||||
|
||||
Post-design Constitution Check:
|
||||
|
||||
- PASS (design remains DB-only, keeps plane separation, uses sanitization contracts, and Spec 114 documents UX-001 empty-state CTA expectations + v1 drilldown scope).
|
||||
|
||||
## Phase 2 — Implementation Planning (for `tasks.md` later)
|
||||
|
||||
This section outlines the implementation chunks and acceptance criteria that will become `tasks.md`.
|
||||
|
||||
### 2.1 RBAC + capabilities
|
||||
|
||||
- Extend `App\Support\Auth\PlatformCapabilities` to include Spec 114 capabilities.
|
||||
- Ensure all new `/system` pages check capabilities via the registry (no raw strings).
|
||||
- Keep 404/403 semantics aligned with the spec decisions.
|
||||
|
||||
### 2.2 Information architecture (/system routes)
|
||||
|
||||
- Dashboard (KPIs): global aggregated view, windowed.
|
||||
- Directory:
|
||||
- Workspaces index + workspace detail.
|
||||
- Tenants index + tenant detail.
|
||||
- Ops:
|
||||
- Runs list.
|
||||
- Failures list (prefiltered/saved view).
|
||||
- Stuck list (queued + running thresholds).
|
||||
- Canonical run detail: remove current runbook-only scoping so it can show any `OperationRun` (still authorization-checked).
|
||||
- Security:
|
||||
- Access logs list (platform login + break-glass only for v1).
|
||||
|
||||
### 2.3 Ops triage actions (v1 manage)
|
||||
|
||||
- Implement manage actions with capability gating (`platform.operations.manage`).
|
||||
- Actions:
|
||||
- Retry run: only when retryable.
|
||||
- Cancel run: only when cancelable.
|
||||
- Mark investigated: requires reason.
|
||||
- All actions:
|
||||
- Execute via Filament `Action::make(...)->action(...)`.
|
||||
- Include `->requiresConfirmation()`.
|
||||
- Produce an `AuditLog` entry with stable action IDs and sanitized context.
|
||||
|
||||
### 2.4 Configuration
|
||||
|
||||
- Add config keys for “stuck” thresholds (queued minutes, running minutes).
|
||||
- Ensure defaults are safe and can be overridden per environment.
|
||||
|
||||
### 2.5 Testing (Pest)
|
||||
|
||||
- New page access tests:
|
||||
- non-platform users get 404.
|
||||
- platform users without capability get 403.
|
||||
- System auth/security regression verification:
|
||||
- `/system` login is rate-limited and failed attempts are audited via `platform.auth.login` (existing coverage in `tests/Feature/System/Spec113/SystemLoginThrottleTest.php`).
|
||||
- break-glass mode renders a persistent banner and audits transitions (`platform.break_glass.*`) (existing coverage in `tests/Feature/Auth/BreakGlassModeTest.php`).
|
||||
- Access logs surface tests:
|
||||
- `platform.auth.login` and `platform.break_glass.*` appear.
|
||||
- Manage action tests:
|
||||
- capability required.
|
||||
- audit entries written.
|
||||
- non-retryable/non-cancelable runs block with clear feedback.
|
||||
|
||||
### 2.6 Formatting
|
||||
|
||||
- Run `vendor/bin/sail bin pint --dirty --format agent` before finalizing implementation.
|
||||
41
specs/114-system-console-control-tower/quickstart.md
Normal file
41
specs/114-system-console-control-tower/quickstart.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Quickstart (Spec 114: System Console Control Tower)
|
||||
|
||||
## Prereqs
|
||||
- Docker running
|
||||
- Laravel Sail
|
||||
|
||||
## Start the app
|
||||
- `vendor/bin/sail up -d`
|
||||
- `vendor/bin/sail composer install`
|
||||
- `vendor/bin/sail artisan migrate`
|
||||
|
||||
## Seed a platform operator (recommended)
|
||||
The repo includes `Database\Seeders\PlatformUserSeeder`, which creates:
|
||||
- Workspace `default`
|
||||
- Tenant with `external_id=platform`
|
||||
- Platform user `operator@tenantpilot.io` (password: `password`) with baseline system capabilities
|
||||
|
||||
Run:
|
||||
- `vendor/bin/sail artisan db:seed`
|
||||
|
||||
## Open the System console
|
||||
- Visit `/system`
|
||||
- Login with:
|
||||
- Email: `operator@tenantpilot.io`
|
||||
- Password: `password`
|
||||
|
||||
## Validate key Spec 114 surfaces
|
||||
- Control Tower dashboard: `/system?window=24h` (switch between `1h`, `24h`, `7d`)
|
||||
- Global operations:
|
||||
- Runs: `/system/ops/runs`
|
||||
- Failures: `/system/ops/failures`
|
||||
- Stuck: `/system/ops/stuck`
|
||||
- Directory:
|
||||
- Workspaces: `/system/directory/workspaces`
|
||||
- Tenants: `/system/directory/tenants`
|
||||
- Security:
|
||||
- Access logs: `/system/security/access-logs`
|
||||
|
||||
## Notes
|
||||
- `/system` uses the `platform` guard and a separate session cookie from `/admin`.
|
||||
- The System console should remain DB-only at render time (no Graph calls on page load).
|
||||
97
specs/114-system-console-control-tower/research.md
Normal file
97
specs/114-system-console-control-tower/research.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Phase 0 — Research (Spec 114: System Console Control Tower)
|
||||
|
||||
## Goal
|
||||
Deliver a platform-operator “/system” control plane that is **strictly separated** from “/admin”, is **metadata-only by default**, and provides fast routing into canonical `OperationRun` detail.
|
||||
|
||||
## Existing primitives (reuse)
|
||||
|
||||
### System panel + plane separation
|
||||
- `app/Providers/Filament/SystemPanelProvider.php`
|
||||
- Panel: `id=system`, `path=system`, `authGuard('platform')`
|
||||
- Uses `UseSystemSessionCookie` to isolate sessions from `/admin`
|
||||
- Uses middleware `ensure-correct-guard:platform` and capability gate `ensure-platform-capability:<ACCESS_SYSTEM_PANEL>`
|
||||
- `app/Http/Middleware/UseSystemSessionCookie.php`
|
||||
- Implements Spec 114 clarification: separate session cookie name for `/system`
|
||||
|
||||
### Authorization semantics (404 vs 403)
|
||||
- Existing tests already enforce the clarified behavior:
|
||||
- Non-platform (wrong guard) → 404 (deny-as-not-found)
|
||||
- Platform user missing capability → 403
|
||||
|
||||
### Operation runs (Monitoring source of truth)
|
||||
- `app/Models/OperationRun.php` + migrations under `database/migrations/*operation_runs*`
|
||||
- `workspace_id` is required; `tenant_id` is nullable (supports tenantless runs)
|
||||
- `failure_summary`, `summary_counts`, `context` are JSON arrays and already used in UI
|
||||
- `app/Services/OperationRunService.php`
|
||||
- Canonical lifecycle transitions, summary-count normalization, failure sanitization
|
||||
- Has stale queued run helper (`isStaleQueuedRun()` + `failStaleQueuedRun()`)
|
||||
- Canonical System run links:
|
||||
- `app/Support/System/SystemOperationRunLinks.php` (index + view)
|
||||
|
||||
### Sanitization / data minimization
|
||||
- Failures: `app/Support/OpsUx/RunFailureSanitizer.php` (reason normalization + message redaction)
|
||||
- Audit metadata: `app/Support/Audit/AuditContextSanitizer.php` (redacts token/secret/password-like keys + bearer/JWT strings)
|
||||
|
||||
### Access logs signal source
|
||||
- `app/Models/AuditLog.php`
|
||||
- System login auditing:
|
||||
- `app/Filament/System/Pages/Auth/Login.php` writes `AuditLog` events with action `platform.auth.login`
|
||||
- Break-glass auditing:
|
||||
- `app/Services/Auth/BreakGlassSession.php` writes `platform.break_glass.enter|exit|expired`
|
||||
|
||||
## Key gaps to implement (Spec 114)
|
||||
|
||||
### Navigation/IA
|
||||
- Add System pages:
|
||||
- `/system/directory/workspaces` (+ detail)
|
||||
- `/system/directory/tenants` (+ detail)
|
||||
- `/system/ops/runs` (global) + canonical detail already exists but is currently *runbook-type scoped*
|
||||
- `/system/ops/failures` (prefilter)
|
||||
- `/system/ops/stuck` (prefilter)
|
||||
- `/system/security/access-logs`
|
||||
|
||||
### RBAC (platform capabilities)
|
||||
- `app/Support/Auth/PlatformCapabilities.php` currently contains only Ops/runbooks/break-glass/core panel access.
|
||||
- Spec 114 introduces additional capabilities (e.g. `platform.console.view`, `platform.directory.view`, `platform.operations.manage`).
|
||||
|
||||
Decision:
|
||||
- Extend `PlatformCapabilities` registry with Spec 114 capabilities and update system pages to gate via the registry constants (no raw strings).
|
||||
|
||||
### Stuck definition
|
||||
- There is a helper for “stale queued” in `OperationRunService`, but no “running too long” classification.
|
||||
|
||||
Decision:
|
||||
- Introduce configurable stuck thresholds for `queued` and `running` (minutes) under a single config namespace (e.g. `config/tenantpilot.php`), and implement stuck classification in a dedicated helper/service used by the System pages.
|
||||
|
||||
### Control Tower aggregation
|
||||
- Spec 114 requires KPIs + top offenders in a selectable time window.
|
||||
|
||||
Decision:
|
||||
- Use DB-only aggregation on `operation_runs` for the selected time window:
|
||||
- KPIs: counts by outcome/status, and “failed/stuck” counts
|
||||
- Top offenders: group by tenant/workspace for failed runs
|
||||
- Default time window: 24h; supported: 1h/24h/7d
|
||||
|
||||
## Non-functional decisions (resolving “NEEDS CLARIFICATION”)
|
||||
|
||||
### Technical context (resolved)
|
||||
- Language/runtime: PHP 8.4 (Laravel 12)
|
||||
- Admin framework: Filament v5 + Livewire v4
|
||||
- Storage: PostgreSQL (Sail locally)
|
||||
- Testing: Pest v4
|
||||
- Target: web app (server-rendered Livewire/Filament)
|
||||
|
||||
### Performance goals (assumptions, but explicit)
|
||||
- System list pages are DB-only at render time; no external calls.
|
||||
- Target: p95 < 1.0s for index pages at typical production volumes, using:
|
||||
- time-window defaults (24h)
|
||||
- pagination
|
||||
- indexes for `operation_runs(status,outcome,created_at,type,workspace_id,tenant_id)` and `audit_logs(action,recorded_at,actor_id)`
|
||||
|
||||
### Data minimization
|
||||
- Default run detail surfaces only sanitized `failure_summary` + normalized `summary_counts`.
|
||||
- `context` rendering remains sanitized/limited (avoid raw payload dumps by default).
|
||||
|
||||
## Alternatives considered
|
||||
- New “SystemOperationRun” table: rejected; existing `OperationRun` is already the canonical monitoring artifact.
|
||||
- Building Access Logs from web server logs: rejected; `AuditLog` already exists, is sanitized, and includes platform-auth events.
|
||||
167
specs/114-system-console-control-tower/spec.md
Normal file
167
specs/114-system-console-control-tower/spec.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Feature Specification: System Console Control Tower (Platform Operator)
|
||||
|
||||
**Feature Branch**: `114-system-console-control-tower`
|
||||
**Created**: 2026-02-27
|
||||
**Status**: Draft
|
||||
**Input**: Spec 114 — System Console Control Tower für Plattformbetreiber
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/system` (alias) and `/system/dashboard` (Control Tower)
|
||||
- `/system/directory/workspaces` + workspace detail
|
||||
- `/system/directory/tenants` + tenant detail
|
||||
- `/system/ops/runs` + canonical run detail (`/system/ops/runs/{run}`)
|
||||
- `/system/ops/failures` (prefilter)
|
||||
- `/system/ops/stuck` (prefilter)
|
||||
- `/system/security/access-logs`
|
||||
- **Data Ownership**: Platform-owned operational metadata across workspaces/tenants (health signals, run metadata, audit/access events). No customer policy payloads, secrets, or PII are presented by default.
|
||||
- **RBAC**:
|
||||
- Access is limited to platform users only (platform guard).
|
||||
- Capability-based access:
|
||||
- `platform.console.view`
|
||||
- `platform.directory.view`
|
||||
- `platform.operations.view`
|
||||
- `platform.operations.manage` (enabled in v1)
|
||||
- `platform.runbooks.view` / `platform.runbooks.run` (integration point with Spec 113)
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Not applicable. `/system` has no tenant-context; it is platform-only.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**:
|
||||
- Any request not authenticated as a platform user is treated as “not found” (deny-as-not-found).
|
||||
- Listing and detail access is always gated by capabilities (view vs manage) and only exposes non-sensitive metadata.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-27
|
||||
|
||||
- Q: Which session isolation should v1 implement for `/system` (SR-003)? → A: Same domain, but separate session cookie name for `/system`.
|
||||
- Q: Should manage actions (Retry/Cancel/Mark investigated) be active in v1? → A: Yes. `platform.operations.manage` is in v1 with: Retry (retryable only), Cancel (supported only), Mark investigated (reason required).
|
||||
- Q: Which 404 vs 403 semantics apply for `/system`? → A: Non-platform / wrong guard returns 404; platform user missing capability returns 403.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Global Health & Triage Entry (Priority: P1)
|
||||
|
||||
As a platform operator, I want a single Control Tower view that summarizes platform health and routes me to the most urgent issues, so I can triage failures quickly without exposing customer-sensitive data.
|
||||
|
||||
**Why this priority**: This is the primary operator workflow (“what’s broken right now?”) and the first screen that enables faster incident response.
|
||||
|
||||
**Independent Test**: A platform user can open the Control Tower, see KPIs/top offenders for a selected time window, and click through to a canonical run detail page.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform user with `platform.console.view`, **When** they open the Control Tower, **Then** they see KPI counts and “Top offenders” summaries for the selected time window.
|
||||
2. **Given** a failed operation exists, **When** they click a “recently failed operation”, **Then** they land on the canonical run detail page.
|
||||
3. **Given** a non-platform user, **When** they request any `/system/*` URL, **Then** the system does not reveal that the console exists.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Directory for Workspaces & Tenants (Priority: P2)
|
||||
|
||||
As a platform support engineer, I want a directory of workspaces and tenants with health signals and recent activity, so I can route issues to the right tenant/workspace and quickly inspect recent operations.
|
||||
|
||||
**Why this priority**: Most incidents are tenant-scoped; fast routing depends on a reliable cross-tenant directory with minimal data exposure.
|
||||
|
||||
**Independent Test**: A platform user can list workspaces/tenants, open details, and jump to run listings filtered to that tenant/workspace.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform user with `platform.directory.view`, **When** they view the Workspaces index, **Then** they can sort and filter by health and activity, and navigate to workspace details.
|
||||
2. **Given** a tenant, **When** they view tenant details, **Then** they see connectivity/permissions status and recent operations as metadata-only summaries.
|
||||
3. **Given** the UI provides an “Open in /admin” link, **When** a platform user clicks it, **Then** it is a plain URL only (no auto-login, no session bridging).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Operations Triage Actions & Auditability (Priority: P3)
|
||||
|
||||
As a privileged platform operator, I want to take safe triage actions on failed or stuck operation runs (retry/cancel/mark investigated), so I can restore platform health with guardrails and complete audit trails.
|
||||
|
||||
**Why this priority**: Operational actions are high-risk; they must be permission-gated and auditable.
|
||||
|
||||
**Independent Test**: A platform user with `platform.operations.manage` can perform an allowed triage action and observe that it is recorded, while a view-only user cannot.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform user without `platform.operations.manage`, **When** they view failures/stuck runs, **Then** they can inspect but cannot execute triage actions.
|
||||
2. **Given** a platform user with `platform.operations.manage`, **When** they retry a retryable run, **Then** a new run is initiated and linked to the original for traceability.
|
||||
3. **Given** a triage action is destructive or high blast-radius, **When** the operator attempts it, **Then** they must explicitly confirm (and provide a reason where required) before it executes.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Large volumes of runs and tenants: list pages still load within an acceptable wait time and do not degrade into partial/inconsistent results.
|
||||
- Missing or unknown health inputs: health is shown as “Unknown” or equivalent, not as a false “OK”.
|
||||
- Stuck classification boundaries: a run right on the threshold is classified consistently.
|
||||
- Sanitization: error/context summaries never reveal tokens, secrets, or policy payloads.
|
||||
- Break-glass mode: all pages show an unmistakable banner and actions include the break-glass marker.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 — Control Tower Dashboard (Global Health)**: The system MUST provide a Control Tower dashboard showing platform health within a selectable time window (default 24h; options include 1h/24h/7d), including KPI counts and “Top offenders” summaries.
|
||||
- **FR-002 — Directory: Workspaces**: The system MUST provide a Workspaces index and workspace detail view that shows tenant counts, a health badge (OK/Warn/Critical/Unknown), last activity, and quick links to relevant views.
|
||||
- **FR-003 — Directory: Tenants**: The system MUST provide a Tenants index and tenant detail view that shows provider connectivity status, permissions status, last sync/compare summaries as counts/metadata only, and runbook shortcuts where available.
|
||||
- **FR-004 — Operations: Global Runs + Canonical Run Detail**: The system MUST provide a global operation runs view with filtering (status/type/workspace/tenant/time window/actor) and a single canonical run detail page used by all “View run” links.
|
||||
- **FR-005 — Failures View (Prefiltered)**: The system MUST provide a failures view that prefilters to failed runs and groups/summarizes failures by run type and by tenant, enabling 1–2 click routing into run details.
|
||||
- **FR-006 — Stuck Runs Definition & View**: The system MUST define and surface “stuck” runs based on configurable thresholds for “queued too long” and “running too long”, and present an operator view for investigating them. Any triage actions available from this surface MUST follow FR-006a.
|
||||
- **FR-006a — Triage Actions (v1 enabled)**: For operators with `platform.operations.manage`, the system MUST provide triage actions in failures/stuck/run detail views, constrained as follows: Retry is available only for retryable run types; Cancel is available only where the run supports cancelation; “Mark investigated” requires a reason/note.
|
||||
- **FR-007 — Runbook Shortcuts Integration**: The system MUST provide navigation to runbooks from the System Console navigation. The UI MAY provide scope-aware shortcuts from tenant/workspace/run details. If runbooks are not available yet, the UI MAY show “coming soon” placeholders.
|
||||
- **FR-008 — Access Logs (Security, minimal v1)**: The system MUST provide an access log view for platform users that supports filtering by user/time/outcome and includes login successes/failures and break-glass activation events.
|
||||
- **FR-009 — Export (optional)**: The system MAY allow exporting filtered run metadata as CSV without including sensitive context. (Deferred in v1.)
|
||||
|
||||
### Security, Privacy, and Guardrails
|
||||
|
||||
- **SR-001 — Guard Isolation**: `/system` MUST be accessible exclusively to platform users; non-platform access (wrong guard or unauthenticated) MUST behave as “not found” and MUST not reveal the presence of the console.
|
||||
- **SR-001a — 404 vs 403 Semantics**: The system MUST apply the following response semantics consistently across `/system/*`:
|
||||
- Wrong guard / unauthenticated / not a platform user → 404 (deny-as-not-found)
|
||||
- Platform user authenticated but missing required capability → 403
|
||||
- **SR-002 — Authentication Hardening**: The system MUST throttle excessive `/system` login attempts and MUST record failed attempts for later review. v1 throttle policy is: max 10 failed attempts per 60 seconds per `ip + email` (throttle key: `system-login:{ip}:{normalizedEmail}`), recording `reason` (e.g., `invalid_credentials`, `inactive`, `throttled`) under the `platform.auth.login` audit action.
|
||||
- **SR-003 — Data Minimization by Default**: `/system` MUST avoid sensitive content by default (no raw policy payloads, secrets, tokens, or PII). Only counts, status badges, and sanitized summaries are shown.
|
||||
- **SR-004 — Sensitive Drilldowns**: v1 MUST NOT provide raw error/context payload inspection in `/system`. If raw inspection is introduced later, it MUST be restricted behind elevated capability and require an operator-provided reason.
|
||||
- **SR-005 — Break-Glass Guardrails**: When break-glass mode is active, the UI MUST show a persistent banner, require a reason, and annotate actions/logs as break-glass.
|
||||
- **SR-006 — Session Isolation**: `/system` MUST use a separate session cookie name (distinct from `/admin`) to reduce cross-plane session coupling. `/system` MUST NOT reuse the customer/admin session cookie.
|
||||
- **SR-007 — Manage Action Guardrails**: Any triage action that mutates state (retry/cancel/mark investigated) MUST be restricted to `platform.operations.manage`, MUST require explicit confirmation, and MUST record an audit trail including actor, scope, target run, and operator-provided reason where applicable.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- A platform operator console exists as a separate plane from customer administration, and customer users must never see maintenance/ops screens.
|
||||
- Operation execution is routed through a single auditable run model (operator actions are “initiated” and traceable).
|
||||
- Health statuses are computed from multiple signals using a “worst wins” rule.
|
||||
|
||||
## UI Action Matrix *(mandatory when System Console UI is changed)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Control Tower Dashboard | `/system/dashboard` | Time window switcher | “Recently failed operations” items link to run detail | None | None | View operation runs | N/A | N/A | Yes (access) | Read-only KPIs and offender summaries; no sensitive payloads |
|
||||
| Workspaces Index | `/system/directory/workspaces` | None | Click workspace name to open details | None | None | Clear filters | N/A | N/A | Yes (access) | Supports sort/filter by health/activity/tenant count |
|
||||
| Workspace Detail | `/system/directory/workspaces/{workspace}` | “View tenants”, “View runs (filtered)” | Tenant list items link to tenant detail; runs link to canonical run detail | None | None | View runs (filtered) | N/A | N/A | Yes (access) | “Open in /admin” is URL-only; no session bridging |
|
||||
| Tenants Index | `/system/directory/tenants` | None | Click tenant name to open details | None | None | Clear filters | N/A | N/A | Yes (access) | Shows health signals as badges and counts |
|
||||
| Tenant Detail | `/system/directory/tenants/{tenant}` | Runbook shortcuts (if entitled) | Recent operations list links to canonical run detail | Optional: “Run health check” / “Run sync” (max 2 visible; can be “coming soon”) | None | View operation runs | N/A | N/A | Yes | Runbook actions require confirmation and capability gating |
|
||||
| Operation Runs | `/system/ops/runs` | Filters | Row click or “View run” link to canonical run detail | “Retry” / “Cancel” (manage only; availability depends on run type/support) | “Retry selected”, “Cancel selected” (manage only; constrained) | Clear filters | N/A | N/A | Yes | Actions require explicit confirmation, may require reason, and are fully auditable |
|
||||
| Run Detail (Canonical) | `/system/ops/runs/{run}` | “Related tenant/workspace”, “Similar failures”, “Go to runbooks” | Links to filtered views | “Retry” / “Cancel” (manage only; constrained) | None | N/A | N/A | N/A | Yes | Context/error panels are sanitized by default in v1 (raw drilldowns are not available in v1) |
|
||||
| Failures View | `/system/ops/failures` | Filters | Links to canonical run detail and tenant/workspace | “Retry” (manage only; retryable only) | “Retry selected” (manage only; retryable only) | Clear filters | N/A | N/A | Yes | Pre-filter to failed; 1–2 click routing |
|
||||
| Stuck Runs View | `/system/ops/stuck` | Filters | Links to canonical run detail | “Cancel”, “Mark investigated” (manage only; cancel only if supported) | “Cancel selected” (manage only; constrained) | Clear filters | N/A | N/A | Yes | “Mark investigated” requires a note/reason |
|
||||
| Access Logs | `/system/security/access-logs` | Filters | None | None | None | Clear filters | N/A | N/A | Yes | Minimal v1 security visibility |
|
||||
|
||||
**Audit log interpretation**: In this matrix, “Audit log?” means security/audit events are visible via the Access Logs surface (login successes/failures, break-glass activation, and operator triage actions). It does not imply per-page view logging for every `/system` page.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Operation Run**: An auditable record of an operational activity, including type, scope (platform/workspace/tenant), actor, start/end timestamps, status/outcome, and a sanitized summary.
|
||||
- **Workspace**: A customer workspace container, used for grouping tenants and operational scope.
|
||||
- **Tenant**: A customer tenant within a workspace, including provider connectivity and governance signal summaries.
|
||||
- **Platform User**: An internal operator identity with capability-based authorization.
|
||||
- **Access Log**: A record of platform access and authentication-related security events.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Platform operators can identify the top failing tenant and open the related canonical run detail in ≤ 2 clicks from the Control Tower.
|
||||
- **SC-002**: The Control Tower and directory pages load in p95 < 1.0s for typical production volumes.
|
||||
- **SC-003**: In a structured review of the `/system` UI, no customer-sensitive payloads (policy content, secrets, tokens, PII) are visible by default.
|
||||
- **SC-004**: 100% of operator triage actions (retry/cancel/mark investigated) are permission-gated and leave a complete audit trail.
|
||||
- **SC-005**: Non-platform users cannot discover `/system` routes via direct URL guessing (console behaves as not found).
|
||||
201
specs/114-system-console-control-tower/tasks.md
Normal file
201
specs/114-system-console-control-tower/tasks.md
Normal file
@ -0,0 +1,201 @@
|
||||
---
|
||||
description: "Executable task breakdown for Spec 114 implementation"
|
||||
---
|
||||
|
||||
# Tasks: System Console Control Tower (Spec 114)
|
||||
|
||||
**Input**: Design documents from `specs/114-system-console-control-tower/`
|
||||
|
||||
**Docs used**:
|
||||
- `specs/114-system-console-control-tower/spec.md`
|
||||
- `specs/114-system-console-control-tower/plan.md`
|
||||
- `specs/114-system-console-control-tower/research.md`
|
||||
- `specs/114-system-console-control-tower/data-model.md`
|
||||
- `specs/114-system-console-control-tower/contracts/system-console-control-tower.openapi.yaml`
|
||||
- `specs/114-system-console-control-tower/quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) for runtime behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Structure)
|
||||
|
||||
- [X] T001 Review existing System panel primitives in `app/Providers/Filament/SystemPanelProvider.php`, System auth/security primitives in `app/Filament/System/Pages/Auth/Login.php` + `app/Services/Auth/BreakGlassSession.php`, and System tests in `tests/Feature/System/Spec113/` + `tests/Feature/Auth/BreakGlassModeTest.php` + `tests/Feature/System/OpsRunbooks/` (confirm session isolation cookie middleware, login throttling + audit trail, break-glass banner/audits, 404/403 semantics, and existing Ops-UX start-surface contract patterns)
|
||||
- [X] T002 [P] Create new System page namespaces for Spec 114 in `app/Filament/System/Pages/Directory/` and `app/Filament/System/Pages/Security/`
|
||||
- [X] T003 [P] Create new System Blade view directories for Spec 114 in `resources/views/filament/system/pages/directory/` and `resources/views/filament/system/pages/security/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [X] T004 Extend platform capability registry with Spec 114 constants in `app/Support/Auth/PlatformCapabilities.php` (add `platform.console.view`, `platform.directory.view`, `platform.operations.view`, `platform.operations.manage`; keep existing constants for compatibility)
|
||||
- [X] T005 Update seeded platform operator capabilities in `database/seeders/PlatformUserSeeder.php` to include the new Spec 114 capabilities
|
||||
- [X] T006 Add stuck threshold defaults to `config/tenantpilot.php` under `system_console.stuck_thresholds.{queued_minutes,running_minutes}` (used by `/system/ops/stuck`)
|
||||
- [X] T007 [P] Implement a typed time-window helper in `app/Support/SystemConsole/SystemConsoleWindow.php` (allowed: `1h`, `24h` default, `7d`; provides start timestamp)
|
||||
- [X] T008 [P] Implement stuck run classification helper in `app/Support/SystemConsole/StuckRunClassifier.php` (DB-only query constraints for queued/running + thresholds)
|
||||
- [X] T009 Update System panel access regression tests in `tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` (if needed) to preserve the clarified rule: wrong guard / unauthenticated → 404; platform user missing page capability → 403
|
||||
- [X] T010 Add Spec 114 access semantics tests in `tests/Feature/System/Spec114/SystemConsoleAccessSemanticsTest.php` (assert 404 for tenant-guard requests across representative `/system/*` URLs and 403 for platform users missing required capabilities; also assert `/system` uses a distinct session cookie name from `/admin` to enforce SR-006)
|
||||
|
||||
**Checkpoint**: Capabilities/config/helpers/tests exist; user story work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Global Health & Triage Entry (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Control Tower KPIs + top offenders + quick clickthrough to a canonical run detail.
|
||||
|
||||
**Independent Test**: A platform user can open `/system` (dashboard), switch time window, see KPIs/top offenders, and open a run detail.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T011 [P] [US1] Add Control Tower access + window default tests in `tests/Feature/System/Spec114/ControlTowerDashboardTest.php`
|
||||
- [X] T012 [P] [US1] Add canonical run detail access + data-minimization tests in `tests/Feature/System/Spec114/CanonicalRunDetailTest.php` (assert SR-004 v1 behavior: no raw error/context drilldowns; only sanitized summaries render)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T013 [US1] Gate the dashboard with `platform.console.view` and add a time-window switcher to header actions in `app/Filament/System/Pages/Dashboard.php`
|
||||
- [X] T014 [P] [US1] Create KPIs widget in `app/Filament/System/Widgets/ControlTowerKpis.php` (DB-only aggregation on `operation_runs` within selected window)
|
||||
- [X] T015 [P] [US1] Create “Top offenders” widget in `app/Filament/System/Widgets/ControlTowerTopOffenders.php` (group failed runs by tenant/workspace/type within window)
|
||||
- [X] T016 [P] [US1] Create “Recently failed operations” widget in `app/Filament/System/Widgets/ControlTowerRecentFailures.php` (links to canonical run detail via `app/Support/System/SystemOperationRunLinks.php`)
|
||||
- [X] T017 [US1] Register Spec 114 widgets on the System dashboard in `app/Filament/System/Pages/Dashboard.php` (ensure all widget queries are DB-only)
|
||||
- [X] T018 [US1] Convert the System runs list to *global* runs (not runbook-only) in `app/Filament/System/Pages/Ops/Runs.php` and keep the table rendering in `resources/views/filament/system/pages/ops/runs.blade.php`
|
||||
- [X] T019 [US1] Make run detail canonical (remove runbook-only + platform-workspace-only constraints) and gate it with `platform.operations.view` in `app/Filament/System/Pages/Ops/ViewRun.php`
|
||||
- [X] T020 [US1] Generalize the run detail rendering to non-runbook runs in `resources/views/filament/system/pages/ops/view-run.blade.php` (keep sanitized failures + avoid leaking sensitive context by default)
|
||||
|
||||
**Checkpoint**: US1 is shippable and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Directory for Workspaces & Tenants (Priority: P2)
|
||||
|
||||
**Goal**: Provide cross-workspace directory pages with health signals and safe links into ops views.
|
||||
|
||||
**Independent Test**: A platform user can list workspaces/tenants, open details, and jump to filtered run listings without session bridging.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T021 [P] [US2] Add workspaces directory access + listing tests in `tests/Feature/System/Spec114/DirectoryWorkspacesTest.php`
|
||||
- [X] T022 [P] [US2] Add tenants directory access + listing tests in `tests/Feature/System/Spec114/DirectoryTenantsTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T023 [US2] Add a System health badge domain (OK/Warn/Critical/Unknown) in `app/Support/Badges/BadgeDomain.php`, map it in `app/Support/Badges/BadgeCatalog.php`, and implement its mapper in `app/Support/Badges/Domains/SystemHealthBadge.php`
|
||||
- [X] T024 [P] [US2] Add badge mapping semantics tests in `tests/Feature/Badges/SystemHealthBadgeSemanticsTest.php`
|
||||
- [X] T025 [P] [US2] Add directory URL helpers in `app/Support/System/SystemDirectoryLinks.php` (workspaces/tenants index + detail URLs, plus safe “Open in /admin” URL-only links)
|
||||
- [X] T026 [US2] Implement Workspaces index page (table + filters) in `app/Filament/System/Pages/Directory/Workspaces.php` with view `resources/views/filament/system/pages/directory/workspaces.blade.php` (gate with `platform.directory.view`)
|
||||
- [X] T027 [US2] Implement Workspace detail page in `app/Filament/System/Pages/Directory/ViewWorkspace.php` with view `resources/views/filament/system/pages/directory/view-workspace.blade.php` (tenants summary + recent ops links)
|
||||
- [X] T028 [US2] Implement Tenants index page in `app/Filament/System/Pages/Directory/Tenants.php` with view `resources/views/filament/system/pages/directory/tenants.blade.php`
|
||||
- [X] T029 [US2] Implement Tenant detail page in `app/Filament/System/Pages/Directory/ViewTenant.php` with view `resources/views/filament/system/pages/directory/view-tenant.blade.php` (connectivity/permission signals + recent ops)
|
||||
- [X] T030 [US2] Ensure any “Open in /admin” links remain URL-only (no auto-login, no session bridging) in `resources/views/filament/system/pages/directory/view-workspace.blade.php` and `resources/views/filament/system/pages/directory/view-tenant.blade.php`
|
||||
|
||||
**Checkpoint**: Directory is usable and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Operations Triage Actions & Auditability (Priority: P3)
|
||||
|
||||
**Goal**: Provide failures/stuck/access-log surfaces plus safe triage actions with confirmation and audit trails.
|
||||
|
||||
**Independent Test**: A view-only platform user can inspect but cannot mutate; a manage-capable user can perform a supported triage action and an audit log entry is written.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T031 [P] [US3] Add failures view access + prefilter tests in `tests/Feature/System/Spec114/OpsFailuresViewTest.php`
|
||||
- [X] T032 [P] [US3] Add stuck view access + stuck classification boundary tests in `tests/Feature/System/Spec114/OpsStuckViewTest.php`
|
||||
- [X] T033 [P] [US3] Add access logs filtering tests in `tests/Feature/System/Spec114/AccessLogsTest.php` (assert `platform.auth.login` includes both success + failure events and includes `platform.break_glass.*` actions)
|
||||
- [X] T034 [P] [US3] Add triage action authorization + audit-write tests in `tests/Feature/System/Spec114/OpsTriageActionsTest.php` (include an Ops-UX contract regression assertion for any triage action that queues work: intent-only toast + working “View run” link + no queued database notifications, mirroring `tests/Feature/System/OpsRunbooks/`)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T035 [US3] Implement failures page in `app/Filament/System/Pages/Ops/Failures.php` and view `resources/views/filament/system/pages/ops/failures.blade.php` (prefilter failed runs; gate with `platform.operations.view`)
|
||||
- [X] T036 [US3] Implement stuck page in `app/Filament/System/Pages/Ops/Stuck.php` and view `resources/views/filament/system/pages/ops/stuck.blade.php` (use `app/Support/SystemConsole/StuckRunClassifier.php`; gate with `platform.operations.view`)
|
||||
- [X] T037 [US3] Implement access logs page in `app/Filament/System/Pages/Security/AccessLogs.php` and view `resources/views/filament/system/pages/security/access-logs.blade.php` (AuditLog list scoped to `platform.auth.login` + `platform.break_glass.*`; gate with `platform.console.view`)
|
||||
- [X] T038 [US3] Implement triage policy + execution in `app/Services/SystemConsole/OperationRunTriageService.php` (define retryable/cancelable allowlist by operation type; “mark investigated” requires reason and writes audit)
|
||||
- [X] T039 [US3] Implement system-console audit logging helper in `app/Services/SystemConsole/SystemConsoleAuditLogger.php` (wrap `app/Services/Intune/AuditLogger.php` using the `platform` tenant; stable action IDs; includes break-glass marker)
|
||||
- [X] T040 [US3] Add manage-only Filament actions (Retry/Cancel/Mark investigated) to run tables and run detail in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, and `app/Filament/System/Pages/Ops/ViewRun.php` (all mutations use `->action(...)` + `->requiresConfirmation()`, “Mark investigated” includes a required reason field)
|
||||
|
||||
**Checkpoint**: All Spec 114 operator actions are capability-gated, confirmed, and audited.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T041 [P] Run code formatting on touched files via `vendor/bin/sail` (use `vendor/bin/sail bin pint --dirty --format agent`)
|
||||
- [X] T042 Run Spec 114 focused tests via `vendor/bin/sail` in `tests/Feature/System/Spec114/`
|
||||
- [X] T043 Validate quickstart steps remain accurate in `specs/114-system-console-control-tower/quickstart.md` (adjust if needed)
|
||||
- [X] T044 [P] Optional performance follow-up: add indexes for windowed queries in `database/migrations/` (only if needed after measuring/explain plans; deferred for now because current EXPLAIN baselines do not indicate index pressure at present data volumes)
|
||||
- [X] T045 [P] Performance validation: capture a baseline for the primary list pages (dashboard widgets, `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck`, directory lists) and only then decide whether T044 is needed
|
||||
- [X] T046 Confirm Runbook navigation/shortcuts satisfy FR-007: System navigation provides Runbooks entry, and the canonical run detail exposes a “Go to runbooks” affordance (or explicitly documents “coming soon” where applicable)
|
||||
- [X] T047 Explicitly document v1 scope decisions in tasks acceptance notes: Export (FR-009) is deferred; raw error/context drilldowns (SR-004) are not present in v1
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Enterprise UI Polish
|
||||
|
||||
**Goal**: Elevate the System Console from functional to enterprise-grade: richer page content, contextual badges in navigation, visual hierarchy for break-glass actions, and visible audit trails.
|
||||
|
||||
- [X] T048 [P] [Polish] Add stats overview widget to Recovery > Repair Workspace Owners page (`app/Filament/System/Pages/Ops/RepairWorkspaceOwners.php`): show "X healthy | Y ownerless | Z stuck" counts above the purpose box
|
||||
- [X] T049 [P] [Polish] Add a Workspaces table to Repair Workspace Owners page listing workspaces with owner status (name, owner count, last activity, health badge) — currently the page is empty below the purpose box
|
||||
- [X] T050 [Polish] Restyle the "Assign owner (break-glass)" button: use `->icon('heroicon-o-shield-exclamation')` + `->color('danger')` with better label "Emergency: Assign Owner" to distinguish intentional danger-action from error-state appearance
|
||||
- [X] T051 [P] [Polish] Add navigation badge counts to Ops sidebar items (Failures, Stuck) showing live counts (e.g. "3" next to Failures, "1" next to Stuck) using `::getNavigationBadge()` + `::getNavigationBadgeColor()`
|
||||
- [X] T052 [P] [Polish] Add navigation badge to Recovery > Repair Workspace Owners showing count of ownerless workspaces
|
||||
- [X] T053 [Polish] Add "Recent break-glass actions" infolist/table to the Repair Workspace Owners page showing the last 10 audit log entries for `platform.break_glass.*` actions (who, when, what workspace)
|
||||
- [X] T054 [P] [Polish] Add a System Console health summary widget to the Dashboard (`app/Filament/System/Pages/Dashboard.php`) showing traffic-light indicator (green/yellow/red) based on failure + stuck counts
|
||||
- [X] T055 Run Pint on touched files via `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T056 Run Spec 114 focused tests via `vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
|
||||
P2 --> US1[Phase 3: US1 (MVP)]
|
||||
P2 --> US2[Phase 4: US2]
|
||||
P2 --> US3[Phase 5: US3]
|
||||
US1 --> POL[Phase 6: Polish]
|
||||
US2 --> POL
|
||||
US3 --> POL
|
||||
POL --> ENT[Phase 7: Enterprise UI Polish]
|
||||
```
|
||||
|
||||
- Phase 2 blocks all user stories.
|
||||
- US2 and US3 can proceed in parallel after Phase 2, but MVP should ship US1 first.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### User Story 1
|
||||
|
||||
- Parallel: T011 + T012 (tests)
|
||||
- Parallel: T014 + T015 + T016 (widgets)
|
||||
|
||||
### User Story 2
|
||||
|
||||
- Parallel: T021 + T022 (tests)
|
||||
- Parallel: T024 + T025 (badge semantics)
|
||||
|
||||
### User Story 3
|
||||
|
||||
- Parallel: T031 + T032 + T033 + T034 (tests)
|
||||
- Parallel: T035 + T036 + T037 (page scaffolds)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 only)
|
||||
|
||||
1) Complete Phase 1 + Phase 2
|
||||
2) Ship US1 (dashboard widgets + global runs + canonical run detail)
|
||||
3) Add US2 directory
|
||||
4) Add US3 triage pages/actions + access logs
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Notes (v1 Scope)
|
||||
|
||||
- FR-009 Export is explicitly deferred for v1.
|
||||
- SR-004 raw error/context drilldowns are intentionally not exposed in v1 run detail views.
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Baseline Operability & Alert Integration
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-28
|
||||
**Feature**: [specs/115-baseline-operability-alerts/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
|
||||
|
||||
- Validation complete.
|
||||
- The spec intentionally references internal platform concepts (capability registry, operation runs, admin UI action surfaces) because they are required by the repository’s constitution; it does not prescribe languages, external APIs, or framework implementation steps.
|
||||
@ -0,0 +1,70 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: TenantPilot Baseline Alert Events
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Domain contract for baseline-related alert events produced during alerts evaluation.
|
||||
This repo dispatches events internally (not a public HTTP API).
|
||||
paths: {}
|
||||
components:
|
||||
schemas:
|
||||
AlertEventBase:
|
||||
type: object
|
||||
required: [event_type, tenant_id, severity, fingerprint_key, title, body, metadata]
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
tenant_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
severity:
|
||||
type: string
|
||||
enum: [low, medium, high, critical]
|
||||
fingerprint_key:
|
||||
type: string
|
||||
minLength: 1
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
BaselineHighDriftEvent:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertEventBase'
|
||||
- type: object
|
||||
properties:
|
||||
event_type:
|
||||
const: baseline_high_drift
|
||||
metadata:
|
||||
type: object
|
||||
required: [finding_id, finding_fingerprint, change_type]
|
||||
properties:
|
||||
finding_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
finding_fingerprint:
|
||||
type: string
|
||||
minLength: 1
|
||||
change_type:
|
||||
type: string
|
||||
enum: [missing_policy, different_version, unexpected_policy]
|
||||
|
||||
BaselineCompareFailedEvent:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AlertEventBase'
|
||||
- type: object
|
||||
properties:
|
||||
event_type:
|
||||
const: baseline_compare_failed
|
||||
severity:
|
||||
const: high
|
||||
metadata:
|
||||
type: object
|
||||
required: [operation_run_id]
|
||||
properties:
|
||||
operation_run_id:
|
||||
type: integer
|
||||
minimum: 1
|
||||
63
specs/115-baseline-operability-alerts/data-model.md
Normal file
63
specs/115-baseline-operability-alerts/data-model.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Data Model — Baseline Operability & Alert Integration (Spec 115)
|
||||
|
||||
This spec extends existing models and introduces no new tables.
|
||||
|
||||
## Entities
|
||||
|
||||
### 1) Finding (existing: `App\Models\Finding`)
|
||||
Baseline compare findings are a subset of drift findings.
|
||||
|
||||
Key fields used/extended by this feature:
|
||||
- `workspace_id` (derived from tenant)
|
||||
- `tenant_id`
|
||||
- `finding_type` = `drift`
|
||||
- `source` = `baseline.compare` (stable contract)
|
||||
- `scope_key` = `baseline_profile:{baseline_profile_id}` (stable grouping)
|
||||
- `fingerprint` (stable identifier; used for idempotent upsert + alert dedupe)
|
||||
- `status` (lifecycle): `new`, `reopened`, other open states, and terminal states
|
||||
- `reopened_at`, `resolved_at`, `resolved_reason`
|
||||
- `severity` (`low|medium|high|critical`)
|
||||
- `evidence_jsonb` (must include at least `change_type`)
|
||||
- `current_operation_run_id` (the compare run that most recently observed the finding)
|
||||
|
||||
Lifecycle rules for baseline compare findings:
|
||||
- New fingerprint → create finding with `status=new`.
|
||||
- Existing finding in terminal state (at least `resolved`) observed again → set `status=reopened`, `reopened_at=now`, clear resolved fields.
|
||||
- Existing open finding observed again → do not override workflow status.
|
||||
- Stale open findings (not observed in a fully successful compare) → set `status=resolved`, `resolved_reason=no_longer_drifting`, `resolved_at=now`.
|
||||
|
||||
### 2) OperationRun (existing: `App\Models\OperationRun`)
|
||||
Baseline compare runs are represented as:
|
||||
- `type = baseline_compare`
|
||||
- `tenant_id` required (tenant-scoped operation)
|
||||
- `status/outcome` managed exclusively via `OperationRunService`
|
||||
- `summary_counts` used for:
|
||||
- completeness: `processed == total`
|
||||
- safety: `failed == 0`
|
||||
|
||||
Baseline capture runs are represented as:
|
||||
- `type = baseline_capture`
|
||||
|
||||
### 3) WorkspaceSetting (existing: `App\Models\WorkspaceSetting`)
|
||||
New workspace keys (domain `baseline`):
|
||||
- `baseline.severity_mapping` (json object)
|
||||
- Keys MUST be exactly: `missing_policy`, `different_version`, `unexpected_policy`
|
||||
- Values MUST be one of: `low|medium|high|critical`
|
||||
- `baseline.alert_min_severity` (string)
|
||||
- Allowed: `low|medium|high|critical`
|
||||
- Default: `high`
|
||||
- `baseline.auto_close_enabled` (bool)
|
||||
- Default: `true`
|
||||
|
||||
Effective value rules:
|
||||
- Consumers read via `SettingsResolver`, which merges system defaults with workspace overrides.
|
||||
|
||||
## Derived/Computed Values
|
||||
- Baseline finding severity is computed at creation time from `baseline.severity_mapping[change_type]`.
|
||||
- Baseline alert eligibility is computed at alert-evaluation time from:
|
||||
- finding `source` + `status` + timestamps vs `windowStart`
|
||||
- finding `severity` vs `baseline.alert_min_severity`
|
||||
|
||||
## Invariants
|
||||
- `Finding.source = baseline.compare` MUST be stable and queryable.
|
||||
- Auto-close MUST only execute if the compare run is complete (`processed==total`) and safe (`failed==0`) and `baseline.auto_close_enabled` is true.
|
||||
166
specs/115-baseline-operability-alerts/plan.md
Normal file
166
specs/115-baseline-operability-alerts/plan.md
Normal file
@ -0,0 +1,166 @@
|
||||
# Implementation Plan: Baseline Operability & Alert Integration (Spec 115)
|
||||
|
||||
**Branch**: `115-baseline-operability-alerts` | **Date**: 2026-02-28 | **Spec**: `specs/115-baseline-operability-alerts/spec.md`
|
||||
**Input**: Feature specification from `specs/115-baseline-operability-alerts/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
- Implement safe baseline finding auto-close after fully successful baseline compares.
|
||||
- Add baseline-specific alert events (`baseline_high_drift`, `baseline_compare_failed`) with precise new/reopened-only semantics and existing cooldown handling.
|
||||
- Introduce workspace settings for baseline severity mapping, minimum alert severity, and an auto-close kill-switch.
|
||||
- Normalize baseline run types via the canonical run type registry.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.x (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail
|
||||
**Storage**: PostgreSQL (Sail) + JSONB
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
|
||||
**Target Platform**: Web application
|
||||
**Project Type**: Laravel monolith
|
||||
**Performance Goals**: N/A (ops correctness + low-noise alerting)
|
||||
**Constraints**: Strict Ops-UX + RBAC-UX compliance; no extra Graph calls; Monitoring render is DB-only
|
||||
**Scale/Scope**: Workspace-scoped settings + tenant-scoped findings/runs
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS (baseline compare reads inventory as “last observed”, no snapshot semantics changed).
|
||||
- Read/write separation: PASS (auto-close is internal lifecycle management; no Graph writes introduced).
|
||||
- Graph contract path: PASS (no new Graph calls).
|
||||
- Deterministic capabilities: PASS (uses existing capability registry for any UI settings mutations).
|
||||
- RBAC-UX: PASS (workspace settings mutations already enforce membership 404 + capability 403 in `SettingsWriter`; tenant-context compare surfaces remain tenant-scoped).
|
||||
- Workspace & tenant isolation: PASS (all findings and runs remain workspace+tenant scoped; alert dispatch validates tenant belongs to workspace).
|
||||
- Run observability: PASS (baseline compare/capture already run via `OperationRunService`; alerts evaluation is an `OperationRun`).
|
||||
- Ops-UX 3-surface feedback: PASS (no new notification surfaces; uses existing Ops UX patterns).
|
||||
- Ops-UX lifecycle + summary counts + guards: PASS (all run transitions via `OperationRunService`; summary keys remain canonical).
|
||||
- Filament Action Surface Contract / UX-001: PASS (only adds fields/actions to existing pages; no new resources required).
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/115-baseline-operability-alerts/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── Settings/
|
||||
│ │ └── WorkspaceSettings.php
|
||||
│ └── Pages/
|
||||
│ └── BaselineCompareLanding.php
|
||||
├── Jobs/
|
||||
│ ├── CompareBaselineToTenantJob.php
|
||||
│ └── Alerts/
|
||||
│ └── EvaluateAlertsJob.php
|
||||
├── Models/
|
||||
│ ├── Finding.php
|
||||
│ ├── AlertRule.php
|
||||
│ └── WorkspaceSetting.php
|
||||
├── Services/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCompareService.php
|
||||
│ │ └── BaselineCaptureService.php
|
||||
│ ├── Alerts/
|
||||
│ │ ├── AlertDispatchService.php
|
||||
│ │ └── AlertFingerprintService.php
|
||||
│ └── Settings/
|
||||
│ ├── SettingsResolver.php
|
||||
│ └── SettingsWriter.php
|
||||
└── Support/
|
||||
├── OperationRunType.php
|
||||
└── Settings/
|
||||
└── SettingsRegistry.php
|
||||
|
||||
tests/
|
||||
└── Feature/
|
||||
├── Alerts/
|
||||
└── Baselines/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith with Filament admin UI. This feature touches Jobs, Services, Settings infrastructure, and adds/updates Pest feature tests.
|
||||
|
||||
## Phase 0 — Outline & Research (Complete)
|
||||
|
||||
Outputs:
|
||||
- `specs/115-baseline-operability-alerts/research.md`
|
||||
|
||||
Unknowns resolved:
|
||||
- Which summary counters can be used for completeness gating (reuse `total/processed/failed`).
|
||||
- How to implement reopen/resolve stale semantics without breaking workflow status.
|
||||
- How alert event dedupe/cooldown works and what keys are used.
|
||||
- How workspace settings are stored/validated and how effective values are resolved.
|
||||
|
||||
## Phase 1 — Design & Contracts (Complete)
|
||||
|
||||
Outputs:
|
||||
- `specs/115-baseline-operability-alerts/data-model.md`
|
||||
- `specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml`
|
||||
- `specs/115-baseline-operability-alerts/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- Baseline findings are a filtered subset of drift findings (`finding_type=drift`, `source=baseline.compare`).
|
||||
- Auto-close resolves stale baseline findings only when the compare run is complete and safe.
|
||||
- Baseline alert events are produced only for new/reopened baseline findings within the evaluation window.
|
||||
|
||||
## Constitution Re-check (Post-Design)
|
||||
|
||||
Result: PASS. No Graph calls added, no new authorization planes, and all `OperationRun` transitions remain service-owned.
|
||||
|
||||
## Phase 2 — Implementation Plan
|
||||
|
||||
1) Settings registry + UI
|
||||
- Add `baseline.severity_mapping`, `baseline.alert_min_severity`, `baseline.auto_close_enabled` to `SettingsRegistry` with strict validation.
|
||||
- Extend `WorkspaceSettings` Filament page to render and persist these settings using the existing `SettingsWriter`.
|
||||
|
||||
2) Canonical run types
|
||||
- Add `baseline_capture` and `baseline_compare` to `OperationRunType` enum and replace ad-hoc literals where touched in this feature.
|
||||
|
||||
3) Baseline compare finding lifecycle
|
||||
- Update `CompareBaselineToTenantJob` to:
|
||||
- apply baseline severity mapping by `change_type`.
|
||||
- preserve existing open finding workflow status.
|
||||
- mark previously resolved findings as `reopened` and set `reopened_at`.
|
||||
|
||||
4) Safe auto-close
|
||||
- At the end of `CompareBaselineToTenantJob`, if:
|
||||
- run outcome is `succeeded`, and
|
||||
- `summary_counts.processed == summary_counts.total`, and
|
||||
- `summary_counts.failed == 0`, and
|
||||
- `baseline.auto_close_enabled == true`
|
||||
then resolve stale open baseline findings (not in “seen set”) with reason `no_longer_drifting`.
|
||||
|
||||
5) Alerts integration
|
||||
- Extend `EvaluateAlertsJob` to produce:
|
||||
- `baseline_high_drift` (baseline findings only; new/reopened only; respects `baseline.alert_min_severity`).
|
||||
- `baseline_compare_failed` (baseline compare runs failed/`partially_succeeded`; dedupe by run id; cooldown via existing rules).
|
||||
- Register new event types in `AlertRule` and surface them in Filament `AlertRuleResource`.
|
||||
|
||||
6) Tests (Pest)
|
||||
- Add/extend Feature tests to cover:
|
||||
- auto-close executes only under the safe gate.
|
||||
- auto-close does not run on `partially_succeeded`/failed/incomplete compares.
|
||||
- reopened findings become `reopened` and trigger baseline drift alerts once.
|
||||
- baseline drift alerts do not trigger repeatedly for the same open finding.
|
||||
- baseline compare failed alerts trigger and are dedupe/cooldown compatible.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
42
specs/115-baseline-operability-alerts/quickstart.md
Normal file
42
specs/115-baseline-operability-alerts/quickstart.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Quickstart — Spec 115 (Baseline Operability & Alert Integration)
|
||||
|
||||
## Prereqs
|
||||
- Run the app via Sail.
|
||||
|
||||
## Local setup
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
|
||||
## How to exercise the feature (manual)
|
||||
|
||||
### 1) Ensure baseline compare can run
|
||||
- In Filament tenant-context, start a baseline compare (existing UI surface).
|
||||
- Confirm an `OperationRun` of type `baseline_compare` appears in Monitoring → Operations.
|
||||
|
||||
### 2) Verify auto-close safety gate
|
||||
- Create/open at least one baseline compare finding (`source = baseline.compare`) by running a compare with drift.
|
||||
- Remediate drift (or modify baseline/current so it no longer appears).
|
||||
- Run baseline compare again.
|
||||
- Expected:
|
||||
- If the run outcome is `succeeded` AND `summary_counts.processed == summary_counts.total` AND `summary_counts.failed == 0`, stale findings are resolved with `resolved_reason = no_longer_drifting`.
|
||||
- If the run fails/partial/incomplete, no findings are auto-resolved.
|
||||
|
||||
### 3) Verify baseline drift alert events
|
||||
- Ensure workspace settings are configured:
|
||||
- `baseline.severity_mapping` has the three required keys.
|
||||
- `baseline.alert_min_severity` is set (defaults to `high`).
|
||||
- Run baseline compare to create new/reopened baseline findings.
|
||||
- Trigger alerts evaluation:
|
||||
- `vendor/bin/sail artisan tenantpilot:dispatch-alerts --once`
|
||||
- Expected:
|
||||
- `baseline_high_drift` events are produced only for findings that are new/reopened within the evaluation window.
|
||||
- Repeat compares do not re-alert the same open finding.
|
||||
|
||||
### 4) Verify baseline compare failed alerts
|
||||
- Force a baseline compare to fail (e.g., by making required preconditions fail or simulating a job failure).
|
||||
- Run alerts evaluation again.
|
||||
- Expected: `baseline_compare_failed` event is produced, subject to the existing per-rule cooldown and quiet-hours suppression.
|
||||
|
||||
## Tests (Pest)
|
||||
- Run focused suite for this spec once implemented:
|
||||
- `vendor/bin/sail artisan test --compact --filter=BaselineOperability`
|
||||
- Or run specific test files under `tests/Feature/Alerts/` and `tests/Feature/Baselines/`.
|
||||
61
specs/115-baseline-operability-alerts/research.md
Normal file
61
specs/115-baseline-operability-alerts/research.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Research — Baseline Operability & Alert Integration (Spec 115)
|
||||
|
||||
This document resolves planning unknowns and records implementation decisions.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Completeness counters for safe auto-close
|
||||
- Decision: Treat compare “completeness counters” as `OperationRun.summary_counts.total`, `processed`, and `failed`.
|
||||
- Rationale: Ops-UX contracts already standardize these keys via `OperationSummaryKeys::all()`; they’re the metrics the UI understands for determinate progress.
|
||||
- Alternatives considered:
|
||||
- Add new keys like `total_count` / `processed_count` / `failed_item_count` → rejected because it would require expanding `OperationSummaryKeys::all()` and updating Ops-UX guard tests without a strong benefit.
|
||||
|
||||
### 2) Where auto-close runs
|
||||
- Decision: Perform auto-close at the end of `CompareBaselineToTenantJob` (after findings upsert), using the run’s computed “seen” fingerprint set.
|
||||
- Rationale: The job already has the full drift result set for the tenant+profile; it’s the only place that can reliably know what was evaluated.
|
||||
- Alternatives considered:
|
||||
- Separate queued job for auto-close → rejected (extra run coordination and more complex observability for no benefit).
|
||||
|
||||
### 3) Baseline finding lifecycle semantics (new vs reopened vs existing open)
|
||||
- Decision: Mirror the existing drift lifecycle behavior (as implemented in `DriftFindingGenerator`):
|
||||
- New fingerprint → `status = new`.
|
||||
- Previously terminal fingerprint (at least `resolved`) observed again → `status = reopened` and set `reopened_at`.
|
||||
- Existing open finding → do not overwrite workflow status (avoid resetting `triaged`/`in_progress`).
|
||||
- Rationale: This preserves operator workflow state and enables “alert only on new/reopened” logic.
|
||||
- Alternatives considered:
|
||||
- Always set `status = new` on every compare (current behavior) → rejected because it can overwrite workflow state.
|
||||
|
||||
### 4) Alert deduplication key for baseline drift
|
||||
- Decision: Set `fingerprint_key` to a stable string derived from the finding fingerprint (e.g. `finding_fingerprint:{fingerprint}`) for baseline drift events.
|
||||
- Rationale: Alert delivery dedupe uses `fingerprint_key` (or `idempotency_key`) via `AlertFingerprintService`.
|
||||
- Alternatives considered:
|
||||
- Use `finding:{id}` → rejected because it ties dedupe to a DB surrogate rather than the domain fingerprint.
|
||||
|
||||
### 5) Baseline-specific event types
|
||||
- Decision: Add two new alert event types and produce them in `EvaluateAlertsJob`:
|
||||
- `baseline_high_drift`: for baseline compare findings (`source = baseline.compare`) that are `new`/`reopened` in the evaluation window and meet severity threshold.
|
||||
- `baseline_compare_failed`: for `OperationRun.type = baseline_compare` with `outcome in {failed, partially_succeeded}` in the evaluation window.
|
||||
- Rationale: The spec requires strict separation from generic drift alerts and precise triggering rules.
|
||||
- Alternatives considered:
|
||||
- Reuse `high_drift` / `compare_failed` → rejected because it would mix baseline and non-baseline meaning.
|
||||
|
||||
### 6) Cooldown behavior for baseline_compare_failed
|
||||
- Decision: Reuse the existing per-rule cooldown + quiet-hours suppression implemented in `AlertDispatchService` (no baseline-specific cooldown setting).
|
||||
- Rationale: Matches spec clarification and existing patterns.
|
||||
|
||||
### 7) Workspace settings implementation approach
|
||||
- Decision: Implement baseline settings using the existing `SettingsRegistry`/`SettingsResolver`/`SettingsWriter` system with new keys under a new `baseline` domain:
|
||||
- `baseline.severity_mapping` (json map with restricted keys)
|
||||
- `baseline.alert_min_severity` (string)
|
||||
- `baseline.auto_close_enabled` (bool)
|
||||
- Rationale: This matches existing settings infrastructure and ensures consistent “effective value” semantics.
|
||||
|
||||
### 8) Information architecture (IA) and planes
|
||||
- Decision: Keep baseline profile CRUD as workspace-owned (non-tenant scoped) and baseline compare monitoring as tenant-context only.
|
||||
- Rationale: Matches SCOPE-001 and spec FR-018.
|
||||
|
||||
## Notes / Repo Facts Used
|
||||
- Ops-UX allowed summary keys are defined in `App\Support\OpsUx\OperationSummaryKeys`.
|
||||
- Drift lifecycle patterns exist in `App\Services\Drift\DriftFindingGenerator` (reopen + resolve stale).
|
||||
- Alert dispatch dedupe/cooldown/quiet-hours are centralized in `App\Services\Alerts\AlertDispatchService` and `AlertFingerprintService`.
|
||||
- Workspace settings are handled by `App\Support\Settings\SettingsRegistry` + `SettingsResolver` + `SettingsWriter`.
|
||||
230
specs/115-baseline-operability-alerts/spec.md
Normal file
230
specs/115-baseline-operability-alerts/spec.md
Normal file
@ -0,0 +1,230 @@
|
||||
# Feature Specification: Baseline Operability & Alert Integration (R1.1–R1.4 Extension)
|
||||
|
||||
**Feature Branch**: `115-baseline-operability-alerts`
|
||||
**Created**: 2026-02-28
|
||||
**Status**: Ready (Design complete; implementation pending)
|
||||
**Input**: User description: "115 — Baseline Operability & Alert Integration (R1.1–R1.4 Extension)"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace (management) + tenant (monitoring)
|
||||
- **Primary Routes**:
|
||||
- Workspace (admin): Baselines management (Baseline Profiles) and Workspace Settings
|
||||
- Tenant-context (admin): Baseline Compare monitoring and Baseline Compare “run now” surface
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned: Baseline profiles, baseline-to-tenant assignments, workspace settings, alert rules
|
||||
- Tenant-scoped (within a workspace): Findings produced by baseline compare; operation runs for tenant compares
|
||||
- **RBAC**:
|
||||
- Workspace Baselines (view/manage): workspace members must be granted the workspace baselines view/manage capabilities (from the canonical capability registry)
|
||||
- Workspace Settings (view/manage): workspace members must be granted the workspace settings view/manage capabilities (from the canonical capability registry)
|
||||
- Alerts (view/manage): workspace members must be granted the alerts view/manage capabilities (from the canonical capability registry)
|
||||
- Tenant monitoring surfaces require tenant access (tenant view) in addition to workspace membership
|
||||
|
||||
For canonical-view specs: not applicable (this is not a canonical-view feature).
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-28
|
||||
|
||||
- Q: What should `baseline.severity_mapping` map from? → A: Baseline drift `change_type` only (keys: `missing_policy`, `different_version`, `unexpected_policy`).
|
||||
- Q: What is the canonical “fully successful compare” gate for auto-close? → A: Outcome `succeeded` AND Ops-UX canonical `OperationRun.summary_counts` gate: `summary_counts.processed == summary_counts.total` AND `summary_counts.failed == 0`.
|
||||
- Q: When a previously resolved baseline finding reappears, what status should it transition to? → A: `reopened`.
|
||||
- Q: For `baseline_compare_failed` alerts, what cooldown behavior applies? → A: Use the existing dispatcher cooldown (no baseline-specific cooldown setting).
|
||||
- Q: Should `baseline.auto_close_enabled` exist as a kill-switch? → A: Yes; keep it with default `true`.
|
||||
|
||||
### Definitions
|
||||
|
||||
- **Baseline finding**: A drift finding produced by comparing a tenant against a baseline.
|
||||
- **Fingerprint**: A stable identifier for “the same underlying issue” across runs, used for idempotency and alert deduplication.
|
||||
- **Fully successful compare**: A compare run that succeeded and is complete (no failed items and all expected items processed).
|
||||
- Completeness is proven via Ops-UX canonical `OperationRun.summary_counts` counters: `summary_counts.processed == summary_counts.total` and `summary_counts.failed == 0`.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Safe auto-close removes stale baseline drift (Priority: P1)
|
||||
|
||||
As an MSP operator, I want baseline drift findings to automatically resolve when drift is no longer present, so the findings list remains actionable and doesn’t accumulate noise.
|
||||
|
||||
**Why this priority**: This is the main “operability” gap; without auto-close, drift remediation cannot be reliably observed, and alerting becomes noisy.
|
||||
|
||||
**Independent Test**: Can be fully tested by running a baseline compare that produces a “seen set” of fingerprints and verifying that previously-open baseline findings not present in the seen set are resolved only when the compare is fully successful.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an open baseline finding with `source = baseline.compare`, **When** a baseline compare completes fully successfully and that finding’s fingerprint is not present in the current compare result, **Then** the finding becomes `resolved` with reason `no_longer_drifting`.
|
||||
2. **Given** an open baseline finding with `source = baseline.compare`, **When** a baseline compare is `partially_succeeded` or failed (or incomplete), **Then** no baseline findings are auto-resolved.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Baseline alerts are precise and deduplicated (Priority: P1)
|
||||
|
||||
As an on-call operator, I want alerts for baseline drift and baseline compare failures to trigger only when there is new actionable work (new or reopened findings) or when a compare fails, so I don’t get spammed on every run.
|
||||
|
||||
**Why this priority**: MSP sellability depends on trust in alerts; repeated “same problem” alerts make alerting unusable.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating findings with controlled timestamps and statuses (new/reopened/open) and verifying that only new/reopened findings generate baseline drift alert events, while repeated compares do not.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a new high-severity baseline drift finding (deduped by fingerprint) created after the alert window start, **When** alerts are evaluated, **Then** a `baseline_high_drift` event is produced exactly once.
|
||||
2. **Given** a resolved baseline drift finding that later reappears and transitions to `reopened`, **When** alerts are evaluated, **Then** a `baseline_high_drift` event is produced again exactly once.
|
||||
3. **Given** a baseline compare run that completes with outcome failed or `partially_succeeded`, **When** alerts are evaluated, **Then** a `baseline_compare_failed` event is produced (deduped by run identity + cooldown).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Workspace-controlled severity mapping and alert threshold (Priority: P2)
|
||||
|
||||
As a workspace admin, I want to configure how baseline drift categories map to severity, and optionally the minimum severity that triggers baseline drift alerts, so the system matches the MSP’s operational standards.
|
||||
|
||||
**Why this priority**: Settings are required for enterprise adoption; hardcoded severity and alert thresholds don’t fit different environments.
|
||||
|
||||
**Independent Test**: Can be fully tested by setting workspace overrides and verifying that newly created baseline findings inherit the configured severity and that alert generation respects the configured minimum severity.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace-level baseline severity mapping override, **When** a new baseline drift finding is created, **Then** it uses the mapped severity (and rejects invalid severity values).
|
||||
2. **Given** a workspace-level baseline alert minimum severity override, **When** baseline findings are evaluated for alerts, **Then** only findings meeting or exceeding that threshold emit `baseline_high_drift` events.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Compare completes but is not “fully successful” (e.g., `summary_counts.failed > 0`, incomplete processing where `summary_counts.processed != summary_counts.total`, or compare preconditions prevent the run from being created): auto-close MUST NOT occur.
|
||||
- Compare does not evaluate all assigned items (e.g., missing baseline snapshot or assignment changes mid-run): auto-close MUST NOT resolve findings for items not evaluated.
|
||||
- A baseline finding was resolved previously and reappears later: it must transition to an actionable open state (e.g., `reopened`) and be eligible for alerting once.
|
||||
- Workspace settings payload is malformed (unknown drift categories or invalid severity values): save MUST be rejected and effective values MUST remain unchanged.
|
||||
|
||||
## 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 (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
|
||||
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
|
||||
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
|
||||
|
||||
**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.
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
|
||||
If UX-001 is not fully satisfied, the spec MUST include an explicit exemption with documented rationale.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Finding source contract)**: Findings created by baseline compare MUST be identifiable as baseline compare findings via a stable source identifier (`source = baseline.compare`).
|
||||
|
||||
- **FR-002 (Fully successful guardrail)**: Auto-close MUST run only after a fully successful baseline compare run. “Fully successful” means all of the following:
|
||||
- The compare run outcome is `succeeded`.
|
||||
- The compare run emits Ops-UX canonical completeness counters in `OperationRun.summary_counts`, and they indicate:
|
||||
- `summary_counts.failed == 0`
|
||||
- `summary_counts.processed == summary_counts.total`
|
||||
- Compare preconditions (e.g., missing active baseline snapshot, missing assignment) are enforced before enqueue and MUST prevent a compare run from being created; therefore auto-close cannot run when preconditions fail.
|
||||
- **Implementation note (required invariant)**: precondition failures are returned as stable reason codes and MUST result in **no `OperationRun` being created**.
|
||||
|
||||
- **FR-003 (Safe auto-close behavior)**: After a fully successful compare, the system MUST resolve open baseline compare findings that are not present in the current run’s seen set.
|
||||
- **FR-004 (No partial resolution)**: The system MUST NOT resolve findings for any items that were not evaluated in the run.
|
||||
- **FR-005 (Resolution reason)**: Auto-resolved findings MUST record resolution reason `no_longer_drifting`.
|
||||
|
||||
- **FR-006 (Reopen semantics)**: If a previously resolved baseline compare finding reappears in a later compare, it MUST transition to an actionable open state (e.g., `reopened`) and be treated as “new actionable work” for alerting.
|
||||
- The required open state for reappearance is `reopened`.
|
||||
|
||||
- **FR-007 (Alert event type: baseline drift)**: The system MUST support an alert event type `baseline_high_drift` for baseline compare findings.
|
||||
- **FR-008 (Alert producer: baseline drift)**: Baseline drift alert events MUST be produced only for baseline compare findings that are actionable (open states) AND are either newly created or newly reopened within the evaluation window.
|
||||
- **FR-009 (Baseline drift deduplication)**: Baseline drift alert events MUST be deduplicated by a stable key derived from the finding fingerprint. The same open finding MUST NOT emit repeated events on subsequent compares.
|
||||
|
||||
- **FR-010 (Alert event type: compare failed)**: The system MUST support an alert event type `baseline_compare_failed`.
|
||||
- **FR-011 (Alert producer: compare failed)**: A `baseline_compare_failed` event MUST be produced when a baseline compare run completes with outcome failed or `partially_succeeded`.
|
||||
- **FR-012 (Compare-failed dedup + cooldown)**: Compare-failed events MUST be deduplicated per run identity and MUST respect existing cooldown/quiet-hours behavior.
|
||||
- This feature MUST NOT introduce a baseline-specific cooldown interval; it reuses the existing dispatcher cooldown behavior.
|
||||
|
||||
- **FR-013 (Canonical run types)**: Baseline capture and baseline compare MUST use centrally defined canonical run types (`baseline_capture`, `baseline_compare`) and MUST NOT rely on ad-hoc string literals.
|
||||
|
||||
- **FR-014 (Workspace settings: severity mapping)**: The system MUST support a workspace setting `baseline.severity_mapping` that maps baseline drift categories to severity.
|
||||
- **FR-015 (Workspace settings: validation)**: The severity mapping MUST:
|
||||
- accept only the baseline drift `change_type` keys `missing_policy`, `different_version`, and `unexpected_policy` (no other keys),
|
||||
- reject invalid severity values,
|
||||
- and expose “effective value” behavior (system defaults + workspace overrides).
|
||||
- **FR-016 (Workspace settings: alert threshold)**: The system MUST support a workspace setting `baseline.alert_min_severity` with allowed values low/medium/high/critical and default high.
|
||||
- Severity threshold comparison MUST use the canonical severity ordering: `low < medium < high < critical` (inclusive).
|
||||
- **FR-017 (Workspace settings: auto-close toggle)**: The system MUST support a workspace setting `baseline.auto_close_enabled` defaulting to true.
|
||||
- When set to `false`, auto-close MUST be skipped even if the compare is fully successful.
|
||||
|
||||
- **FR-018 (Information architecture / ownership)**: Baseline Profile CRUD MUST remain workspace-owned. It MUST NOT appear tenant-scoped, must not show tenant scope banners, and must not be reachable from tenant-only navigation.
|
||||
|
||||
#### Assumptions & Dependencies
|
||||
|
||||
- Baseline compare already produces stable finding fingerprints and a per-run Ops-UX `summary_counts` payload that can express completeness (`processed` vs `total`) and failures (`failed`).
|
||||
- Findings support lifecycle transitions including resolve with a reason and reopen semantics for a recurring fingerprint.
|
||||
- Alert dispatch already supports deduplication, cooldown, and quiet hours; this feature reuses that behavior for new baseline-specific event types.
|
||||
|
||||
#### Constitution Alignment Notes (non-functional but mandatory)
|
||||
|
||||
- This feature adds no new Microsoft Graph calls.
|
||||
- Baseline compare and alert evaluation are long-running operations; any new auto-close and alert integration MUST preserve tenant isolation and run observability.
|
||||
|
||||
- **Ops-UX (3-surface feedback)**: baseline compare/capture and alerts evaluation must continue to provide:
|
||||
- an intent-only toast on start,
|
||||
- progress surfaces (Operations pages),
|
||||
- and terminal DB notifications where applicable.
|
||||
|
||||
- **Operation run ownership**: Operation status/outcome transitions are owned by the operations subsystem and must not be mutated directly by UI code.
|
||||
- **Summary counts contract**: Any summary counters produced/updated by this feature MUST use the canonical summary key registry and numeric-only values.
|
||||
- **Scheduled/system runs**: Runs initiated without a human initiator MUST not produce terminal DB notifications; monitoring remains via Operations/Alerts.
|
||||
|
||||
- **RBAC-UX**: Authorization planes involved:
|
||||
- Workspace management plane (admin, workspace-owned baselines + workspace settings)
|
||||
- Tenant-context plane (baseline compare monitoring)
|
||||
Cross-plane access MUST be deny-as-not-found (404).
|
||||
- Non-member or not entitled to the workspace scope or tenant scope → 404 (deny-as-not-found)
|
||||
- Member but missing capability for the surface → 403
|
||||
|
||||
- **BADGE-001**: Any new baseline severity mapping must remain centralized (single mapping source) and covered by tests.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Resource | Admin → Governance → Baselines (workspace management) | Create baseline profile (capability-gated) | View action | Edit (capability-gated), Delete/Archive (if present) | None | Create baseline profile | Capture / Compare shortcuts (if present), Edit | Save + Cancel | Yes | Workspace-owned; MUST NOT show tenant scope banner; must not appear in tenant nav. |
|
||||
| Page | Admin → Settings → Workspace settings | Save (capability-gated) | N/A | N/A | N/A | N/A | N/A | Save + Cancel (or equivalent) | Yes | Adds baseline settings fields; validation must reject malformed mapping. |
|
||||
| Page | Admin → Tenant context → Governance → Baseline Compare | Compare now (capability-gated) | Link to findings / operation run details | N/A | N/A | N/A | N/A | N/A | Yes | Tenant-context monitoring surface; must not expose workspace management actions. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Baseline Compare Finding**: A finding produced by a baseline compare run, identified by `source = baseline.compare` and a stable fingerprint.
|
||||
- **Baseline Compare Run**: A run that evaluates tenant configuration against a baseline profile and produces a compare summary that can indicate completeness.
|
||||
- **Alert Event**: A deduplicated, rule-dispatchable representation of actionable baseline drift or baseline compare failure.
|
||||
- **Workspace Baseline Settings**: Workspace-specific overrides for severity mapping, alert threshold, and auto-close enablement.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001 (Noise reduction)**: In a controlled test scenario where drift disappears, 100% of baseline drift findings created by baseline compare auto-resolve after the first fully successful compare.
|
||||
- **SC-002 (Safety)**: In scenarios where compare is failed/`partially_succeeded`/incomplete, 0 baseline findings are auto-resolved.
|
||||
- **SC-003 (Alert dedupe)**: The same open baseline drift finding does not generate more than 1 `baseline_high_drift` alert event per open/reopen cycle.
|
||||
- **SC-004 (Timeliness)**: Baseline compare failures generate a `baseline_compare_failed` alert event within the next alert evaluation cycle.
|
||||
- **SC-005 (Configurability)**: A workspace admin can change baseline severity mapping and minimum alert severity in under 2 minutes, and newly generated findings reflect the change.
|
||||
186
specs/115-baseline-operability-alerts/tasks.md
Normal file
186
specs/115-baseline-operability-alerts/tasks.md
Normal file
@ -0,0 +1,186 @@
|
||||
---
|
||||
|
||||
description: "Task list for Spec 115 implementation"
|
||||
---
|
||||
|
||||
# Tasks: Baseline Operability & Alert Integration (Spec 115)
|
||||
|
||||
**Input**: Design documents from `/specs/115-baseline-operability-alerts/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
**Tests**: REQUIRED (Pest) — this feature changes runtime behavior (Jobs/Services/Settings/UI).
|
||||
|
||||
**Operations (Ops-UX)**: This feature reuses `OperationRun`. Tasks must preserve the 3-surface feedback contract, keep all status/outcome transitions service-owned (via `OperationRunService`), and keep `summary_counts` numeric-only with keys from `OperationSummaryKeys::all()`.
|
||||
|
||||
**RBAC**: Any new/changed UI mutations or operation-start surfaces must use the capability registry (no raw strings) and preserve 404 vs 403 semantics (non-member/tenant-mismatch → 404; member missing capability → 403).
|
||||
|
||||
**Filament**: Any destructive-like actions must use `->requiresConfirmation()` and remain workspace vs tenant-plane correct (FR-018).
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
|
||||
|
||||
## Phase 1: Setup (Confirm Inputs)
|
||||
|
||||
- [X] T001 Confirm scope + priorities in specs/115-baseline-operability-alerts/spec.md
|
||||
- [X] T002 Confirm implementation sequencing and touched paths in specs/115-baseline-operability-alerts/plan.md
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared building blocks required by US1/US2/US3.
|
||||
|
||||
- [X] T003 Add baseline settings definitions + strict validation in app/Support/Settings/SettingsRegistry.php
|
||||
- [X] T004 [P] Add canonical run types for baseline operations in app/Support/OperationRunType.php
|
||||
- [X] T005 Update baseline run creation to use canonical run types in app/Services/Baselines/BaselineCompareService.php and app/Services/Baselines/BaselineCaptureService.php
|
||||
- [X] T006 [P] Add baseline compare precondition regression coverage ensuring unmet preconditions return `ok=false` and no `OperationRun` is created in tests/Feature/Baselines/BaselineComparePreconditionsTest.php
|
||||
|
||||
**Checkpoint**: Baseline settings keys exist with correct defaults; baseline run types are canonical and referenced from code.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Safe auto-close removes stale baseline drift (Priority: P1) 🎯
|
||||
|
||||
**Goal**: Resolve stale baseline drift findings only after a fully successful, complete compare, and never on `partially_succeeded`/failed/incomplete runs.
|
||||
|
||||
**Independent Test**: A compare that produces an initial seen fingerprint set, followed by a fully successful compare where those fingerprints are absent, resolves only the stale baseline findings.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T007 [P] [US1] Add auto-close safety gate coverage in tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php
|
||||
- [X] T008 [P] [US1] Extend lifecycle coverage (new vs reopened vs preserve open status) in tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T009 [US1] Add safe auto-close implementation in app/Services/Baselines/BaselineAutoCloseService.php
|
||||
- [X] T010 [US1] Preserve finding workflow state and implement reopen semantics in app/Jobs/CompareBaselineToTenantJob.php
|
||||
- [X] T011 [US1] Apply baseline severity mapping by change_type when upserting findings in app/Jobs/CompareBaselineToTenantJob.php
|
||||
- [X] T012 [US1] Wire auto-close into compare completion using the safe gate (outcome+safety+completeness+kill-switch) in app/Jobs/CompareBaselineToTenantJob.php and app/Services/Baselines/BaselineAutoCloseService.php
|
||||
|
||||
### Verification
|
||||
|
||||
- [X] T013 [US1] Run focused baseline tests: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php`
|
||||
|
||||
**Checkpoint**: Auto-close resolves only when safe; `partially_succeeded`/failed/incomplete compares never resolve baseline findings.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Baseline alerts are precise and deduplicated (Priority: P1)
|
||||
|
||||
**Goal**: Emit baseline alerts only for new/reopened baseline findings (within window), and for compare failures, with correct dedupe/cooldown.
|
||||
|
||||
**Independent Test**: Create baseline findings with controlled timestamps and statuses; evaluate alerts and ensure only new/reopened findings emit `baseline_high_drift` and only failed/`partially_succeeded` compares emit `baseline_compare_failed`.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T014 [P] [US2] Add baseline drift alert event coverage in tests/Feature/Alerts/BaselineHighDriftAlertTest.php
|
||||
- [X] T015 [P] [US2] Add baseline compare failed alert event coverage in tests/Feature/Alerts/BaselineCompareFailedAlertTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T016 [US2] Register baseline alert event constants in app/Models/AlertRule.php
|
||||
- [X] T017 [US2] Add baseline event types to the rule UI options/labels in app/Filament/Resources/AlertRuleResource.php
|
||||
- [X] T018 [US2] Produce baseline_high_drift events (baseline-only, new/reopened-only, severity threshold) in app/Jobs/Alerts/EvaluateAlertsJob.php
|
||||
- [X] T019 [US2] Produce baseline_compare_failed events for baseline compare runs with outcome failed/partially_succeeded in app/Jobs/Alerts/EvaluateAlertsJob.php
|
||||
- [X] T020 [US2] Ensure baseline drift event dedupe uses finding fingerprint (not numeric ID) in app/Jobs/Alerts/EvaluateAlertsJob.php
|
||||
|
||||
### Verification
|
||||
|
||||
- [X] T021 [US2] Run focused alert tests: `vendor/bin/sail artisan test --compact tests/Feature/Alerts/BaselineHighDriftAlertTest.php`
|
||||
- [X] T022 [US2] Run focused alert tests: `vendor/bin/sail artisan test --compact tests/Feature/Alerts/BaselineCompareFailedAlertTest.php`
|
||||
|
||||
**Checkpoint**: Alerts fire only on new/reopened baseline work; repeated compares do not re-alert the same open finding.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Workspace-controlled severity mapping and alert threshold (Priority: P2)
|
||||
|
||||
**Goal**: Workspace admins can configure baseline severity mapping, alert threshold, and auto-close enablement via Workspace Settings.
|
||||
|
||||
**Independent Test**: Saving workspace overrides updates effective values and affects newly created baseline findings and baseline alert eligibility.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T023 [P] [US3] Extend manage flow assertions for baseline settings in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php
|
||||
- [X] T024 [P] [US3] Extend read-only flow assertions for baseline settings in tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T025 [US3] Add baseline settings fields to the settings field map in app/Filament/Pages/Settings/WorkspaceSettings.php
|
||||
- [X] T026 [US3] Render a "Baseline settings" section (mapping + minimum severity + auto-close toggle) in app/Filament/Pages/Settings/WorkspaceSettings.php
|
||||
- [X] T027 [US3] Ensure save/reset uses SettingsWriter validation and records audit logs for baseline settings in app/Filament/Pages/Settings/WorkspaceSettings.php
|
||||
|
||||
### Verification
|
||||
|
||||
- [X] T028 [US3] Run focused settings tests: `vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php`
|
||||
|
||||
**Checkpoint**: Workspace overrides are validated strictly and reflected as effective settings.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T029 [P] Validate implementation matches the baseline alert event contract in specs/115-baseline-operability-alerts/contracts/baseline-alert-events.openapi.yaml and app/Jobs/Alerts/EvaluateAlertsJob.php
|
||||
- [X] T030 Validate the manual workflow remains accurate in specs/115-baseline-operability-alerts/quickstart.md
|
||||
- [X] T031 Run focused suites: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` and `vendor/bin/sail artisan test --compact tests/Feature/Alerts/`
|
||||
- [X] T032 [P] Add FR-018 regression coverage ensuring Baseline Profile CRUD remains workspace-owned (not reachable via tenant-context URLs and not present in tenant navigation) in tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Setup[Phase 1: Setup] --> Foundation[Phase 2: Foundational]
|
||||
Foundation --> US1[US1: Safe auto-close]
|
||||
Foundation --> US2[US2: Baseline alerts]
|
||||
Foundation --> US3[US3: Workspace baseline settings]
|
||||
US1 --> Polish[Phase 6: Polish]
|
||||
US2 --> Polish
|
||||
US3 --> Polish
|
||||
```
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies.
|
||||
- **Foundational (Phase 2)**: Blocks all user stories.
|
||||
- **US1 + US2 (Phase 3–4)**: Can proceed in parallel after Phase 2.
|
||||
- **US3 (Phase 5)**: Can proceed after Phase 2; does not block US1/US2 (defaults exist), but is required for workspace customization.
|
||||
- **Polish (Phase 6)**: After desired user stories complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)** depends on: baseline settings defaults (T003) and compare job wiring.
|
||||
- **US2 (P1)** depends on: baseline settings defaults (T003) and baseline compare run type (T004–T005).
|
||||
- **US3 (P2)** depends on: baseline settings definitions (T003) and Workspace Settings page wiring (T025–T027).
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
- Run in parallel:
|
||||
- T007 (auto-close tests) and T008 (lifecycle tests)
|
||||
- T009 (auto-close service) and T010/T011 (compare job lifecycle + severity mapping)
|
||||
|
||||
### US2
|
||||
|
||||
- Run in parallel:
|
||||
- T014 (baseline drift alert tests) and T015 (compare failed alert tests)
|
||||
- T016/T017 (rule model/UI constants) while T018–T020 (job event producers) are implemented
|
||||
|
||||
### US3
|
||||
|
||||
- Run in parallel:
|
||||
- T023 (manage tests) and T024 (view-only tests)
|
||||
- T025 (field map) and T026 (form section rendering)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
- **MVP**: Complete Phase 2 + Phase 3 (US1). This delivers operability via safe auto-close with defaults.
|
||||
- **Next**: Phase 4 (US2) to make alerting low-noise and actionable.
|
||||
- **Then**: Phase 5 (US3) to let workspaces tailor severity mapping and alert thresholds.
|
||||
185
tests/Feature/Alerts/BaselineCompareFailedAlertTest.php
Normal file
185
tests/Feature/Alerts/BaselineCompareFailedAlertTest.php
Normal file
@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Alerts\EvaluateAlertsJob;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: AlertRule, 1: AlertDestination}
|
||||
*/
|
||||
function createBaselineCompareFailedRuleWithDestination(int $workspaceId, int $cooldownSeconds = 0): array
|
||||
{
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'event_type' => 'baseline_compare_failed',
|
||||
'minimum_severity' => 'low',
|
||||
'is_enabled' => true,
|
||||
'cooldown_seconds' => $cooldownSeconds,
|
||||
]);
|
||||
|
||||
$rule->destinations()->attach($destination->getKey(), [
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
return [$rule, $destination];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function invokeBaselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$job = new EvaluateAlertsJob($workspaceId);
|
||||
$reflection = new ReflectionMethod($job, 'baselineCompareFailedEvents');
|
||||
|
||||
/** @var array<int, array<string, mixed>> $events */
|
||||
$events = $reflection->invoke($job, $workspaceId, $windowStart);
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
it('produces baseline compare failed events for failed and partially succeeded baseline compare runs', function (): void {
|
||||
$now = CarbonImmutable::parse('2026-02-28T12:00:00Z');
|
||||
CarbonImmutable::setTestNow($now);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
||||
$windowStart = $now->subHour();
|
||||
|
||||
$failedRun = OperationRun::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => $now->subMinutes(10),
|
||||
'failure_summary' => [
|
||||
['message' => 'The baseline compare failed.'],
|
||||
],
|
||||
]);
|
||||
|
||||
$partialRun = OperationRun::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'completed_at' => $now->subMinutes(5),
|
||||
'failure_summary' => [
|
||||
['message' => 'The baseline compare partially succeeded.'],
|
||||
],
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => $now->subMinutes(5),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'drift_generate_findings',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => $now->subMinutes(5),
|
||||
]);
|
||||
|
||||
$events = invokeBaselineCompareFailedEvents($workspaceId, $windowStart);
|
||||
|
||||
expect($events)->toHaveCount(2);
|
||||
|
||||
$eventsByRunId = collect($events)->keyBy(static fn (array $event): int => (int) $event['metadata']['operation_run_id']);
|
||||
|
||||
expect($eventsByRunId[$failedRun->getKey()])
|
||||
->toMatchArray([
|
||||
'event_type' => 'baseline_compare_failed',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => 'high',
|
||||
'fingerprint_key' => 'operation_run:'.$failedRun->getKey(),
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $failedRun->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
expect($eventsByRunId[$partialRun->getKey()])
|
||||
->toMatchArray([
|
||||
'event_type' => 'baseline_compare_failed',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => 'high',
|
||||
'fingerprint_key' => 'operation_run:'.$partialRun->getKey(),
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $partialRun->getKey(),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps baseline compare failed events compatible with dispatcher cooldown dedupe', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
||||
[$rule, $destination] = createBaselineCompareFailedRuleWithDestination($workspaceId, cooldownSeconds: 3600);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'event_type' => 'baseline_compare_failed',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => 'high',
|
||||
'fingerprint_key' => 'operation_run:'.$run->getKey(),
|
||||
'title' => 'Baseline compare failed',
|
||||
'body' => 'The baseline compare failed.',
|
||||
'metadata' => [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$workspace = Workspace::query()->findOrFail($workspaceId);
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
|
||||
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
|
||||
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
|
||||
|
||||
$deliveries = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('alert_rule_id', (int) $rule->getKey())
|
||||
->where('alert_destination_id', (int) $destination->getKey())
|
||||
->where('event_type', 'baseline_compare_failed')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
expect($deliveries)->toHaveCount(2);
|
||||
expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED);
|
||||
expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
||||
});
|
||||
211
tests/Feature/Alerts/BaselineHighDriftAlertTest.php
Normal file
211
tests/Feature/Alerts/BaselineHighDriftAlertTest.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Alerts\EvaluateAlertsJob;
|
||||
use App\Models\AlertDelivery;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: AlertRule, 1: AlertDestination}
|
||||
*/
|
||||
function createBaselineHighDriftRuleWithDestination(int $workspaceId, int $cooldownSeconds = 0): array
|
||||
{
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'event_type' => 'baseline_high_drift',
|
||||
'minimum_severity' => 'low',
|
||||
'is_enabled' => true,
|
||||
'cooldown_seconds' => $cooldownSeconds,
|
||||
]);
|
||||
|
||||
$rule->destinations()->attach($destination->getKey(), [
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
return [$rule, $destination];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function invokeBaselineHighDriftEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
||||
{
|
||||
$job = new EvaluateAlertsJob($workspaceId);
|
||||
$reflection = new ReflectionMethod($job, 'baselineHighDriftEvents');
|
||||
|
||||
/** @var array<int, array<string, mixed>> $events */
|
||||
$events = $reflection->invoke($job, $workspaceId, $windowStart);
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
it('produces baseline drift events only for new and reopened baseline findings that meet the workspace threshold', function (): void {
|
||||
$now = CarbonImmutable::parse('2026-02-28T12:00:00Z');
|
||||
CarbonImmutable::setTestNow($now);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
||||
$windowStart = $now->subHour();
|
||||
|
||||
WorkspaceSetting::query()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'domain' => 'baseline',
|
||||
'key' => 'alert_min_severity',
|
||||
'value' => Finding::SEVERITY_HIGH,
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$newFinding = Finding::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source' => 'baseline.compare',
|
||||
'fingerprint' => 'baseline-fingerprint-new',
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'created_at' => $now->subMinutes(10),
|
||||
'evidence_jsonb' => ['change_type' => 'missing_policy'],
|
||||
]);
|
||||
|
||||
$reopenedFinding = Finding::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source' => 'baseline.compare',
|
||||
'fingerprint' => 'baseline-fingerprint-reopened',
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
'reopened_at' => $now->subMinutes(5),
|
||||
'evidence_jsonb' => ['change_type' => 'different_version'],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source' => 'baseline.compare',
|
||||
'fingerprint' => 'baseline-too-old',
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'created_at' => $now->subDays(1),
|
||||
'evidence_jsonb' => ['change_type' => 'missing_policy'],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source' => 'baseline.compare',
|
||||
'fingerprint' => 'baseline-below-threshold',
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'created_at' => $now->subMinutes(5),
|
||||
'evidence_jsonb' => ['change_type' => 'unexpected_policy'],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source' => 'permission_check',
|
||||
'fingerprint' => 'not-baseline',
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'created_at' => $now->subMinutes(5),
|
||||
'evidence_jsonb' => ['change_type' => 'missing_policy'],
|
||||
]);
|
||||
|
||||
$events = invokeBaselineHighDriftEvents($workspaceId, $windowStart);
|
||||
|
||||
expect($events)->toHaveCount(2);
|
||||
|
||||
$eventsByFindingId = collect($events)->keyBy(static fn (array $event): int => (int) $event['metadata']['finding_id']);
|
||||
|
||||
expect($eventsByFindingId[$newFinding->getKey()])
|
||||
->toMatchArray([
|
||||
'event_type' => 'baseline_high_drift',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_CRITICAL,
|
||||
'fingerprint_key' => 'finding_fingerprint:baseline-fingerprint-new',
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $newFinding->getKey(),
|
||||
'finding_fingerprint' => 'baseline-fingerprint-new',
|
||||
'change_type' => 'missing_policy',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($eventsByFindingId[$reopenedFinding->getKey()])
|
||||
->toMatchArray([
|
||||
'event_type' => 'baseline_high_drift',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'fingerprint_key' => 'finding_fingerprint:baseline-fingerprint-reopened',
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $reopenedFinding->getKey(),
|
||||
'finding_fingerprint' => 'baseline-fingerprint-reopened',
|
||||
'change_type' => 'different_version',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the finding fingerprint for dedupe and remains cooldown compatible', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
||||
[$rule, $destination] = createBaselineHighDriftRuleWithDestination($workspaceId, cooldownSeconds: 3600);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source' => 'baseline.compare',
|
||||
'fingerprint' => 'stable-fingerprint-key',
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'evidence_jsonb' => ['change_type' => 'missing_policy'],
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'event_type' => 'baseline_high_drift',
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'fingerprint_key' => 'finding_fingerprint:stable-fingerprint-key',
|
||||
'title' => 'Baseline drift detected',
|
||||
'body' => 'A baseline finding was created.',
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'finding_fingerprint' => 'stable-fingerprint-key',
|
||||
'change_type' => 'missing_policy',
|
||||
],
|
||||
];
|
||||
|
||||
$workspace = Workspace::query()->findOrFail($workspaceId);
|
||||
$dispatchService = app(AlertDispatchService::class);
|
||||
|
||||
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
|
||||
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
|
||||
|
||||
$deliveries = AlertDelivery::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->where('alert_rule_id', (int) $rule->getKey())
|
||||
->where('alert_destination_id', (int) $destination->getKey())
|
||||
->where('event_type', 'baseline_high_drift')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
expect($deliveries)->toHaveCount(2);
|
||||
expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED);
|
||||
expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
||||
});
|
||||
37
tests/Feature/Badges/SystemHealthBadgeSemanticsTest.php
Normal file
37
tests/Feature/Badges/SystemHealthBadgeSemanticsTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps system health ok to an OK success badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'ok');
|
||||
|
||||
expect($spec->label)->toBe('OK');
|
||||
expect($spec->color)->toBe('success');
|
||||
expect($spec->icon)->toBe('heroicon-m-check-circle');
|
||||
});
|
||||
|
||||
it('maps system health warn to a Warn warning badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'warn');
|
||||
|
||||
expect($spec->label)->toBe('Warn');
|
||||
expect($spec->color)->toBe('warning');
|
||||
expect($spec->icon)->toBe('heroicon-m-exclamation-triangle');
|
||||
});
|
||||
|
||||
it('maps system health critical to a Critical danger badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'critical');
|
||||
|
||||
expect($spec->label)->toBe('Critical');
|
||||
expect($spec->color)->toBe('danger');
|
||||
expect($spec->icon)->toBe('heroicon-m-x-circle');
|
||||
});
|
||||
|
||||
it('maps unknown system health states to an Unknown badge', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::SystemHealth, 'not-a-state');
|
||||
|
||||
expect($spec->label)->toBe('Unknown');
|
||||
expect($spec->color)->toBe('gray');
|
||||
});
|
||||
@ -4,14 +4,20 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
// --- T041: Compare idempotent finding fingerprint tests ---
|
||||
|
||||
@ -69,7 +75,7 @@
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_compare',
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
@ -102,12 +108,203 @@
|
||||
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
|
||||
expect($findings->count())->toBe(3);
|
||||
|
||||
// Lifecycle v2 fields must be initialized for new findings.
|
||||
expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count());
|
||||
expect($findings->pluck('last_seen_at')->filter()->count())->toBe($findings->count());
|
||||
expect($findings->pluck('times_seen')->every(fn ($value) => (int) $value === 1))->toBeTrue();
|
||||
|
||||
$severities = $findings->pluck('severity')->sort()->values()->all();
|
||||
expect($severities)->toContain(Finding::SEVERITY_HIGH);
|
||||
expect($severities)->toContain(Finding::SEVERITY_MEDIUM);
|
||||
expect($severities)->toContain(Finding::SEVERITY_LOW);
|
||||
});
|
||||
|
||||
it('does not fail compare when baseline severity mapping setting is missing', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-a-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'content-a'),
|
||||
'meta_jsonb' => ['display_name' => 'Policy A'],
|
||||
]);
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-b-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'content-b'),
|
||||
'meta_jsonb' => ['display_name' => 'Policy B'],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'policy-a-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Policy A modified',
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$settingsResolver = mock(SettingsResolver::class);
|
||||
$settingsResolver
|
||||
->shouldReceive('resolveValue')
|
||||
->andThrow(new InvalidArgumentException('Unknown setting key: baseline.severity_mapping'));
|
||||
|
||||
$baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
settingsResolver: $settingsResolver,
|
||||
baselineAutoCloseService: $baselineAutoCloseService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->get();
|
||||
|
||||
// policyB missing (high), policyA different (medium) = 2 findings.
|
||||
expect($findings->count())->toBe(2);
|
||||
expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_HIGH);
|
||||
expect($findings->pluck('severity')->all())->toContain(Finding::SEVERITY_MEDIUM);
|
||||
});
|
||||
|
||||
it('treats inventory items not seen in latest inventory sync as missing', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'settings-catalog-policy-uuid',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'baseline_hash' => hash('sha256', 'content-a'),
|
||||
'meta_jsonb' => ['display_name' => 'Settings Catalog A'],
|
||||
]);
|
||||
|
||||
$olderInventoryRun = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
'context' => [
|
||||
'policy_types' => ['settingsCatalogPolicy'],
|
||||
'selection_hash' => 'older',
|
||||
],
|
||||
]);
|
||||
|
||||
// Inventory item exists, but it was NOT observed in the latest sync run.
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'settings-catalog-policy-uuid',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog A',
|
||||
'meta_jsonb' => ['etag' => 'abc'],
|
||||
'last_seen_operation_run_id' => (int) $olderInventoryRun->getKey(),
|
||||
'last_seen_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'policy_types' => ['settingsCatalogPolicy'],
|
||||
'selection_hash' => 'latest',
|
||||
],
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->get();
|
||||
|
||||
expect($findings->count())->toBe(1);
|
||||
expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy');
|
||||
});
|
||||
|
||||
it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -138,7 +335,7 @@
|
||||
// First run
|
||||
$run1 = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_compare',
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
@ -171,7 +368,7 @@
|
||||
|
||||
$run2 = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_compare',
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
@ -241,7 +438,7 @@
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_compare',
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
@ -274,6 +471,96 @@
|
||||
expect($findings)->toBe(0);
|
||||
});
|
||||
|
||||
it('does not create missing_policy findings for baseline snapshot items outside effective scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
$metaContent = ['policy_key' => 'value123'];
|
||||
$driftHasher = app(DriftHasher::class);
|
||||
$contentHash = $driftHasher->hashNormalized($metaContent);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'matching-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => $contentHash,
|
||||
'meta_jsonb' => ['display_name' => 'Matching Policy'],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'foundation-uuid',
|
||||
'policy_type' => 'notificationMessageTemplate',
|
||||
'baseline_hash' => hash('sha256', 'foundation-content'),
|
||||
'meta_jsonb' => ['display_name' => 'Foundation Template'],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'matching-uuid',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => $metaContent,
|
||||
'display_name' => 'Matching Policy',
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'foundation-uuid',
|
||||
'policy_type' => 'notificationMessageTemplate',
|
||||
'meta_jsonb' => ['some' => 'value'],
|
||||
'display_name' => 'Foundation Template',
|
||||
]);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$opService,
|
||||
);
|
||||
|
||||
$run = $run->fresh();
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['total'] ?? -1))->toBe(0);
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
expect(
|
||||
Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->where('scope_key', $scopeKey)
|
||||
->count()
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
// --- T042: Summary counts severity breakdown tests ---
|
||||
|
||||
it('writes severity breakdown in summary_counts', function () {
|
||||
@ -330,7 +617,7 @@
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_compare',
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
@ -384,7 +671,7 @@
|
||||
$opService = app(OperationRunService::class);
|
||||
$run = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'baseline_compare',
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
@ -412,3 +699,260 @@
|
||||
expect((int) $result['findings_total'])->toBe(1);
|
||||
expect((int) $result['findings_upserted'])->toBe(1);
|
||||
});
|
||||
|
||||
it('reopens a previously resolved baseline finding when the same drift reappears', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'policy-reappears',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-content'),
|
||||
'meta_jsonb' => ['display_name' => 'Policy Reappears'],
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
|
||||
$firstRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($firstRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->sole();
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subMinute(),
|
||||
'resolved_reason' => 'manually_resolved',
|
||||
])->save();
|
||||
|
||||
$firstRun->update(['completed_at' => now()->subMinute()]);
|
||||
|
||||
$secondRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($secondRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$finding->refresh();
|
||||
expect($finding->status)->toBe(Finding::STATUS_REOPENED);
|
||||
expect($finding->resolved_at)->toBeNull();
|
||||
expect($finding->resolved_reason)->toBeNull();
|
||||
expect($finding->reopened_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('preserves an existing open workflow status when the same baseline drift is seen again', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'triaged-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-content'),
|
||||
'meta_jsonb' => ['display_name' => 'Triaged Policy'],
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
|
||||
$firstRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($firstRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->sole();
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_TRIAGED,
|
||||
'triaged_at' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$firstRun->update(['completed_at' => now()->subMinute()]);
|
||||
|
||||
$secondRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($secondRun))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$finding->refresh();
|
||||
expect($finding->status)->toBe(Finding::STATUS_TRIAGED);
|
||||
expect($finding->current_operation_run_id)->toBe((int) $secondRun->getKey());
|
||||
});
|
||||
|
||||
it('applies the workspace baseline severity mapping by change type', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
WorkspaceSetting::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'domain' => 'baseline',
|
||||
'key' => 'severity_mapping',
|
||||
'value' => [
|
||||
'missing_policy' => Finding::SEVERITY_CRITICAL,
|
||||
'different_version' => Finding::SEVERITY_LOW,
|
||||
'unexpected_policy' => Finding::SEVERITY_MEDIUM,
|
||||
],
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'missing-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-a'),
|
||||
'meta_jsonb' => ['display_name' => 'Missing Policy'],
|
||||
]);
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $snapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'different-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-b'),
|
||||
'meta_jsonb' => ['display_name' => 'Different Policy'],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'different-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['different_content' => true],
|
||||
'display_name' => 'Different Policy',
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'external_id' => 'unexpected-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'meta_jsonb' => ['unexpected' => true],
|
||||
'display_name' => 'Unexpected Policy',
|
||||
]);
|
||||
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
$run = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new CompareBaselineToTenantJob($run))->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->get()
|
||||
->keyBy(fn (Finding $finding): string => (string) data_get($finding->evidence_jsonb, 'change_type'));
|
||||
|
||||
expect($findings['missing_policy']->severity)->toBe(Finding::SEVERITY_CRITICAL);
|
||||
expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW);
|
||||
expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM);
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
// --- T040: Compare precondition 422 tests ---
|
||||
@ -23,7 +24,7 @@
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects compare when assigned profile is in draft status', function () {
|
||||
@ -49,7 +50,7 @@
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects compare when assigned profile is archived [EC-001]', function () {
|
||||
@ -74,6 +75,7 @@
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects compare when profile has no active snapshot', function () {
|
||||
@ -99,6 +101,7 @@
|
||||
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT);
|
||||
|
||||
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('enqueues compare successfully when all preconditions are met', function () {
|
||||
@ -132,7 +135,7 @@
|
||||
|
||||
/** @var OperationRun $run */
|
||||
$run = $result['run'];
|
||||
expect($run->type)->toBe('baseline_compare');
|
||||
expect($run->type)->toBe(OperationRunType::BaselineCompare->value);
|
||||
expect($run->status)->toBe('queued');
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
@ -174,5 +177,5 @@
|
||||
expect($result1['ok'])->toBeTrue();
|
||||
expect($result2['ok'])->toBeTrue();
|
||||
expect($result1['run']->getKey())->toBe($result2['run']->getKey());
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(1);
|
||||
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(1);
|
||||
});
|
||||
|
||||
274
tests/Feature/Baselines/BaselineCompareStatsTest.php
Normal file
274
tests/Feature/Baselines/BaselineCompareStatsTest.php
Normal file
@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
|
||||
it('returns no_tenant state when tenant is null', function (): void {
|
||||
$stats = BaselineCompareStats::forTenant(null);
|
||||
|
||||
expect($stats->state)->toBe('no_tenant')
|
||||
->and($stats->message)->toContain('No tenant');
|
||||
});
|
||||
|
||||
it('returns no_assignment state when tenant has no baseline assignment', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('no_assignment')
|
||||
->and($stats->profileName)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns no_snapshot state when profile has no active snapshot', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'active_snapshot_id' => null,
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('no_snapshot')
|
||||
->and($stats->profileName)->toBe($profile->name);
|
||||
});
|
||||
|
||||
it('returns comparing state when a run is queued', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('comparing')
|
||||
->and($stats->operationRunId)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('returns failed state when the latest run has failed outcome', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'failed',
|
||||
'failure_summary' => ['message' => 'Graph API timeout'],
|
||||
'completed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('failed')
|
||||
->and($stats->failureReason)->toBe('Graph API timeout')
|
||||
->and($stats->operationRunId)->not->toBeNull()
|
||||
->and($stats->lastComparedHuman)->not->toBeNull()
|
||||
->and($stats->lastComparedIso)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('returns ready state with grouped severity counts', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
Finding::factory()->count(2)->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'severity' => Finding::SEVERITY_LOW,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
// Terminal finding should not be counted in "open" drift totals.
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'completed_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('ready')
|
||||
->and($stats->findingsCount)->toBe(4)
|
||||
->and($stats->severityCounts)->toBe([
|
||||
'high' => 2,
|
||||
'medium' => 1,
|
||||
'low' => 1,
|
||||
])
|
||||
->and($stats->lastComparedHuman)->not->toBeNull()
|
||||
->and($stats->lastComparedIso)->toContain('T');
|
||||
});
|
||||
|
||||
it('returns idle state when profile is ready but no run exists yet', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->state)->toBe('idle')
|
||||
->and($stats->profileName)->toBe($profile->name)
|
||||
->and($stats->snapshotId)->toBe((int) $snapshot->getKey());
|
||||
});
|
||||
|
||||
it('forWidget returns grouped severity counts for new findings only', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
// New finding (should be counted)
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
// Resolved finding (should NOT be counted)
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => $scopeKey,
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forWidget($tenant);
|
||||
|
||||
expect($stats->findingsCount)->toBe(1)
|
||||
->and($stats->severityCounts['high'])->toBe(1);
|
||||
});
|
||||
188
tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php
Normal file
188
tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php
Normal file
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\CompareBaselineToTenantJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceSetting;
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: Tenant, 2: BaselineProfile, 3: BaselineSnapshot}
|
||||
*/
|
||||
function createBaselineOperabilityFixture(): array
|
||||
{
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
|
||||
return [$user, $tenant, $profile, $snapshot];
|
||||
}
|
||||
|
||||
function runBaselineCompareForSnapshot(
|
||||
User $user,
|
||||
Tenant $tenant,
|
||||
BaselineProfile $profile,
|
||||
BaselineSnapshot $snapshot,
|
||||
): OperationRun {
|
||||
$operationRuns = app(OperationRunService::class);
|
||||
|
||||
$run = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::BaselineCompare->value,
|
||||
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
|
||||
context: [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$job = new CompareBaselineToTenantJob($run);
|
||||
$job->handle(
|
||||
app(DriftHasher::class),
|
||||
app(BaselineSnapshotIdentity::class),
|
||||
app(AuditLogger::class),
|
||||
$operationRuns,
|
||||
);
|
||||
|
||||
return $run->fresh();
|
||||
}
|
||||
|
||||
it('resolves stale baseline findings after a fully successful compare', function (): void {
|
||||
[$user, $tenant, $profile, $firstSnapshot] = createBaselineOperabilityFixture();
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => $firstSnapshot->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'stale-policy',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => hash('sha256', 'baseline-content'),
|
||||
'meta_jsonb' => ['display_name' => 'Stale Policy'],
|
||||
]);
|
||||
|
||||
$firstRun = runBaselineCompareForSnapshot($user, $tenant, $profile, $firstSnapshot);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->first();
|
||||
|
||||
expect($finding)->not->toBeNull();
|
||||
expect($finding?->status)->toBe(Finding::STATUS_NEW);
|
||||
|
||||
$secondSnapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'baseline_profile_id' => $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => $secondSnapshot->getKey()]);
|
||||
$firstRun->update(['completed_at' => now()->subMinute()]);
|
||||
|
||||
$secondRun = runBaselineCompareForSnapshot($user, $tenant, $profile, $secondSnapshot);
|
||||
|
||||
$finding->refresh();
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED);
|
||||
expect($finding->resolved_reason)->toBe('no_longer_drifting');
|
||||
expect($finding->resolved_at)->not->toBeNull();
|
||||
expect($finding->current_operation_run_id)->toBe((int) $secondRun->getKey());
|
||||
});
|
||||
|
||||
dataset('baseline auto close safety gates', [
|
||||
'safe and enabled' => [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
['total' => 2, 'processed' => 2, 'failed' => 0],
|
||||
null,
|
||||
true,
|
||||
],
|
||||
'disabled by workspace setting' => [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
['total' => 2, 'processed' => 2, 'failed' => 0],
|
||||
false,
|
||||
false,
|
||||
],
|
||||
'partially succeeded outcome' => [
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
['total' => 2, 'processed' => 2, 'failed' => 0],
|
||||
null,
|
||||
false,
|
||||
],
|
||||
'failed outcome' => [
|
||||
OperationRunOutcome::Failed->value,
|
||||
['total' => 2, 'processed' => 2, 'failed' => 0],
|
||||
null,
|
||||
false,
|
||||
],
|
||||
'incomplete processed count' => [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
['total' => 2, 'processed' => 1, 'failed' => 0],
|
||||
null,
|
||||
false,
|
||||
],
|
||||
'failed work recorded' => [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
['total' => 2, 'processed' => 2, 'failed' => 1],
|
||||
null,
|
||||
false,
|
||||
],
|
||||
'missing counters' => [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
['processed' => 2, 'failed' => 0],
|
||||
null,
|
||||
false,
|
||||
],
|
||||
]);
|
||||
|
||||
it('gates auto close on outcome completion counts and workspace setting', function (
|
||||
string $outcome,
|
||||
array $summaryCounts,
|
||||
?bool $settingValue,
|
||||
bool $expected,
|
||||
): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
if ($settingValue !== null) {
|
||||
WorkspaceSetting::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'domain' => 'baseline',
|
||||
'key' => 'auto_close_enabled',
|
||||
'value' => $settingValue,
|
||||
'updated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => $outcome,
|
||||
'summary_counts' => $summaryCounts,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
expect(app(BaselineAutoCloseService::class)->shouldAutoClose($tenant, $run))->toBe($expected);
|
||||
})->with('baseline auto close safety gates');
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void {
|
||||
$tenantPanelResources = Filament::getPanel('tenant')->getResources();
|
||||
|
||||
expect($tenantPanelResources)->not->toContain(BaselineProfileResource::class);
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertOk()
|
||||
->assertDontSee("/admin/t/{$tenant->external_id}/baseline-profiles", false)
|
||||
->assertDontSee('>Baselines</span>', false);
|
||||
});
|
||||
|
||||
it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$workspaceUrl = BaselineProfileResource::getUrl(panel: 'admin');
|
||||
|
||||
expect($workspaceUrl)->toContain('/admin/baseline-profiles');
|
||||
expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles");
|
||||
|
||||
$this->get($workspaceUrl)->assertOk();
|
||||
$this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound();
|
||||
});
|
||||
@ -53,3 +53,15 @@
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run?->status)->toBe('queued');
|
||||
});
|
||||
|
||||
it('can refresh stats without calling mount directly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->call('refreshStats')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
@ -47,6 +47,11 @@ function workspaceManagerUser(): array
|
||||
->assertSet('data.backup_retention_keep_last_default', null)
|
||||
->assertSet('data.backup_retention_min_floor', null)
|
||||
->assertSet('data.drift_severity_mapping', [])
|
||||
->assertSet('data.baseline_severity_missing_policy', null)
|
||||
->assertSet('data.baseline_severity_different_version', null)
|
||||
->assertSet('data.baseline_severity_unexpected_policy', null)
|
||||
->assertSet('data.baseline_alert_min_severity', null)
|
||||
->assertSet('data.baseline_auto_close_enabled', null)
|
||||
->assertSet('data.findings_sla_critical', null)
|
||||
->assertSet('data.findings_sla_high', null)
|
||||
->assertSet('data.findings_sla_medium', null)
|
||||
@ -56,6 +61,11 @@ function workspaceManagerUser(): array
|
||||
->set('data.backup_retention_keep_last_default', 55)
|
||||
->set('data.backup_retention_min_floor', 12)
|
||||
->set('data.drift_severity_mapping', ['drift' => 'critical'])
|
||||
->set('data.baseline_severity_missing_policy', 'critical')
|
||||
->set('data.baseline_severity_different_version', 'low')
|
||||
->set('data.baseline_severity_unexpected_policy', 'medium')
|
||||
->set('data.baseline_alert_min_severity', 'critical')
|
||||
->set('data.baseline_auto_close_enabled', '0')
|
||||
->set('data.findings_sla_critical', 2)
|
||||
->set('data.findings_sla_high', 5)
|
||||
->set('data.findings_sla_medium', 10)
|
||||
@ -66,6 +76,11 @@ function workspaceManagerUser(): array
|
||||
->assertHasNoErrors()
|
||||
->assertSet('data.backup_retention_keep_last_default', 55)
|
||||
->assertSet('data.backup_retention_min_floor', 12)
|
||||
->assertSet('data.baseline_severity_missing_policy', 'critical')
|
||||
->assertSet('data.baseline_severity_different_version', 'low')
|
||||
->assertSet('data.baseline_severity_unexpected_policy', 'medium')
|
||||
->assertSet('data.baseline_alert_min_severity', 'critical')
|
||||
->assertSet('data.baseline_auto_close_enabled', '0')
|
||||
->assertSet('data.findings_sla_critical', 2)
|
||||
->assertSet('data.findings_sla_high', 5)
|
||||
->assertSet('data.findings_sla_medium', 10)
|
||||
@ -88,6 +103,19 @@ function workspaceManagerUser(): array
|
||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping'))
|
||||
->toBe(['drift' => 'critical']);
|
||||
|
||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'severity_mapping'))
|
||||
->toBe([
|
||||
'different_version' => 'low',
|
||||
'missing_policy' => 'critical',
|
||||
'unexpected_policy' => 'medium',
|
||||
]);
|
||||
|
||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'alert_min_severity'))
|
||||
->toBe('critical');
|
||||
|
||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'baseline', 'auto_close_enabled'))
|
||||
->toBeFalse();
|
||||
|
||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'findings', 'sla_days'))
|
||||
->toBe([
|
||||
'critical' => 2,
|
||||
@ -258,6 +286,41 @@ function workspaceManagerUser(): array
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects malformed baseline settings values', function (): void {
|
||||
[$workspace, $user] = workspaceManagerUser();
|
||||
|
||||
$writer = app(SettingsWriter::class);
|
||||
|
||||
expect(fn () => $writer->updateWorkspaceSetting(
|
||||
actor: $user,
|
||||
workspace: $workspace,
|
||||
domain: 'baseline',
|
||||
key: 'severity_mapping',
|
||||
value: ['unknown_change_type' => 'high'],
|
||||
))->toThrow(ValidationException::class);
|
||||
|
||||
expect(fn () => $writer->updateWorkspaceSetting(
|
||||
actor: $user,
|
||||
workspace: $workspace,
|
||||
domain: 'baseline',
|
||||
key: 'severity_mapping',
|
||||
value: ['missing_policy' => 'urgent'],
|
||||
))->toThrow(ValidationException::class);
|
||||
|
||||
expect(fn () => $writer->updateWorkspaceSetting(
|
||||
actor: $user,
|
||||
workspace: $workspace,
|
||||
domain: 'baseline',
|
||||
key: 'alert_min_severity',
|
||||
value: 'urgent',
|
||||
))->toThrow(ValidationException::class);
|
||||
|
||||
expect(WorkspaceSetting::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->where('domain', 'baseline')
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('saves partial findings sla days without auto-filling unset severities', function (): void {
|
||||
[$workspace, $user] = workspaceManagerUser();
|
||||
|
||||
|
||||
@ -41,6 +41,11 @@
|
||||
->assertSet('data.backup_retention_keep_last_default', 27)
|
||||
->assertSet('data.backup_retention_min_floor', null)
|
||||
->assertSet('data.drift_severity_mapping', [])
|
||||
->assertSet('data.baseline_severity_missing_policy', null)
|
||||
->assertSet('data.baseline_severity_different_version', null)
|
||||
->assertSet('data.baseline_severity_unexpected_policy', null)
|
||||
->assertSet('data.baseline_alert_min_severity', null)
|
||||
->assertSet('data.baseline_auto_close_enabled', null)
|
||||
->assertSet('data.findings_sla_critical', null)
|
||||
->assertSet('data.findings_sla_high', null)
|
||||
->assertSet('data.findings_sla_medium', null)
|
||||
@ -55,6 +60,12 @@
|
||||
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
||||
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
||||
->assertFormComponentActionDisabled('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
||||
->assertActionVisible(TestAction::make('reset_baseline_severity_mapping')->schemaComponent('baseline_section'))
|
||||
->assertActionDisabled(TestAction::make('reset_baseline_severity_mapping')->schemaComponent('baseline_section'))
|
||||
->assertActionVisible(TestAction::make('reset_baseline_alert_min_severity')->schemaComponent('baseline_section'))
|
||||
->assertActionDisabled(TestAction::make('reset_baseline_alert_min_severity')->schemaComponent('baseline_section'))
|
||||
->assertActionVisible(TestAction::make('reset_baseline_auto_close_enabled')->schemaComponent('baseline_section'))
|
||||
->assertActionDisabled(TestAction::make('reset_baseline_auto_close_enabled')->schemaComponent('baseline_section'))
|
||||
->assertActionVisible(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section'))
|
||||
->assertActionDisabled(TestAction::make('reset_findings_sla_days')->schemaComponent('findings_section'))
|
||||
->assertFormComponentActionVisible('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
|
||||
|
||||
@ -9,30 +9,38 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 when a tenant session accesses the system panel', function () {
|
||||
it('returns 404 when a tenant session accesses system panel routes', function (string $url) {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)->get('/system/login')->assertNotFound();
|
||||
$this->actingAs($user)->get($url)->assertNotFound();
|
||||
})->with([
|
||||
'/system/login',
|
||||
'/system',
|
||||
'/system/ops/runbooks',
|
||||
'/system/ops/runs',
|
||||
]);
|
||||
|
||||
// Filament may switch the active guard within the test process,
|
||||
// so ensure the tenant session is set for each request we assert.
|
||||
$this->actingAs($user)->get('/system')->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 when a platform user lacks the required capability', function () {
|
||||
it('returns 403 when a platform user lacks the required capability on system pages', function (string $url) {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system')
|
||||
->get($url)
|
||||
->assertForbidden();
|
||||
});
|
||||
})->with([
|
||||
'/system',
|
||||
'/system/ops/runbooks',
|
||||
'/system/ops/runs',
|
||||
]);
|
||||
|
||||
it('returns 200 when a platform user has the required capability', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL],
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
@ -40,4 +48,3 @@
|
||||
->get('/system')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
|
||||
70
tests/Feature/System/Spec114/AccessLogsTest.php
Normal file
70
tests/Feature/System/Spec114/AccessLogsTest.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('lists platform auth access logs with success and failure statuses plus break-glass actions', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'external_id' => 'platform',
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.auth.login',
|
||||
'status' => 'success',
|
||||
'metadata' => ['attempted_email' => 'operator@tenantpilot.io'],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.auth.login',
|
||||
'status' => 'failure',
|
||||
'metadata' => ['attempted_email' => 'operator@tenantpilot.io'],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.break_glass.enter',
|
||||
'status' => 'success',
|
||||
'metadata' => ['reason' => 'Recovery'],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.unrelated.event',
|
||||
'status' => 'success',
|
||||
'metadata' => [],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/security/access-logs')
|
||||
->assertSuccessful()
|
||||
->assertSee('platform.auth.login')
|
||||
->assertSee('success')
|
||||
->assertSee('failure')
|
||||
->assertSee('platform.break_glass.enter')
|
||||
->assertDontSee('platform.unrelated.event');
|
||||
});
|
||||
58
tests/Feature/System/Spec114/CanonicalRunDetailTest.php
Normal file
58
tests/Feature/System/Spec114/CanonicalRunDetailTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('allows canonical run detail for non-runbook operation types with operations view capability', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertSuccessful()
|
||||
->assertSee('Run #'.(int) $run->getKey());
|
||||
});
|
||||
|
||||
it('does not render raw context payloads in canonical run detail', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'type' => 'inventory_sync',
|
||||
'context' => [
|
||||
'secret_token' => 'top-secret-token',
|
||||
'raw_error' => 'sensitive stack trace',
|
||||
],
|
||||
'failure_summary' => [
|
||||
['code' => 'operation.failed', 'message' => 'Job failed'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Context (raw)')
|
||||
->assertDontSee('top-secret-token')
|
||||
->assertDontSee('sensitive stack trace');
|
||||
});
|
||||
52
tests/Feature/System/Spec114/ControlTowerDashboardTest.php
Normal file
52
tests/Feature/System/Spec114/ControlTowerDashboardTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Dashboard;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
});
|
||||
|
||||
it('forbids system dashboard when platform.console.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('defaults dashboard to the 24h window and allows switching window', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::CONSOLE_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system')
|
||||
->assertSuccessful();
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSet('window', SystemConsoleWindow::LastDay)
|
||||
->callAction('set_window', data: [
|
||||
'window' => SystemConsoleWindow::LastWeek,
|
||||
])
|
||||
->assertSet('window', SystemConsoleWindow::LastWeek);
|
||||
});
|
||||
54
tests/Feature/System/Spec114/DirectoryTenantsTest.php
Normal file
54
tests/Feature/System/Spec114/DirectoryTenantsTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('forbids tenants directory when platform.directory.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/tenants')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('lists tenants in the system directory', function () {
|
||||
$workspace = Workspace::factory()->create(['name' => 'Directory Workspace']);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Contoso',
|
||||
'status' => Tenant::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Fabrikam',
|
||||
'status' => Tenant::STATUS_ONBOARDING,
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/tenants')
|
||||
->assertSuccessful()
|
||||
->assertSee('Contoso')
|
||||
->assertSee('Fabrikam');
|
||||
});
|
||||
53
tests/Feature/System/Spec114/DirectoryWorkspacesTest.php
Normal file
53
tests/Feature/System/Spec114/DirectoryWorkspacesTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('forbids workspaces directory when platform.directory.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/workspaces')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('lists workspaces in the system directory', function () {
|
||||
$workspaceA = Workspace::factory()->create(['name' => 'Alpha Workspace']);
|
||||
$workspaceB = Workspace::factory()->create(['name' => 'Bravo Workspace']);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspaceA->getKey(),
|
||||
'name' => 'Tenant A',
|
||||
]);
|
||||
|
||||
Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
'name' => 'Tenant B',
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::DIRECTORY_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/directory/workspaces')
|
||||
->assertSuccessful()
|
||||
->assertSee('Alpha Workspace')
|
||||
->assertSee('Bravo Workspace');
|
||||
});
|
||||
54
tests/Feature/System/Spec114/OpsFailuresViewTest.php
Normal file
54
tests/Feature/System/Spec114/OpsFailuresViewTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('forbids failures page when platform.operations.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/failures')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('prefilters failures page to failed runs', function () {
|
||||
$failedRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$succeededRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/failures')
|
||||
->assertSuccessful()
|
||||
->assertSee(SystemOperationRunLinks::view($failedRun))
|
||||
->assertDontSee(SystemOperationRunLinks::view($succeededRun));
|
||||
});
|
||||
73
tests/Feature/System/Spec114/OpsStuckViewTest.php
Normal file
73
tests/Feature/System/Spec114/OpsStuckViewTest.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function () {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('forbids stuck page when platform.operations.view is missing', function () {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/stuck')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('shows only queued/running runs that cross stuck thresholds', function () {
|
||||
config()->set('tenantpilot.system_console.stuck_thresholds.queued_minutes', 10);
|
||||
config()->set('tenantpilot.system_console.stuck_thresholds.running_minutes', 20);
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-27 10:00:00'));
|
||||
|
||||
$stuckQueued = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(30),
|
||||
'started_at' => null,
|
||||
]);
|
||||
|
||||
$stuckRunning = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(25),
|
||||
'started_at' => now()->subMinutes(21),
|
||||
]);
|
||||
|
||||
$freshQueued = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'started_at' => null,
|
||||
]);
|
||||
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get('/system/ops/stuck')
|
||||
->assertSuccessful()
|
||||
->assertSee('#'.(int) $stuckQueued->getKey())
|
||||
->assertSee('#'.(int) $stuckRunning->getKey())
|
||||
->assertDontSee('#'.(int) $freshQueued->getKey());
|
||||
});
|
||||
104
tests/Feature/System/Spec114/OpsTriageActionsTest.php
Normal file
104
tests/Feature/System/Spec114/OpsTriageActionsTest.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\System\Pages\Ops\Runs;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Filament::setCurrentPanel('system');
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
Tenant::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'external_id' => 'platform',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides triage actions for operators without platform.operations.manage', function () {
|
||||
$run = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$viewOnlyUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($viewOnlyUser, 'platform');
|
||||
|
||||
Livewire::test(Runs::class)
|
||||
->assertTableActionHidden('retry', $run)
|
||||
->assertTableActionHidden('cancel', $run)
|
||||
->assertTableActionHidden('mark_investigated', $run);
|
||||
});
|
||||
|
||||
it('allows manage operators to run triage actions with audit logs and queued-run ux contract', function () {
|
||||
NotificationFacade::fake();
|
||||
|
||||
$failedRun = OperationRun::factory()->create([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$manageUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
PlatformCapabilities::OPERATIONS_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($manageUser, 'platform');
|
||||
|
||||
Livewire::test(Runs::class)
|
||||
->callTableAction('retry', $failedRun)
|
||||
->assertHasNoTableActionErrors()
|
||||
->assertNotified('Inventory sync queued');
|
||||
|
||||
NotificationFacade::assertNothingSent();
|
||||
expect(DatabaseNotification::query()->count())->toBe(0);
|
||||
|
||||
$retriedRun = OperationRun::query()
|
||||
->whereKeyNot((int) $failedRun->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($retriedRun)->not->toBeNull();
|
||||
expect((string) $retriedRun?->status)->toBe(OperationRunStatus::Queued->value);
|
||||
expect((int) data_get($retriedRun?->context, 'triage.retry_of_run_id'))->toBe((int) $failedRun->getKey());
|
||||
|
||||
$this->get(SystemOperationRunLinks::view($retriedRun))
|
||||
->assertSuccessful()
|
||||
->assertSee('Run #'.(int) $retriedRun?->getKey());
|
||||
|
||||
Livewire::test(Runs::class)
|
||||
->callTableAction('mark_investigated', $failedRun, data: [
|
||||
'reason' => 'Checked by platform operations',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
expect(AuditLog::query()->where('action', 'platform.system_console.retry')->exists())->toBeTrue();
|
||||
expect(AuditLog::query()->where('action', 'platform.system_console.mark_investigated')->exists())->toBeTrue();
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns 404 for tenant-guard access to representative /system urls', function (string $url) {
|
||||
$tenantUser = User::factory()->create();
|
||||
|
||||
$this->actingAs($tenantUser)->get($url)->assertNotFound();
|
||||
})->with([
|
||||
'/system/login',
|
||||
'/system',
|
||||
'/system/ops/runbooks',
|
||||
'/system/ops/runs',
|
||||
]);
|
||||
|
||||
it('returns 403 for platform users missing required system page capabilities', function (string $url, array $capabilities) {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => $capabilities,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get($url)
|
||||
->assertForbidden();
|
||||
})->with([
|
||||
['/system', []],
|
||||
['/system/ops/runbooks', [PlatformCapabilities::ACCESS_SYSTEM_PANEL]],
|
||||
['/system/ops/runs', [PlatformCapabilities::ACCESS_SYSTEM_PANEL]],
|
||||
]);
|
||||
|
||||
it('uses a distinct session cookie name for /system versus /admin', function () {
|
||||
$systemCookieName = Str::slug((string) config('app.name', 'laravel')).'-system-session';
|
||||
$adminCookieName = (string) config('session.cookie');
|
||||
|
||||
expect($systemCookieName)->not->toBe($adminCookieName);
|
||||
|
||||
$this->get('/system/login')
|
||||
->assertSuccessful()
|
||||
->assertCookie($systemCookieName);
|
||||
|
||||
$this->get('/admin/login')->assertSuccessful();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user