085-tenant-operate-hub #103

Merged
ahmido merged 6 commits from 085-tenant-operate-hub into dev 2026-02-11 13:02:04 +00:00
179 changed files with 7036 additions and 1723 deletions

View File

@ -24,6 +24,9 @@ ## Active Technologies
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -43,9 +46,10 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4)
- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
- 085-tenant-operate-hub: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail
- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface`
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -2,10 +2,10 @@
namespace App\Console\Commands;
use App\Services\OperationRunService;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class TenantpilotDispatchDirectoryGroupsSync extends Command
{
@ -46,27 +46,37 @@ public function handle(): int
$skipped = 0;
foreach ($tenants as $tenant) {
$inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => $slotKey,
'status' => 'pending',
'initiator_user_id' => null,
'created_at' => $now,
'updated_at' => $now,
]);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'directory_groups.sync',
identityInputs: [
'selection_key' => $selectionKey,
'slot_key' => $slotKey,
],
context: [
'selection_key' => $selectionKey,
'slot_key' => $slotKey,
'trigger' => 'scheduled',
],
initiator: null,
);
if ($inserted === 1) {
$created++;
dispatch(new \App\Jobs\EntraGroupSyncJob(
tenantId: $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: $slotKey,
));
} else {
if (! $opRun->wasRecentlyCreated) {
$skipped++;
continue;
}
$created++;
dispatch(new \App\Jobs\EntraGroupSyncJob(
tenantId: $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: $slotKey,
runId: null,
operationRun: $opRun,
));
}
$this->info(sprintf(

View File

@ -4,7 +4,9 @@
namespace App\Filament\Pages\Monitoring;
use App\Support\OperateHub\OperateHubShell;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use UnitEnum;
@ -25,4 +27,15 @@ class Alerts extends Page
protected static ?string $title = 'Alerts';
protected string $view = 'filament.pages.monitoring.alerts';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_alerts',
returnActionName: 'operate_hub_return_alerts',
);
}
}

View File

@ -4,7 +4,9 @@
namespace App\Filament\Pages\Monitoring;
use App\Support\OperateHub\OperateHubShell;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use UnitEnum;
@ -25,4 +27,15 @@ class AuditLog extends Page
protected static ?string $title = 'Audit Log';
protected string $view = 'filament.pages.monitoring.audit-log';
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return app(OperateHubShell::class)->headerActions(
scopeActionName: 'operate_hub_scope_audit_log',
returnActionName: 'operate_hub_return_audit_log',
);
}
}

View File

@ -7,10 +7,14 @@
use App\Filament\Resources\OperationRunResource;
use App\Filament\Widgets\Operations\OperationsKpiHeader;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array
];
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$actions = [
Action::make('operate_hub_scope_operations')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
->label('Back to '.$activeTenant->name)
->icon('heroicon-o-arrow-left')
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
$actions[] = Action::make('operate_hub_show_all_tenants')
->label('Show all tenants')
->color('gray')
->action(function (): void {
Filament::setTenant(null, true);
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilter('tenant_id');
$this->redirect('/admin/operations');
});
}
return $actions;
}
public function updatedActiveTab(): void
{
$this->resetPage();
@ -61,6 +105,8 @@ public function table(Table $table): Table
->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$query = OperationRun::query()
->with('user')
->latest('id')
@ -71,6 +117,10 @@ public function table(Table $table): Table
->when(
! $workspaceId,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
$activeTenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
);
return $this->applyActiveTab($query);

View File

@ -9,17 +9,20 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
class TenantlessOperationRunViewer extends Page
{
use AuthorizesRequests;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
@ -37,16 +40,42 @@ class TenantlessOperationRunViewer extends Page
*/
protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$actions = [
Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
Action::make('operate_hub_scope_run_detail')
->label($operateHubShell->scopeLabel(request()))
->color('gray')
->url(fn (): string => isset($this->run)
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
: route('admin.operations.index')),
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
$actions[] = Action::make('operate_hub_show_all_operations')
->label('Show all operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
} else {
$actions[] = Action::make('operate_hub_back_to_operations')
->label('Back to Operations')
->color('gray')
->url(fn (): string => route('admin.operations.index'));
}
$actions[] = Action::make('refresh')
->label('Refresh')
->icon('heroicon-o-arrow-path')
->color('gray')
->url(fn (): string => isset($this->run)
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
: route('admin.operations.index'));
if (! isset($this->run)) {
return $actions;
}
@ -87,7 +116,7 @@ public function mount(OperationRun $run): void
abort(403);
}
Gate::forUser($user)->authorize('view', $run);
$this->authorize('view', $run);
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
}

View File

@ -347,7 +347,7 @@ public function content(Schema $schema): Schema
SchemaActions::make([
Action::make('wizardStartVerification')
->label('Start verification')
->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress')
->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive())
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
? null
@ -629,6 +629,10 @@ private function verificationStatus(): string
return 'in_progress';
}
if ($run->outcome === OperationRunOutcome::Blocked->value) {
return 'blocked';
}
if ($run->outcome === OperationRunOutcome::Succeeded->value) {
return 'ready';
}
@ -658,7 +662,7 @@ private function verificationStatus(): string
continue;
}
if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied'], true)) {
if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) {
return 'blocked';
}
}

View File

@ -4,10 +4,10 @@
use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Models\User;
use App\Rules\SupportedPolicyTypesRule;
@ -26,7 +26,6 @@
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Carbon\CarbonImmutable;
use DateTimeZone;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@ -50,7 +49,6 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@ -58,6 +56,10 @@
class BackupScheduleResource extends Resource
{
protected static ?string $model = BackupSchedule::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
@ -384,104 +386,38 @@ public static function table(Table $table): Table
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: [
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
],
initiator: $userModel
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Run already queued')
->body('This schedule already has a queued or running backup.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Run already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
context: [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
'trigger' => 'run_now',
],
initiator: $userModel,
);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $operationRun->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'operation_run_id' => $operationRun->getKey(),
'trigger' => 'run_now',
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -519,104 +455,38 @@ public static function table(Table $table): Table
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.retry',
inputs: [
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
],
initiator: $userModel
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Retry already queued')
->body('This schedule already has a queued or running retry.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
return;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
Notification::make()
->title('Retry already queued')
->body('Please wait a moment and try again.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($operationRun, $tenant)),
])
->send();
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
return;
}
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
context: [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
'trigger' => 'retry',
],
initiator: $userModel,
);
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $operationRun->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'operation_run_id' => $operationRun->getKey(),
'trigger' => 'retry',
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
@ -640,6 +510,7 @@ public static function table(Table $table): Table
DeleteAction::make()
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->destructive()
->apply(),
])->icon('heroicon-o-ellipsis-vertical'),
])
@ -674,96 +545,52 @@ public static function table(Table $table): Table
$bulkRun = null;
$createdRunIds = [];
$createdOperationRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun(
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: [
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
],
initiator: $user
context: [
'backup_schedule_id' => (int) $record->getKey(),
'trigger' => 'bulk_run_now',
],
initiator: $user,
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
$createdOperationRunIds[] = (int) $operationRun->getKey();
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $operationRun->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'operation_run_id' => $operationRun->getKey(),
'trigger' => 'bulk_run_now',
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
}, emitQueuedNotification: false);
}
$notification = Notification::make()
->title('Runs dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
if (count($createdRunIds) === 0) {
if (count($createdOperationRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
@ -779,7 +606,7 @@ public static function table(Table $table): Table
$notification->send();
if (count($createdRunIds) > 0) {
if (count($createdOperationRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
})
@ -815,96 +642,52 @@ public static function table(Table $table): Table
$bulkRun = null;
$createdRunIds = [];
$createdOperationRunIds = [];
/** @var BackupSchedule $record */
foreach ($records as $record) {
$operationRun = $operationRunService->ensureRun(
$nonce = (string) Str::uuid();
$operationRun = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.retry',
inputs: [
identityInputs: [
'backup_schedule_id' => (int) $record->getKey(),
'nonce' => $nonce,
],
initiator: $user
context: [
'backup_schedule_id' => (int) $record->getKey(),
'trigger' => 'bulk_retry',
],
initiator: $user,
);
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
continue;
}
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
$run = null;
for ($i = 0; $i < 5; $i++) {
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $record->id,
'tenant_id' => $tenant->getKey(),
'user_id' => $userId,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
break;
} catch (UniqueConstraintViolationException) {
$scheduledFor = $scheduledFor->addMinute();
}
}
if (! $run instanceof BackupScheduleRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'backup_schedule_id' => (int) $record->getKey(),
],
failures: [
[
'code' => 'SCHEDULE_CONFLICT',
'message' => 'Unable to queue a unique backup schedule retry run.',
],
],
);
continue;
}
$createdRunIds[] = (int) $run->id;
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_id' => (int) $record->getKey(),
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
$createdOperationRunIds[] = (int) $operationRun->getKey();
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'backup_schedule.run_dispatched_manual',
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $operationRun->getKey(),
status: 'success',
context: [
'metadata' => [
'backup_schedule_id' => $record->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'operation_run_id' => $operationRun->getKey(),
'trigger' => 'bulk_retry',
],
],
);
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
}, emitQueuedNotification: false);
}
$notification = Notification::make()
->title('Retries dispatched')
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
if (count($createdRunIds) === 0) {
if (count($createdOperationRunIds) === 0) {
$notification->warning();
} else {
$notification->success();
@ -920,7 +703,7 @@ public static function table(Table $table): Table
$notification->send();
if (count($createdRunIds) > 0) {
if (count($createdOperationRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
})
@ -931,6 +714,7 @@ public static function table(Table $table): Table
DeleteBulkAction::make('bulk_delete')
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->destructive()
->apply(),
]),
]);
@ -949,6 +733,7 @@ public static function getEloquentQuery(): Builder
public static function getRelations(): array
{
return [
BackupScheduleOperationRunsRelationManager::class,
BackupScheduleRunsRelationManager::class,
];
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BackupScheduleOperationRunsRelationManager extends RelationManager
{
protected static string $relationship = 'operationRuns';
protected static ?string $title = 'Executions';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
}
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('Enqueued')
->dateTime(),
Tables\Columns\TextColumn::make('type')
->label('Type')
->formatStateUsing([OperationCatalog::class, 'label']),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
Tables\Columns\TextColumn::make('outcome')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
Tables\Columns\TextColumn::make('counts')
->label('Counts')
->getStateUsing(function (OperationRun $record): string {
$counts = is_array($record->summary_counts) ? $record->summary_counts : [];
$total = (int) ($counts['total'] ?? 0);
$succeeded = (int) ($counts['succeeded'] ?? 0);
$failed = (int) ($counts['failed'] ?? 0);
if ($total === 0 && $succeeded === 0 && $failed === 0) {
return '—';
}
return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed);
}),
])
->filters([])
->headerActions([])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(function (OperationRun $record): string {
$tenant = Tenant::currentOrFail();
return OperationRunLinks::view($record, $tenant);
})
->openUrlInNewTab(true),
])
->bulkActions([]);
}
}

View File

@ -43,6 +43,8 @@ class BackupSetResource extends Resource
{
protected static ?string $model = BackupSet::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';

View File

@ -5,7 +5,6 @@
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Directory\EntraGroupSelection;
@ -47,11 +46,15 @@ protected function getHeaderActions(): array
// --- Phase 3: Canonical Operation Run Start ---
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'directory_groups.sync',
inputs: ['selection_key' => $selectionKey],
initiator: $user
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
@ -70,42 +73,11 @@ protected function getHeaderActions(): array
}
// ----------------------------------------------
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
Notification::make()
->title('Group sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->sendToDatabase($user)
->send();
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
runId: null,
operationRun: $opRun
));

View File

@ -3,86 +3,9 @@
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Services\Directory\EntraGroupSelection;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
class ListEntraGroupSyncRuns extends ListRecords
{
protected static string $resource = EntraGroupSyncRunResource::class;
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Action::make('sync_groups')
->label('Sync Groups')
->icon('heroicon-o-arrow-path')
->color('warning')
->action(function (): void {
$user = auth()->user();
$tenant = Tenant::current();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
$selectionKey = EntraGroupSelection::allGroupsV1();
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
if ($existing instanceof EntraGroupSyncRun) {
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $existing->getKey(),
'status' => $normalizedStatus,
]));
return;
}
$run = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
));
$user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $tenant->getKey(),
'run_type' => 'directory_groups',
'run_id' => (int) $run->getKey(),
'status' => 'queued',
]));
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
];
}
}

View File

@ -3,9 +3,22 @@
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Models\EntraGroupSyncRun;
use App\Support\OperationRunLinks;
use Filament\Resources\Pages\ViewRecord;
class ViewEntraGroupSyncRun extends ViewRecord
{
protected static string $resource = EntraGroupSyncRunResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$legacyRun = $this->getRecord();
if ($legacyRun instanceof EntraGroupSyncRun && is_numeric($legacyRun->operation_run_id)) {
$this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id));
}
}
}

View File

@ -38,6 +38,8 @@ class FindingResource extends Resource
{
protected static ?string $model = Finding::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string|UnitEnum|null $navigationGroup = 'Drift';

View File

@ -37,6 +37,8 @@ class InventoryItemResource extends Resource
{
protected static ?string $model = InventoryItem::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static ?string $cluster = InventoryCluster::class;
protected static ?int $navigationSort = 1;
@ -125,8 +127,20 @@ public static function infolist(Schema $schema): Schema
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
TextEntry::make('last_seen_operation_run_id')
->label('Last inventory sync')
->visible(fn (InventoryItem $record): bool => filled($record->last_seen_operation_run_id))
->url(function (InventoryItem $record): ?string {
if (! $record->last_seen_operation_run_id) {
return null;
}
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
})
->openUrlInNewTab(),
TextEntry::make('last_seen_run_id')
->label('Last sync run')
->label('Last inventory sync (legacy)')
->visible(fn (InventoryItem $record): bool => blank($record->last_seen_operation_run_id) && filled($record->last_seen_run_id))
->url(function (InventoryItem $record): ?string {
if (! $record->last_seen_run_id) {
return null;

View File

@ -5,7 +5,6 @@
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
@ -152,11 +151,16 @@ protected function getHeaderActions(): array
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'inventory.sync',
inputs: $computed['selection'],
initiator: $user
identityInputs: [
'selection_hash' => $computed['selection_hash'],
],
context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'],
]),
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
@ -176,57 +180,26 @@ protected function getHeaderActions(): array
return;
}
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
$existing = InventorySyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $computed['selection_hash'])
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
->first();
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
if ($existing instanceof InventorySyncRun) {
Notification::make()
->title('Inventory sync already active')
->body('A matching inventory sync run is already pending or running.')
->warning()
->actions([
Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$run = $inventorySyncService->createPendingRunForUser($tenant, $user, $computed['selection']);
$policyTypes = $computed['selection']['policy_types'] ?? [];
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.dispatched',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
'operation_run_id' => (int) $opRun->getKey(),
'selection_hash' => $computed['selection_hash'],
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $opRun->getKey(),
);
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $opRun): void {
RunInventorySyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->id,
operationRun: $opRun
);
});

View File

@ -32,6 +32,8 @@ class InventorySyncRunResource extends Resource
{
protected static ?string $model = InventorySyncRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static bool $shouldRegisterNavigation = true;
protected static ?string $cluster = InventoryCluster::class;

View File

@ -3,9 +3,22 @@
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\InventorySyncRun;
use App\Support\OperationRunLinks;
use Filament\Resources\Pages\ViewRecord;
class ViewInventorySyncRun extends ViewRecord
{
protected static string $resource = InventorySyncRunResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$legacyRun = $this->getRecord();
if ($legacyRun instanceof InventorySyncRun && is_numeric($legacyRun->operation_run_id)) {
$this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id));
}
}
}

View File

@ -10,6 +10,7 @@
use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
@ -317,6 +318,14 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('tenant_id')
->label('Tenant')
->options(function (): array {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
return [
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
];
}
$user = auth()->user();
if (! $user instanceof User) {
@ -330,19 +339,19 @@ public static function table(Table $table): Table
->all();
})
->default(function (): ?string {
$tenant = Filament::getTenant();
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $tenant instanceof Tenant) {
if (! $activeTenant instanceof Tenant) {
return null;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
return null;
}
return (string) $tenant->getKey();
return (string) $activeTenant->getKey();
})
->searchable(),
Tables\Filters\SelectFilter::make('type')

View File

@ -52,6 +52,8 @@ class PolicyResource extends Resource
{
protected static ?string $model = Policy::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';

View File

@ -51,6 +51,8 @@ class PolicyVersionResource extends Resource
{
protected static ?string $model = PolicyVersion::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';

View File

@ -18,6 +18,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
@ -84,7 +85,61 @@ protected static function resolveScopedTenant(): ?Tenant
->first();
}
return Tenant::current();
$externalId = static::resolveTenantExternalIdFromLivewireRequest();
if (is_string($externalId) && $externalId !== '') {
return Tenant::query()
->where('external_id', $externalId)
->first();
}
$filamentTenant = \Filament\Facades\Filament::getTenant();
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
}
private static function resolveTenantExternalIdFromLivewireRequest(): ?string
{
if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) {
return null;
}
try {
$url = \Livewire\Livewire::originalUrl();
if (is_string($url) && $url !== '') {
$externalId = static::extractTenantExternalIdFromUrl($url);
if (is_string($externalId) && $externalId !== '') {
return $externalId;
}
}
} catch (\Throwable) {
// Ignore and fall back to referer.
}
$referer = request()->headers->get('referer');
if (! is_string($referer) || $referer === '') {
return null;
}
return static::extractTenantExternalIdFromUrl($referer);
}
private static function extractTenantExternalIdFromUrl(string $url): ?string
{
$path = parse_url($url, PHP_URL_PATH);
if (! is_string($path) || $path === '') {
$path = $url;
}
if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) {
return null;
}
return (string) $matches[1];
}
public static function form(Schema $schema): Schema
@ -589,15 +644,18 @@ public static function table(Table $table): Table
}
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status;
$status = $hadCredentials ? 'connected' : 'error';
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
$record->update([
'status' => $status,
'health_status' => 'unknown',
'last_health_check_at' => null,
'last_error_reason_code' => null,
'last_error_message' => null,
'last_error_reason_code' => $errorReasonCode,
'last_error_message' => $errorMessage,
]);
$user = auth()->user();
@ -716,9 +774,13 @@ public static function getEloquentQuery(): Builder
return $query->whereRaw('1 = 0');
}
if ($tenantId === null) {
return $query->whereRaw('1 = 0');
}
return $query
->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->where('tenant_id', $tenantId)
->latest('id');
}
@ -736,6 +798,10 @@ public static function getPages(): array
*/
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
{
if (array_key_exists('tenant', $parameters) && blank($parameters['tenant'])) {
unset($parameters['tenant']);
}
if (! array_key_exists('tenant', $parameters)) {
if ($tenant instanceof Tenant) {
$parameters['tenant'] = $tenant->external_id;
@ -746,6 +812,20 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) {
$parameters['tenant'] = $resolvedTenant->external_id;
}
$record = $parameters['record'] ?? null;
if (! array_key_exists('tenant', $parameters) && $record instanceof ProviderConnection) {
$recordTenant = $record->tenant;
if (! $recordTenant instanceof Tenant) {
$recordTenant = Tenant::query()->whereKey($record->tenant_id)->first();
}
if ($recordTenant instanceof Tenant) {
$parameters['tenant'] = $recordTenant->external_id;
}
}
}
$panel ??= 'admin';

View File

@ -28,10 +28,29 @@ class EditProviderConnection extends EditRecord
{
protected static string $resource = ProviderConnectionResource::class;
public ?string $scopedTenantExternalId = null;
protected bool $shouldMakeDefault = false;
protected bool $defaultWasChanged = false;
public function mount($record): void
{
parent::mount($record);
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {
$this->scopedTenantExternalId = (string) $tenant->external_id;
return;
}
if (is_string($tenant) && $tenant !== '') {
$this->scopedTenantExternalId = $tenant;
}
}
protected function mutateFormDataBeforeSave(array $data): array
{
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
@ -42,9 +61,16 @@ protected function mutateFormDataBeforeSave(array $data): array
protected function afterSave(): void
{
$tenant = $this->currentTenant();
$record = $this->getRecord();
$tenant = $record instanceof ProviderConnection
? ($record->tenant ?? $this->currentTenant())
: $this->currentTenant();
if (! $tenant instanceof Tenant) {
return;
}
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
if ($this->shouldMakeDefault && ! $record->is_default) {
@ -602,15 +628,18 @@ protected function getHeaderActions(): array
}
$hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent';
$previousStatus = (string) $record->status;
$status = $hadCredentials ? 'connected' : 'needs_consent';
$errorReasonCode = null;
$errorMessage = null;
$record->update([
'status' => $status,
'health_status' => 'unknown',
'last_health_check_at' => null,
'last_error_reason_code' => null,
'last_error_message' => null,
'last_error_reason_code' => $errorReasonCode,
'last_error_message' => $errorMessage,
]);
$user = auth()->user();
@ -640,8 +669,8 @@ protected function getHeaderActions(): array
if (! $hadCredentials) {
Notification::make()
->title('Connection enabled (credentials missing)')
->body('Add credentials before running checks or operations.')
->title('Connection enabled (needs consent)')
->body('Grant admin consent before running checks or operations.')
->warning()
->send();
@ -744,7 +773,9 @@ protected function getFormActions(): array
protected function handleRecordUpdate(Model $record, array $data): Model
{
$tenant = $this->currentTenant();
$tenant = $record instanceof ProviderConnection
? ($record->tenant ?? $this->currentTenant())
: $this->currentTenant();
$user = auth()->user();
@ -767,6 +798,12 @@ protected function handleRecordUpdate(Model $record, array $data): Model
private function currentTenant(): ?Tenant
{
if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') {
return Tenant::query()
->where('external_id', $this->scopedTenantExternalId)
->first();
}
$tenant = request()->route('tenant');
if ($tenant instanceof Tenant) {

View File

@ -36,6 +36,7 @@
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
@ -60,6 +61,17 @@ class RestoreRunResource extends Resource
{
protected static ?string $model = RestoreRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
return false;
}
return parent::shouldRegisterNavigation();
}
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
@ -876,7 +888,27 @@ public static function table(Table $table): Table
status: 'success',
);
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
$auditLogger->log(
tenant: $tenant,
@ -896,6 +928,11 @@ public static function table(Table $table): Table
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
@ -1727,7 +1764,35 @@ public static function createRestoreRun(array $data): RestoreRun
status: 'success',
);
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
],
initiator: $initiator,
);
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
}
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return $restoreRun->refresh();
}

View File

@ -7,12 +7,16 @@
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\EntraGroup;
use App\Models\EntraRoleDefinition;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacOnboardingService;
@ -46,9 +50,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use UnitEnum;
class TenantResource extends Resource
@ -949,18 +951,14 @@ public static function rbacAction(): Actions\Action
->optionsLimit(20)
->searchDebounce(400)
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::roleSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::formatRoleLabel(
static::resolveRoleName($record, $value),
$value ?? ''
))
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::roleLabelFromCache($record, $value))
->helperText(fn (?Tenant $record) => static::roleSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchRolesAction($record))
->hintAction(fn (?Tenant $record) => static::syncRoleDefinitionsAction())
->hint('Wizard grants Intune RBAC roles only. "Intune Administrator" is an Entra directory role and is not assigned here.')
->noSearchResultsMessage('No Intune RBAC roleDefinitions found (tenant may restrict RBAC or missing permission).')
->loadingMessage('Loading roles...')
->afterStateUpdated(function (Set $set, ?string $state, ?Tenant $record) {
$set('role_display_name', static::resolveRoleName($record, $state));
$set('role_display_name', static::roleNameFromCache($record, $state));
}),
Forms\Components\Hidden::make('role_display_name')
->dehydrated(),
@ -982,11 +980,9 @@ public static function rbacAction(): Actions\Action
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('scope') === 'scope_group')
->required(fn (Get $get) => $get('scope') === 'scope_group')
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null)
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value))
->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'),
Forms\Components\Select::make('group_mode')
@ -1011,11 +1007,9 @@ public static function rbacAction(): Actions\Action
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('group_mode') === 'existing')
->required(fn (Get $get) => $get('group_mode') === 'existing')
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null)
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value))
->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'),
])
@ -1052,8 +1046,10 @@ public static function rbacAction(): Actions\Action
abort(403);
}
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
$token = Cache::get($cacheKey);
$userCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), null);
$sessionCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
$token = Cache::get($sessionCacheKey) ?? Cache::get($userCacheKey);
if (! $token) {
Notification::make()
@ -1078,7 +1074,8 @@ public static function rbacAction(): Actions\Action
$result = $service->run($record, $data, $user, $token);
Cache::forget($cacheKey);
Cache::forget($sessionCacheKey);
Cache::forget($userCacheKey);
if ($result['status'] === 'success') {
Notification::make()
@ -1133,6 +1130,10 @@ public static function adminConsentUrl(Tenant $tenant): ?string
{
$tenantId = $tenant->graphTenantId();
$clientId = $tenant->app_client_id;
if (! is_string($clientId) || trim($clientId) === '') {
$clientId = static::resolveProviderClientIdForConsent($tenant);
}
$redirectUri = route('admin.consent.callback');
$state = sprintf('tenantpilot|%s', $tenant->id);
@ -1162,6 +1163,36 @@ public static function adminConsentUrl(Tenant $tenant): ?string
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
}
private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string
{
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->orderByDesc('is_default')
->orderBy('id')
->first();
if (! $connection instanceof ProviderConnection) {
return null;
}
$payload = $connection->credential?->payload;
if (! is_array($payload)) {
return null;
}
$clientId = $payload['client_id'] ?? null;
if (! is_string($clientId)) {
return null;
}
$clientId = trim($clientId);
return $clientId !== '' ? $clientId : null;
}
public static function entraUrl(Tenant $tenant): ?string
{
if ($tenant->app_client_id) {
@ -1195,23 +1226,16 @@ private static function delegatedToken(?Tenant $tenant): ?string
private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Action
{
if (! $tenant) {
return null;
}
return Actions\Action::make('login_to_load_roles')
->label('Login to load roles')
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'record' => $tenant,
]),
]));
return null;
}
public static function roleSearchHelper(?Tenant $tenant): ?string
{
return static::delegatedToken($tenant) ? null : 'Login to load roles';
if (! $tenant) {
return null;
}
return static::delegatedToken($tenant) === null ? 'Login to search roles' : null;
}
/**
@ -1219,122 +1243,112 @@ public static function roleSearchHelper(?Tenant $tenant): ?string
*/
public static function roleSearchOptions(?Tenant $tenant, string $search): array
{
$token = static::delegatedToken($tenant);
if ($token !== null) {
return static::searchRoleDefinitionsDelegated($tenant, $search, $token);
}
return static::searchRoleDefinitions($tenant, $search);
}
/**
* @return array<string, string>
*/
private static function searchRoleDefinitionsDelegated(?Tenant $tenant, string $search, string $token): array
{
if (! $tenant || mb_strlen($search) < 2) {
return [];
}
$needle = mb_strtolower($search);
/** @var GraphClientInterface $graph */
$graph = app(GraphClientInterface::class);
$response = $graph->request('GET', 'deviceManagement/roleDefinitions', [
'access_token' => $token,
]);
if ($response->failed()) {
return [];
}
$roles = is_array($response->data['value'] ?? null) ? $response->data['value'] : [];
$results = [];
foreach ($roles as $role) {
$id = is_string($role['id'] ?? null) ? (string) $role['id'] : null;
$displayName = is_string($role['displayName'] ?? null) ? (string) $role['displayName'] : null;
if (! $id || ! $displayName) {
continue;
}
if (! str_contains(mb_strtolower($displayName), $needle)) {
continue;
}
$results[$id] = static::formatRoleLabel($displayName, $id);
}
ksort($results);
return array_slice($results, 0, 20, true);
}
/**
* @return array<string, string>
*/
private static function searchRoleDefinitions(?Tenant $tenant, string $search): array
{
if (! $tenant) {
if (! $tenant || mb_strlen($search) < 2) {
return [];
}
$token = static::delegatedToken($tenant);
$needle = mb_strtolower($search);
if (! $token) {
return [];
}
if (Str::contains(Str::lower($search), 'intune administrator')) {
Notification::make()
->title('Intune Administrator is a directory role')
->body('Das ist eine Entra Directory Role, nicht Intune RBAC; wird vom Wizard nicht vergeben.')
->warning()
->persistent()
->send();
}
$filter = mb_strlen($search) >= 2
? sprintf("startswith(displayName,'%s')", static::escapeOdataValue($search))
: null;
$query = [
'$select' => 'id,displayName,isBuiltIn',
'$top' => 20,
];
if ($filter) {
$query['$filter'] = $filter;
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'deviceManagement/roleDefinitions',
[
'query' => $query,
] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
static::notifyRoleLookupFailure();
return [];
}
if ($response->failed()) {
static::notifyRoleLookupFailure();
return [];
}
$roles = collect($response->data['value'] ?? [])
->filter(fn (array $role) => filled($role['id'] ?? null))
->mapWithKeys(fn (array $role) => [
$role['id'] => static::formatRoleLabel($role['displayName'] ?? null, $role['id']),
return EntraRoleDefinition::query()
->where('tenant_id', $tenant->getKey())
->whereRaw('lower(display_name) like ?', [$needle.'%'])
->orderBy('display_name')
->limit(20)
->get(['entra_id', 'display_name'])
->mapWithKeys(fn (EntraRoleDefinition $role): array => [
(string) $role->entra_id => static::formatRoleLabel((string) $role->display_name, (string) $role->entra_id),
])
->all();
if (empty($roles)) {
static::logEmptyRoleDefinitions($tenant, $response->data['value'] ?? []);
}
return $roles;
}
private static function resolveRoleName(?Tenant $tenant, ?string $roleId): ?string
public static function roleLabelFromCache(?Tenant $tenant, ?string $roleId): ?string
{
if (! $tenant || blank($roleId)) {
return $roleId;
}
$token = static::delegatedToken($tenant);
$displayName = EntraRoleDefinition::query()
->where('tenant_id', $tenant->getKey())
->where('entra_id', $roleId)
->value('display_name');
if (! $token) {
$displayName = is_string($displayName) && $displayName !== '' ? $displayName : $roleId;
return static::formatRoleLabel($displayName, $roleId);
}
private static function roleNameFromCache(?Tenant $tenant, ?string $roleId): ?string
{
if (! $tenant || blank($roleId)) {
return $roleId;
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
"deviceManagement/roleDefinitions/{$roleId}",
[
'query' => [
'$select' => 'id,displayName',
],
] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
static::notifyRoleLookupFailure();
$displayName = EntraRoleDefinition::query()
->where('tenant_id', $tenant->getKey())
->where('entra_id', $roleId)
->value('display_name');
return $roleId;
}
if ($response->failed()) {
static::notifyRoleLookupFailure();
return $roleId;
}
$displayName = $response->data['displayName'] ?? null;
$id = $response->data['id'] ?? $roleId;
return $displayName ?: $id;
return is_string($displayName) && $displayName !== '' ? $displayName : $roleId;
}
private static function formatRoleLabel(?string $displayName, string $id): string
@ -1344,58 +1358,9 @@ private static function formatRoleLabel(?string $displayName, string $id): strin
return trim(($displayName ?: 'RBAC role').$suffix);
}
private static function escapeOdataValue(string $value): string
{
return str_replace("'", "''", $value);
}
private static function notifyRoleLookupFailure(): void
{
Notification::make()
->title('Role lookup failed')
->body('Delegated session may have expired. Login again to load Intune RBAC roles.')
->danger()
->send();
}
private static function logEmptyRoleDefinitions(Tenant $tenant, array $roles): void
{
$names = collect($roles)->pluck('displayName')->filter()->take(5)->values()->all();
Log::warning('rbac.role_definitions.empty', [
'tenant_id' => $tenant->id,
'count' => count($roles),
'sample' => $names,
]);
try {
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'rbac.roles.empty',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'warning',
context: ['metadata' => ['count' => count($roles), 'sample' => $names]],
);
} catch (Throwable) {
Log::notice('rbac.role_definitions.audit_failed', ['tenant_id' => $tenant->id]);
}
}
private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Action
{
if (! $tenant) {
return null;
}
return Actions\Action::make('login_to_search_groups')
->label('Login to search groups')
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'record' => $tenant,
]),
]));
return null;
}
public static function groupSearchHelper(?Tenant $tenant): ?string
@ -1404,7 +1369,7 @@ public static function groupSearchHelper(?Tenant $tenant): ?string
return null;
}
return static::delegatedToken($tenant) ? null : 'Login to search groups';
return static::delegatedToken($tenant) === null ? 'Login to search groups' : null;
}
/**
@ -1418,76 +1383,116 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra
$token = static::delegatedToken($tenant);
if (! $token) {
return [];
if ($token !== null) {
/** @var GraphClientInterface $graph */
$graph = app(GraphClientInterface::class);
$response = $graph->request('GET', 'groups', [
'access_token' => $token,
'query' => [
'$filter' => sprintf("startswith(displayName,'%s') and securityEnabled eq true", addslashes($search)),
],
]);
if ($response->failed()) {
return [];
}
$groups = is_array($response->data['value'] ?? null) ? $response->data['value'] : [];
$results = [];
foreach ($groups as $group) {
$id = is_string($group['id'] ?? null) ? (string) $group['id'] : null;
$displayName = is_string($group['displayName'] ?? null) ? (string) $group['displayName'] : null;
if (! $id || ! $displayName) {
continue;
}
$results[$id] = EntraGroupLabelResolver::formatLabel($displayName, $id);
}
ksort($results);
return array_slice($results, 0, 20, true);
}
$filter = sprintf(
"securityEnabled eq true and startswith(displayName,'%s')",
static::escapeOdataValue($search)
);
$needle = mb_strtolower($search);
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups',
[
'query' => [
'$select' => 'id,displayName',
'$top' => 20,
'$filter' => $filter,
],
] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
return [];
}
if ($response->failed()) {
return [];
}
return collect($response->data['value'] ?? [])
->filter(fn (array $group) => filled($group['id'] ?? null))
->mapWithKeys(fn (array $group) => [
(string) $group['id'] => EntraGroupLabelResolver::formatLabel($group['displayName'] ?? null, (string) $group['id']),
return EntraGroup::query()
->where('tenant_id', $tenant->getKey())
->whereRaw('lower(display_name) like ?', [$needle.'%'])
->orderBy('display_name')
->limit(20)
->get(['entra_id', 'display_name'])
->mapWithKeys(fn (EntraGroup $group): array => [
(string) $group->entra_id => EntraGroupLabelResolver::formatLabel((string) $group->display_name, (string) $group->entra_id),
])
->all();
}
private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?string
public static function groupLabelFromCache(?Tenant $tenant, ?string $groupId): ?string
{
if (! $tenant || blank($groupId)) {
return $groupId;
}
$token = static::delegatedToken($tenant);
$resolver = app(EntraGroupLabelResolver::class);
if (! $token) {
return $groupId;
}
return $resolver->resolveOne($tenant, $groupId);
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups/'.$groupId,
[] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
return $groupId;
}
public static function syncRoleDefinitionsAction(): Actions\Action
{
return Actions\Action::make('sync_role_definitions')
->label('Sync now')
->icon('heroicon-o-arrow-path')
->color('warning')
->visible(fn (?Tenant $record): bool => $record instanceof Tenant && $record->isActive())
->action(function (Tenant $record, RoleDefinitionsSyncService $syncService): void {
$user = auth()->user();
if ($response->failed()) {
return $groupId;
}
if (! $user instanceof User) {
abort(403);
}
return EntraGroupLabelResolver::formatLabel(
$response->data['displayName'] ?? null,
$response->data['id'] ?? $groupId
);
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
abort(403);
}
$opRun = $syncService->startManualSync($record, $user);
$runUrl = OperationRunLinks::tenantlessView($opRun);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Role definitions sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
return;
}
OperationUxPresenter::queuedToast('directory_role_definitions.sync')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url($runUrl),
])
->send();
});
}
}

View File

@ -17,6 +17,14 @@ public function __invoke(Request $request): RedirectResponse
app(WorkspaceContext::class)->clearLastTenantId($request);
return redirect()->to('/admin/operations');
$previousUrl = url()->previous();
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
if ($previousHost !== null && $previousHost !== $request->getHost()) {
return redirect()->to('/admin/operations');
}
return redirect()->to((string) $previousUrl);
}
}

View File

@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) {
abort(404);
}
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
abort(404);
}
@ -101,4 +105,13 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
}
private function isOperateHubPath(string $path): bool
{
return in_array($path, [
'/admin/operations',
'/admin/alerts',
'/admin/audit-log',
], true);
}
}

View File

@ -40,38 +40,59 @@ public function middleware(): array
public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void
{
if (! $this->operationRun) {
$this->fail(new RuntimeException('OperationRun context is required for EntraGroupSyncJob.'));
return;
}
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$run = $this->resolveRun($tenant);
$legacyRun = $this->resolveLegacyRun($tenant);
if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) {
// Already ran?
return;
if ($legacyRun instanceof EntraGroupSyncRun) {
if ($legacyRun->status !== EntraGroupSyncRun::STATUS_PENDING) {
return;
}
$legacyRun->update([
'status' => EntraGroupSyncRun::STATUS_RUNNING,
'started_at' => CarbonImmutable::now('UTC'),
]);
$auditLogger->log(
tenant: $tenant,
action: 'directory_groups.sync.started',
context: [
'selection_key' => $legacyRun->selection_key,
'run_id' => $legacyRun->getKey(),
'slot_key' => $legacyRun->slot_key,
],
actorId: $legacyRun->initiator_user_id,
status: 'success',
resourceType: 'entra_group_sync_run',
resourceId: (string) $legacyRun->getKey(),
);
} else {
$auditLogger->log(
tenant: $tenant,
action: 'directory_groups.sync.started',
context: [
'selection_key' => $this->selectionKey,
'slot_key' => $this->slotKey,
],
actorId: $this->operationRun->user_id,
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
$run->update([
'status' => EntraGroupSyncRun::STATUS_RUNNING,
'started_at' => CarbonImmutable::now('UTC'),
]);
$result = $syncService->sync($tenant, $this->selectionKey);
$auditLogger->log(
tenant: $tenant,
action: 'directory_groups.sync.started',
context: [
'selection_key' => $run->selection_key,
'run_id' => $run->getKey(),
'slot_key' => $run->slot_key,
],
actorId: $run->initiator_user_id,
status: 'success',
resourceType: 'entra_group_sync_run',
resourceId: (string) $run->getKey(),
);
$result = $syncService->sync($tenant, $run);
$terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED;
@ -81,43 +102,80 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
$terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL;
}
$run->update([
'status' => $terminalStatus,
'pages_fetched' => $result['pages_fetched'],
'items_observed_count' => $result['items_observed_count'],
'items_upserted_count' => $result['items_upserted_count'],
'error_count' => $result['error_count'],
'safety_stop_triggered' => $result['safety_stop_triggered'],
'safety_stop_reason' => $result['safety_stop_reason'],
'error_code' => $result['error_code'],
'error_category' => $result['error_category'],
'error_summary' => $result['error_summary'],
'finished_at' => CarbonImmutable::now('UTC'),
]);
if ($legacyRun instanceof EntraGroupSyncRun) {
$legacyRun->update([
'status' => $terminalStatus,
'pages_fetched' => $result['pages_fetched'],
'items_observed_count' => $result['items_observed_count'],
'items_upserted_count' => $result['items_upserted_count'],
'error_count' => $result['error_count'],
'safety_stop_triggered' => $result['safety_stop_triggered'],
'safety_stop_reason' => $result['safety_stop_reason'],
'error_code' => $result['error_code'],
'error_category' => $result['error_category'],
'error_summary' => $result['error_summary'],
'finished_at' => CarbonImmutable::now('UTC'),
]);
}
// Update OperationRun with stats
if ($this->operationRun) {
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opOutcome = match ($terminalStatus) {
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded',
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded',
EntraGroupSyncRun::STATUS_FAILED => 'failed',
default => 'failed'
};
$opOutcome = match ($terminalStatus) {
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded',
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded',
EntraGroupSyncRun::STATUS_FAILED => 'failed',
default => 'failed',
};
$opService->updateRun(
$this->operationRun,
'completed',
$opOutcome,
[
'fetched' => $result['items_observed_count'],
'upserted' => $result['items_upserted_count'],
'errors' => $result['error_count'],
$failures = [];
if (is_string($result['error_code']) && $result['error_code'] !== '') {
$failures[] = [
'code' => $result['error_code'],
'message' => is_string($result['error_summary']) ? $result['error_summary'] : 'Directory groups sync failed.',
];
}
$opService->updateRun(
$this->operationRun,
'completed',
$opOutcome,
[
// NOTE: summary_counts are normalized to a fixed whitelist for Ops UX.
// Keep keys aligned with App\Support\OpsUx\OperationSummaryKeys.
'total' => $result['items_observed_count'],
'processed' => $result['items_observed_count'],
'updated' => $result['items_upserted_count'],
'failed' => $result['error_count'],
],
$failures,
);
if ($legacyRun instanceof EntraGroupSyncRun) {
$auditLogger->log(
tenant: $tenant,
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
? 'directory_groups.sync.succeeded'
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL
? 'directory_groups.sync.partial'
: 'directory_groups.sync.failed'),
context: [
'selection_key' => $legacyRun->selection_key,
'run_id' => $legacyRun->getKey(),
'slot_key' => $legacyRun->slot_key,
'pages_fetched' => $legacyRun->pages_fetched,
'items_observed_count' => $legacyRun->items_observed_count,
'items_upserted_count' => $legacyRun->items_upserted_count,
'error_code' => $legacyRun->error_code,
'error_category' => $legacyRun->error_category,
],
$result['error_summary'] ? [['code' => $result['error_code'] ?? 'ERR', 'message' => json_encode($result['error_summary'])]] : []
actorId: $legacyRun->initiator_user_id,
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
resourceType: 'entra_group_sync_run',
resourceId: (string) $legacyRun->getKey(),
);
return;
}
$auditLogger->log(
@ -128,23 +186,22 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
? 'directory_groups.sync.partial'
: 'directory_groups.sync.failed'),
context: [
'selection_key' => $run->selection_key,
'run_id' => $run->getKey(),
'slot_key' => $run->slot_key,
'pages_fetched' => $run->pages_fetched,
'items_observed_count' => $run->items_observed_count,
'items_upserted_count' => $run->items_upserted_count,
'error_code' => $run->error_code,
'error_category' => $run->error_category,
'selection_key' => $this->selectionKey,
'slot_key' => $this->slotKey,
'pages_fetched' => $result['pages_fetched'],
'items_observed_count' => $result['items_observed_count'],
'items_upserted_count' => $result['items_upserted_count'],
'error_code' => $result['error_code'],
'error_category' => $result['error_category'],
],
actorId: $run->initiator_user_id,
actorId: $this->operationRun->user_id,
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
resourceType: 'entra_group_sync_run',
resourceId: (string) $run->getKey(),
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
private function resolveRun(Tenant $tenant): EntraGroupSyncRun
private function resolveLegacyRun(Tenant $tenant): ?EntraGroupSyncRun
{
if ($this->runId !== null) {
$run = EntraGroupSyncRun::query()
@ -156,7 +213,7 @@ private function resolveRun(Tenant $tenant): EntraGroupSyncRun
return $run;
}
throw new RuntimeException('EntraGroupSyncRun not found.');
return null;
}
if ($this->slotKey !== null) {
@ -170,9 +227,9 @@ private function resolveRun(Tenant $tenant): EntraGroupSyncRun
return $run;
}
throw new RuntimeException('EntraGroupSyncRun not found for slot.');
return null;
}
throw new RuntimeException('Job missing runId/slotKey.');
return null;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Listeners\SyncRestoreRunToOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
@ -22,20 +23,37 @@ class ExecuteRestoreRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $restoreRunId,
public ?string $actorEmail = null,
public ?string $actorName = null,
) {}
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
{
if (! $this->operationRun) {
$this->fail(new \RuntimeException('OperationRun context is required for ExecuteRestoreRunJob.'));
return;
}
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
if (! $restoreRun) {
return;
}
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $this->operationRun->getKey()) {
RestoreRun::withoutEvents(function () use ($restoreRun): void {
$restoreRun->forceFill(['operation_run_id' => $this->operationRun?->getKey()])->save();
});
}
if ($restoreRun->status !== RestoreRunStatus::Queued->value) {
return;
}

View File

@ -16,6 +16,7 @@
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Verification\TenantPermissionCheckClusters;
use App\Support\Verification\VerificationReportWriter;
@ -230,13 +231,36 @@ public function handle(
return;
}
$reasonCode = is_string($result->reasonCode) && $result->reasonCode !== ''
? $result->reasonCode
: 'unknown_error';
$nextSteps = app(ProviderNextStepsRegistry::class)->forReason(
$tenant,
$reasonCode,
$connection,
);
if ($reasonCode === ProviderReasonCodes::ProviderConsentMissing) {
$run = $runs->finalizeBlockedRun(
$this->operationRun,
reasonCode: $reasonCode,
nextSteps: $nextSteps,
message: $result->message ?? 'Admin consent is required before verification can proceed.',
);
$this->logVerificationCompletion($tenant, $user, $run, $report);
return;
}
$run = $runs->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'provider.connection.check.failed',
'reason_code' => $result->reasonCode ?? 'unknown_error',
'reason_code' => $reasonCode,
'message' => $result->message ?? 'Health check failed.',
]],
);

View File

@ -26,6 +26,7 @@
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class RunBackupScheduleJob implements ShouldQueue
{
@ -38,6 +39,7 @@ class RunBackupScheduleJob implements ShouldQueue
public function __construct(
public int $backupScheduleRunId,
?OperationRun $operationRun = null,
public ?int $backupScheduleId = null,
) {
$this->operationRun = $operationRun;
}
@ -55,6 +57,26 @@ public function handle(
AuditLogger $auditLogger,
RunErrorMapper $errorMapper,
): void {
if (! $this->operationRun) {
$this->fail(new \RuntimeException('OperationRun context is required for RunBackupScheduleJob.'));
return;
}
if ($this->backupScheduleId !== null) {
$this->handleFromScheduleId(
backupScheduleId: $this->backupScheduleId,
policySyncService: $policySyncService,
backupService: $backupService,
policyTypeResolver: $policyTypeResolver,
scheduleTimeService: $scheduleTimeService,
auditLogger: $auditLogger,
errorMapper: $errorMapper,
);
return;
}
$run = BackupScheduleRun::query()
->with(['schedule', 'tenant', 'user'])
->find($this->backupScheduleRunId);
@ -74,10 +96,6 @@ public function handle(
$tenant = $run->tenant;
if ($tenant instanceof Tenant) {
$this->resolveOperationRunFromContext($tenant, $run);
}
if ($this->operationRun) {
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
@ -347,6 +365,464 @@ public function handle(
}
}
private function handleFromScheduleId(
int $backupScheduleId,
PolicySyncService $policySyncService,
BackupService $backupService,
PolicyTypeResolver $policyTypeResolver,
ScheduleTimeService $scheduleTimeService,
AuditLogger $auditLogger,
RunErrorMapper $errorMapper,
): void {
$schedule = BackupSchedule::query()
->with('tenant')
->find($backupScheduleId);
if (! $schedule instanceof BackupSchedule) {
$this->markOperationRunFailed(
run: $this->operationRun,
summaryCounts: [],
reasonCode: 'schedule_not_found',
reason: 'Schedule not found.',
);
return;
}
$tenant = $schedule->tenant;
if (! $tenant instanceof Tenant) {
$this->markOperationRunFailed(
run: $this->operationRun,
summaryCounts: [],
reasonCode: 'tenant_not_found',
reason: 'Tenant not found.',
);
return;
}
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
'backup_schedule_id' => (int) $schedule->getKey(),
]),
]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
if ($this->operationRun->status === 'queued') {
$operationRunService->updateRun($this->operationRun, 'running');
}
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
if (! $lock->get()) {
$nowUtc = CarbonImmutable::now('UTC');
$this->finishSchedule(
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc,
);
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
],
failures: [
[
'code' => 'concurrent_run',
'message' => 'Another run is already in progress for this schedule.',
],
],
);
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorMessage: 'Another run is already in progress for this schedule.',
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_skipped',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'operation_run_id' => $this->operationRun->getKey(),
'reason' => 'concurrent_run',
],
],
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
status: 'partial'
);
return;
}
try {
$nowUtc = CarbonImmutable::now('UTC');
$this->notifyScheduleRunStarted(tenant: $tenant, schedule: $schedule);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_started',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'operation_run_id' => $this->operationRun->getKey(),
],
],
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
status: 'success'
);
$runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? []));
$validTypes = $runtime['valid'];
$unknownTypes = $runtime['unknown'];
if (empty($validTypes)) {
$this->finishSchedule(
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc,
);
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
],
failures: [
[
'code' => 'unknown_policy_type',
'message' => 'All configured policy types are unknown.',
],
],
);
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: BackupScheduleRun::STATUS_SKIPPED,
errorMessage: 'All configured policy types are unknown.',
);
return;
}
$supported = array_values(array_filter(
config('tenantpilot.supported_policy_types', []),
fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true),
));
$syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported);
$policyIds = $syncReport['synced'] ?? [];
$syncFailures = $syncReport['failures'] ?? [];
$backupSet = $backupService->createBackupSet(
tenant: $tenant,
policyIds: $policyIds,
actorEmail: null,
actorName: null,
name: 'Scheduled backup: '.$schedule->name,
includeAssignments: false,
includeScopeTags: false,
includeFoundations: (bool) ($schedule->include_foundations ?? false),
);
$status = match ($backupSet->status) {
'completed' => BackupScheduleRun::STATUS_SUCCESS,
'partial' => BackupScheduleRun::STATUS_PARTIAL,
'failed' => BackupScheduleRun::STATUS_FAILED,
default => BackupScheduleRun::STATUS_SUCCESS,
};
$errorCode = null;
$errorMessage = null;
if (! empty($unknownTypes)) {
$status = BackupScheduleRun::STATUS_PARTIAL;
$errorCode = 'UNKNOWN_POLICY_TYPE';
$errorMessage = 'Some configured policy types are unknown and were skipped.';
}
$policiesTotal = count($policyIds);
$policiesBackedUp = (int) ($backupSet->item_count ?? 0);
$failedCount = max(0, $policiesTotal - $policiesBackedUp);
$summaryCounts = [
'total' => $policiesTotal,
'processed' => $policiesTotal,
'succeeded' => $policiesBackedUp,
'failed' => $failedCount,
'skipped' => 0,
'created' => 1,
'updated' => $policiesBackedUp,
'items' => $policiesTotal,
];
$failures = [];
if (is_string($errorMessage) && $errorMessage !== '') {
$failures[] = [
'code' => strtolower((string) ($errorCode ?: 'backup_schedule_error')),
'message' => $errorMessage,
];
}
if (is_array($syncFailures)) {
foreach ($syncFailures as $failure) {
if (! is_array($failure)) {
continue;
}
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
$httpStatus = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
$errors = $failure['errors'] ?? null;
$firstErrorMessage = null;
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
$firstErrorMessage = $errors[0]['message'] ?? null;
}
$message = $httpStatus !== null
? "{$policyType}: Graph returned {$httpStatus}"
: "{$policyType}: Graph request failed";
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
$message .= ' - '.trim($firstErrorMessage);
}
$failures[] = [
'code' => $httpStatus !== null ? 'graph_http_'.(string) $httpStatus : 'graph_error',
'message' => $message,
];
}
}
$this->operationRun->update([
'context' => array_merge($this->operationRun->context ?? [], [
'backup_schedule_id' => (int) $schedule->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
]),
]);
$outcome = match ($status) {
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
default => 'failed',
};
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: $outcome,
summaryCounts: $summaryCounts,
failures: $failures,
);
$this->finishSchedule(
schedule: $schedule,
status: $status,
scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc,
);
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: $status,
errorMessage: $errorMessage,
);
if (in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
}
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_finished',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'operation_run_id' => $this->operationRun->getKey(),
'status' => $status,
'error_code' => $errorCode,
],
],
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
);
} catch (\Throwable $throwable) {
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
if ($mapped['shouldRetry']) {
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
$this->release($mapped['delay']);
return;
}
$nowUtc = CarbonImmutable::now('UTC');
$this->finishSchedule(
schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED,
scheduleTimeService: $scheduleTimeService,
nowUtc: $nowUtc,
);
$operationRunService->updateRun(
$this->operationRun,
status: 'completed',
outcome: 'failed',
summaryCounts: [
'total' => 0,
'processed' => 0,
'failed' => 1,
],
failures: [
[
'code' => strtolower((string) $mapped['error_code']),
'message' => (string) $mapped['error_message'],
],
],
);
$this->notifyScheduleRunFinished(
tenant: $tenant,
schedule: $schedule,
status: BackupScheduleRun::STATUS_FAILED,
errorMessage: (string) $mapped['error_message'],
);
$auditLogger->log(
tenant: $tenant,
action: 'backup_schedule.run_failed',
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'operation_run_id' => $this->operationRun->getKey(),
'error_code' => $mapped['error_code'],
],
],
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
status: 'failed'
);
} finally {
optional($lock)->release();
}
}
private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void
{
$userId = $this->operationRun?->user_id;
if (! $userId) {
return;
}
$user = \App\Models\User::query()->find($userId);
if (! $user) {
return;
}
Notification::make()
->title('Backup started')
->body(sprintf('Schedule "%s" has started.', $schedule->name))
->info()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
])
->sendToDatabase($user);
}
private function notifyScheduleRunFinished(
Tenant $tenant,
BackupSchedule $schedule,
string $status,
?string $errorMessage,
): void {
$userId = $this->operationRun?->user_id;
if (! $userId) {
return;
}
$user = \App\Models\User::query()->find($userId);
if (! $user) {
return;
}
$title = match ($status) {
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
default => 'Backup failed',
};
$notification = Notification::make()
->title($title)
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $status));
if (is_string($errorMessage) && $errorMessage !== '') {
$notification->body($notification->getBody()."\n".$errorMessage);
}
match ($status) {
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
default => $notification->danger(),
};
$notification
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($this->operationRun, $tenant)),
])
->sendToDatabase($user);
}
private function finishSchedule(
BackupSchedule $schedule,
string $status,
ScheduleTimeService $scheduleTimeService,
CarbonImmutable $nowUtc,
): void {
$schedule->forceFill([
'last_run_at' => $nowUtc,
'last_run_status' => $status,
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
}
private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void
{
$user = $run->user;
@ -527,25 +1003,6 @@ private function markOperationRunFailed(
);
}
private function resolveOperationRunFromContext(Tenant $tenant, BackupScheduleRun $run): void
{
if ($this->operationRun) {
return;
}
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
->whereIn('status', ['queued', 'running'])
->where('context->backup_schedule_run_id', (int) $run->getKey())
->latest('id')
->first();
if ($operationRun instanceof OperationRun) {
$this->operationRun = $operationRun;
}
}
private function finishRun(
BackupScheduleRun $run,
BackupSchedule $schedule,

View File

@ -3,7 +3,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
@ -31,7 +30,6 @@ class RunInventorySyncJob implements ShouldQueue
public function __construct(
public int $tenantId,
public int $userId,
public int $inventorySyncRunId,
?OperationRun $operationRun = null
) {
$this->operationRun = $operationRun;
@ -52,6 +50,12 @@ public function middleware(): array
*/
public function handle(InventorySyncService $inventorySyncService, AuditLogger $auditLogger, OperationRunService $operationRunService): void
{
if (! $this->operationRun) {
$this->fail(new RuntimeException('OperationRun context is required for RunInventorySyncJob.'));
return;
}
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
@ -62,15 +66,9 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
throw new RuntimeException('User not found.');
}
$run = InventorySyncRun::query()->find($this->inventorySyncRunId);
if (! $run instanceof InventorySyncRun) {
throw new RuntimeException('InventorySyncRun not found.');
}
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$policyTypes = $context['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : [];
$processedPolicyTypes = [];
$successCount = 0;
@ -81,9 +79,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
// However, InventorySyncService execution logic might be complex with partial failures.
// We might want to explicitly update the OperationRun if partial failures occur.
$run = $inventorySyncService->executePendingRun(
$run,
$result = $inventorySyncService->executeSelection(
$this->operationRun,
$tenant,
$context,
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void {
$processedPolicyTypes[] = $policyType;
@ -97,134 +97,90 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
},
);
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => count($policyTypes),
'failed' => 0,
// Reuse allowed keys for inventory item stats.
'items' => (int) $run->items_observed_count,
'updated' => (int) $run->items_upserted_count,
],
);
}
$status = (string) ($result['status'] ?? 'failed');
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : [];
$reason = (string) ($errorCodes[0] ?? $status);
$itemsObserved = (int) ($result['items_observed_count'] ?? 0);
$itemsUpserted = (int) ($result['items_upserted_count'] ?? 0);
$errorsCount = (int) ($result['errors_count'] ?? 0);
if ($status === 'success') {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => count($policyTypes),
'failed' => 0,
'items' => $itemsObserved,
'updated' => $itemsUpserted,
],
);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.completed',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_count,
'operation_run_id' => (int) $this->operationRun->getKey(),
'observed' => $itemsObserved,
'upserted' => $itemsUpserted,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
return;
}
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => max(0, count($policyTypes) - (int) $run->errors_count),
'failed' => (int) $run->errors_count,
'items' => (int) $run->items_observed_count,
'updated' => (int) $run->items_upserted_count,
],
failures: [
['code' => 'inventory.partial', 'message' => "Errors: {$run->errors_count}"],
],
);
}
if ($status === 'partial') {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => max(0, count($policyTypes) - $errorsCount),
'failed' => $errorsCount,
'items' => $itemsObserved,
'updated' => $itemsUpserted,
],
failures: [
['code' => 'inventory.partial', 'message' => "Errors: {$errorsCount}"],
],
);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.partial',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
'observed' => $run->items_observed_count,
'upserted' => $run->items_upserted_count,
'errors' => $run->errors_count,
'operation_run_id' => (int) $this->operationRun->getKey(),
'observed' => $itemsObserved,
'upserted' => $itemsUpserted,
'errors' => $errorsCount,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
status: 'failure',
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
return;
}
if ($run->status === InventorySyncRun::STATUS_SKIPPED) {
$reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped');
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => 0,
'failed' => 0,
'skipped' => count($policyTypes),
],
failures: [
['code' => 'inventory.skipped', 'message' => $reason],
],
);
}
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.skipped',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
'reason' => $reason,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
);
return;
}
$reason = (string) (($run->error_codes ?? [])[0] ?? 'failed');
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
if ($this->operationRun) {
if ($status === 'skipped') {
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
@ -232,22 +188,57 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
summaryCounts: [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => $successCount,
'failed' => max($failedCount, count($missingPolicyTypes)),
'succeeded' => 0,
'failed' => 0,
'skipped' => count($policyTypes),
],
failures: [
['code' => 'inventory.failed', 'message' => $reason],
['code' => 'inventory.skipped', 'message' => $reason],
],
);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.skipped',
context: [
'metadata' => [
'operation_run_id' => (int) $this->operationRun->getKey(),
'reason' => $reason,
],
],
actorId: $user->id,
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
return;
}
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
summaryCounts: [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => $successCount,
'failed' => max($failedCount, count($missingPolicyTypes)),
],
failures: [
['code' => 'inventory.failed', 'message' => $reason],
],
);
$auditLogger->log(
tenant: $tenant,
action: 'inventory.sync.failed',
context: [
'metadata' => [
'inventory_sync_run_id' => $run->id,
'selection_hash' => $run->selection_hash,
'operation_run_id' => (int) $this->operationRun->getKey(),
'reason' => $reason,
],
],
@ -255,9 +246,8 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
actorEmail: $user->email,
actorName: $user->name,
status: 'failure',
resourceType: 'inventory_sync_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
}

View File

@ -46,7 +46,7 @@ public function handle(PolicySyncService $service, OperationRunService $operatio
{
$graph = app(GraphClientInterface::class);
if (! config('graph.enabled') || $graph instanceof NullGraphClient) {
if ($graph instanceof NullGraphClient) {
if ($this->operationRun) {
$operationRunService->updateRun(
$this->operationRun,

View File

@ -0,0 +1,123 @@
<?php
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class SyncRoleDefinitionsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
/**
* Create a new job instance.
*/
public function __construct(
public int $tenantId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new TrackOperationRun];
}
/**
* Execute the job.
*/
public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $auditLogger): void
{
if (! $this->operationRun) {
$this->fail(new RuntimeException('OperationRun context is required for SyncRoleDefinitionsJob.'));
return;
}
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
throw new RuntimeException('Tenant not found.');
}
$auditLogger->log(
tenant: $tenant,
action: 'directory_role_definitions.sync.started',
context: [
'tenant_id' => (int) $tenant->getKey(),
],
actorId: $this->operationRun->user_id,
status: 'success',
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
$result = $syncService->sync($tenant);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$outcome = 'succeeded';
if ($result['error_code'] !== null) {
$outcome = 'failed';
} elseif ($result['safety_stop_triggered'] === true) {
$outcome = 'partially_succeeded';
}
$failures = [];
if (is_string($result['error_code']) && $result['error_code'] !== '') {
$failures[] = [
'code' => $result['error_code'],
'message' => is_string($result['error_summary']) ? $result['error_summary'] : 'Role definitions sync failed.',
];
}
$opService->updateRun(
$this->operationRun,
'completed',
$outcome,
[
'total' => $result['items_observed_count'],
'processed' => $result['items_observed_count'],
'updated' => $result['items_upserted_count'],
'failed' => $result['error_count'],
],
$failures,
);
$auditLogger->log(
tenant: $tenant,
action: $outcome === 'succeeded'
? 'directory_role_definitions.sync.succeeded'
: ($outcome === 'partially_succeeded'
? 'directory_role_definitions.sync.partial'
: 'directory_role_definitions.sync.failed'),
context: [
'pages_fetched' => $result['pages_fetched'],
'items_observed_count' => $result['items_observed_count'],
'items_upserted_count' => $result['items_upserted_count'],
'error_code' => $result['error_code'],
'error_category' => $result['error_category'],
'finished_at' => CarbonImmutable::now('UTC')->toIso8601String(),
],
actorId: $this->operationRun->user_id,
status: $outcome === 'failed' ? 'failed' : 'success',
resourceType: 'operation_run',
resourceId: (string) $this->operationRun->getKey(),
);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Listeners;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Services\OperationRunService;
use App\Support\RestoreRunStatus;
@ -42,12 +43,30 @@ public function handle(RestoreRun $restoreRun): void
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
];
$opRun = $this->service->ensureRun(
tenant: $restoreRun->tenant,
type: 'restore.execute',
inputs: $inputs,
initiator: null
);
$opRun = null;
if ($restoreRun->operation_run_id) {
$opRun = OperationRun::query()->whereKey($restoreRun->operation_run_id)->first();
if ($opRun?->type !== 'restore.execute') {
$opRun = null;
}
}
if (! $opRun) {
$opRun = $this->service->ensureRun(
tenant: $restoreRun->tenant,
type: 'restore.execute',
inputs: $inputs,
initiator: null
);
}
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
RestoreRun::withoutEvents(function () use ($restoreRun, $opRun): void {
$restoreRun->forceFill(['operation_run_id' => $opRun->getKey()])->save();
});
}
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status);

View File

@ -31,4 +31,15 @@ public function runs(): HasMany
{
return $this->hasMany(BackupScheduleRun::class);
}
public function operationRuns(): HasMany
{
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
->whereIn('type', [
'backup_schedule.run_now',
'backup_schedule.retry',
'backup_schedule.scheduled',
])
->where('context->backup_schedule_id', (int) $this->getKey());
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EntraRoleDefinition extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'is_built_in' => 'boolean',
'last_seen_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -27,4 +27,9 @@ public function lastSeenRun(): BelongsTo
{
return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id');
}
public function lastSeenOperationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id');
}
}

View File

@ -38,6 +38,11 @@ public function backupSet(): BelongsTo
return $this->belongsTo(BackupSet::class)->withTrashed();
}
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
public function scopeDeletable(Builder $query): Builder
{
return $query->whereIn('status', array_map(

View File

@ -68,7 +68,7 @@ public function toDatabase(object $notifiable): array
$url = match ($runType) {
'bulk_operation' => OperationRunLinks::view($runId, $tenant),
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
'directory_groups' => OperationRunLinks::view($runId, $tenant),
default => null,
};

View File

@ -3,11 +3,15 @@
namespace App\Policies;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Gate;
class OperationRunPolicy
{
@ -56,6 +60,39 @@ public function view(User $user, OperationRun $run): Response|bool
}
}
$requiredCapability = app(OperationRunCapabilityResolver::class)
->requiredCapabilityForType((string) $run->type);
if (! is_string($requiredCapability) || $requiredCapability === '') {
return true;
}
if (str_starts_with($requiredCapability, 'workspace')) {
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return Response::denyAsNotFound();
}
if (! Gate::forUser($user)->allows($requiredCapability, $workspace)) {
return Response::deny();
}
return true;
}
if ($tenantId > 0) {
$tenant = Tenant::query()->whereKey($tenantId)->first();
if (! $tenant instanceof Tenant) {
return Response::denyAsNotFound();
}
if (! Gate::forUser($user)->allows($requiredCapability, $tenant)) {
return Response::deny();
}
}
return true;
}
}

View File

@ -36,7 +36,7 @@ public function view(User $user, ProviderConnection $connection): Response|bool
return Response::denyAsNotFound();
}
$tenant = $this->currentTenant();
$tenant = $this->tenantForConnection($connection) ?? $this->currentTenant();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -78,7 +78,7 @@ public function update(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
$tenant = $this->currentTenant();
$tenant = $this->tenantForConnection($connection) ?? $this->currentTenant();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -106,7 +106,7 @@ public function delete(User $user, ProviderConnection $connection): Response|boo
return Response::denyAsNotFound();
}
$tenant = $this->currentTenant();
$tenant = $this->tenantForConnection($connection) ?? $this->currentTenant();
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return Response::denyAsNotFound();
@ -152,4 +152,17 @@ private function currentTenant(): ?Tenant
return Tenant::current();
}
private function tenantForConnection(ProviderConnection $connection): ?Tenant
{
if ($connection->relationLoaded('tenant') && $connection->tenant instanceof Tenant) {
return $connection->tenant;
}
if (is_int($connection->tenant_id) || is_numeric($connection->tenant_id)) {
return Tenant::query()->whereKey((int) $connection->tenant_id)->first();
}
return null;
}
}

View File

@ -5,8 +5,11 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\TenantRequiredPermissions;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\Workspaces\WorkspaceResource;
@ -37,6 +40,7 @@ class AdminPanelProvider extends PanelProvider
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('admin')
->path('admin')
->login(Login::class)
@ -44,8 +48,6 @@ public function panel(Panel $panel): Panel
ChooseWorkspace::registerRoutes($panel);
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
WorkspaceResource::registerRoutes($panel);
})
->colors([
'primary' => Color::Amber,
@ -104,9 +106,15 @@ public function panel(Panel $panel): Panel
)
->resources([
TenantResource::class,
PolicyResource::class,
ProviderConnectionResource::class,
InventoryItemResource::class,
WorkspaceResource::class,
])
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->pages([
InventoryCoverage::class,
TenantRequiredPermissions::class,
])
->widgets([
@ -124,6 +132,7 @@ public function panel(Panel $panel): Panel
SubstituteBindings::class,
'ensure-correct-guard:web',
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])

View File

@ -11,6 +11,7 @@
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@ -29,7 +30,6 @@ class TenantPanelProvider extends PanelProvider
public function panel(Panel $panel): Panel
{
$panel = $panel
->default()
->id('tenant')
->path('admin/t')
->login(Login::class)
@ -40,6 +40,23 @@ public function panel(Panel $panel): Panel
->colors([
'primary' => Color::Amber,
])
->navigationItems([
NavigationItem::make('Runs')
->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list')
->group('Monitoring')
->sort(10),
NavigationItem::make('Alerts')
->url(fn (): string => route('admin.monitoring.alerts'))
->icon('heroicon-o-bell-alert')
->group('Monitoring')
->sort(20),
NavigationItem::make('Audit Log')
->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list')
->group('Monitoring')
->sort(30),
])
->renderHook(
PanelsRenderHook::HEAD_END,
fn () => view('filament.partials.livewire-intercept-shim')->render()

View File

@ -4,11 +4,11 @@
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunType;
use Carbon\CarbonImmutable;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Log;
@ -17,6 +17,7 @@ class BackupScheduleDispatcher
public function __construct(
private readonly ScheduleTimeService $scheduleTimeService,
private readonly AuditLogger $auditLogger,
private readonly OperationRunService $operationRunService,
) {}
/**
@ -62,23 +63,29 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
continue;
}
$run = null;
$scheduledFor = $slot->startOfMinute();
try {
$run = BackupScheduleRun::create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $schedule->tenant_id,
'scheduled_for' => $slot->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
} catch (UniqueConstraintViolationException) {
// Idempotency: unique (backup_schedule_id, scheduled_for)
$operationRun = $this->operationRunService->ensureRunWithIdentityStrict(
tenant: $schedule->tenant,
type: OperationRunType::BackupScheduleScheduled->value,
identityInputs: [
'backup_schedule_id' => (int) $schedule->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
],
context: [
'backup_schedule_id' => (int) $schedule->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'trigger' => 'scheduled',
],
);
if (! $operationRun->wasRecentlyCreated) {
$skippedRuns++;
Log::debug('Backup schedule run already dispatched for slot.', [
Log::debug('Backup schedule operation already dispatched for slot.', [
'schedule_id' => $schedule->id,
'slot' => $slot->toDateTimeString(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
'operation_run_id' => $operationRun->getKey(),
]);
$schedule->forceFill([
@ -96,12 +103,12 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
context: [
'metadata' => [
'backup_schedule_id' => $schedule->id,
'backup_schedule_run_id' => $run->id,
'scheduled_for' => $slot->toDateTimeString(),
'operation_run_id' => $operationRun->getKey(),
'scheduled_for' => $scheduledFor->toDateTimeString(),
],
],
resourceType: 'backup_schedule_run',
resourceId: (string) $run->id,
resourceType: 'operation_run',
resourceId: (string) $operationRun->getKey(),
status: 'success'
);
@ -109,7 +116,7 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc),
])->saveQuietly();
Bus::dispatch(new RunBackupScheduleJob($run->id));
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id));
}
return [

View File

@ -3,9 +3,11 @@
namespace App\Services\Directory;
use App\Models\EntraGroup;
use App\Models\EntraGroupSyncRun;
use App\Jobs\EntraGroupSyncJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
@ -18,36 +20,36 @@ public function __construct(
private readonly GraphContractRegistry $contracts,
) {}
public function startManualSync(Tenant $tenant, User $user): EntraGroupSyncRun
public function startManualSync(Tenant $tenant, User $user): OperationRun
{
$selectionKey = EntraGroupSelection::allGroupsV1();
$existing = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', $selectionKey)
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
->orderByDesc('id')
->first();
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'directory_groups.sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
);
if ($existing instanceof EntraGroupSyncRun) {
return $existing;
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
$run = EntraGroupSyncRun::create([
'tenant_id' => $tenant->getKey(),
'selection_key' => $selectionKey,
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
dispatch(new \App\Jobs\EntraGroupSyncJob(
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: (int) $run->getKey(),
runId: null,
operationRun: $opRun,
));
return $run;
return $opRun;
}
/**
@ -63,7 +65,7 @@ public function startManualSync(Tenant $tenant, User $user): EntraGroupSyncRun
* error_summary:?string
* }
*/
public function sync(Tenant $tenant, EntraGroupSyncRun $run): array
public function sync(Tenant $tenant, string $selectionKey): array
{
$nowUtc = CarbonImmutable::now('UTC');

View File

@ -0,0 +1,257 @@
<?php
namespace App\Services\Directory;
use App\Jobs\SyncRoleDefinitionsJob;
use App\Models\EntraRoleDefinition;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
class RoleDefinitionsSyncService
{
public function __construct(
private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts,
) {}
public function startManualSync(Tenant $tenant, User $user): OperationRun
{
$selectionKey = 'role_definitions_v1';
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'directory_role_definitions.sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new SyncRoleDefinitionsJob(
tenantId: (int) $tenant->getKey(),
operationRun: $opRun,
));
return $opRun;
}
/**
* @return array{
* pages_fetched:int,
* items_observed_count:int,
* items_upserted_count:int,
* error_count:int,
* safety_stop_triggered:bool,
* safety_stop_reason:?string,
* error_code:?string,
* error_category:?string,
* error_summary:?string
* }
*/
public function sync(Tenant $tenant): array
{
$nowUtc = CarbonImmutable::now('UTC');
$policyType = $this->contracts->directoryRoleDefinitionsPolicyType();
$path = $this->contracts->directoryRoleDefinitionsListPath();
$contract = $this->contracts->get($policyType);
$query = [];
if (isset($contract['allowed_select']) && is_array($contract['allowed_select']) && $contract['allowed_select'] !== []) {
$query['$select'] = $contract['allowed_select'];
}
$pageSize = (int) config('directory_role_definitions.page_size', 200);
if ($pageSize > 0) {
$query['$top'] = $pageSize;
}
$sanitized = $this->contracts->sanitizeQuery($policyType, $query);
$query = $sanitized['query'];
$maxPages = (int) config('directory_role_definitions.safety_stop.max_pages', 50);
$maxRuntimeSeconds = (int) config('directory_role_definitions.safety_stop.max_runtime_seconds', 120);
$deadline = $nowUtc->addSeconds(max(1, $maxRuntimeSeconds));
$pagesFetched = 0;
$observed = 0;
$upserted = 0;
$safetyStopTriggered = false;
$safetyStopReason = null;
$errorCode = null;
$errorCategory = null;
$errorSummary = null;
$errorCount = 0;
$options = $tenant->graphOptions();
$useQuery = $query;
$nextPath = $path;
while ($nextPath) {
if (CarbonImmutable::now('UTC')->greaterThan($deadline)) {
$safetyStopTriggered = true;
$safetyStopReason = 'runtime_exceeded';
break;
}
if ($pagesFetched >= $maxPages) {
$safetyStopTriggered = true;
$safetyStopReason = 'max_pages_exceeded';
break;
}
$response = $this->requestWithRetry('GET', $nextPath, $options + ['query' => $useQuery]);
if ($response->failed()) {
[$errorCode, $errorCategory, $errorSummary] = $this->categorizeError($response);
$errorCount = 1;
break;
}
$pagesFetched++;
$data = $response->data;
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
if (is_array($pageItems)) {
foreach ($pageItems as $item) {
if (! is_array($item)) {
continue;
}
$entraId = $item['id'] ?? null;
if (! is_string($entraId) || $entraId === '') {
continue;
}
$displayName = $item['displayName'] ?? null;
$isBuiltIn = (bool) ($item['isBuiltIn'] ?? false);
$values = [
'display_name' => is_string($displayName) ? $displayName : $entraId,
'is_built_in' => $isBuiltIn,
'last_seen_at' => $nowUtc,
];
EntraRoleDefinition::query()->updateOrCreate([
'tenant_id' => $tenant->getKey(),
'entra_id' => $entraId,
], $values);
$observed++;
$upserted++;
}
}
$nextLink = is_array($data) ? ($data['@odata.nextLink'] ?? null) : null;
if (! is_string($nextLink) || $nextLink === '') {
break;
}
$nextPath = $this->stripGraphBaseUrl($nextLink);
$useQuery = [];
}
$retentionDays = (int) config('directory_role_definitions.retention_days', 90);
if ($retentionDays > 0) {
$cutoff = $nowUtc->subDays($retentionDays);
EntraRoleDefinition::query()
->where('tenant_id', $tenant->getKey())
->whereNotNull('last_seen_at')
->where('last_seen_at', '<', $cutoff)
->delete();
}
return [
'pages_fetched' => $pagesFetched,
'items_observed_count' => $observed,
'items_upserted_count' => $upserted,
'error_count' => $errorCount,
'safety_stop_triggered' => $safetyStopTriggered,
'safety_stop_reason' => $safetyStopReason,
'error_code' => $errorCode,
'error_category' => $errorCategory,
'error_summary' => $errorSummary,
];
}
private function requestWithRetry(string $method, string $path, array $options): GraphResponse
{
$maxRetries = (int) config('directory_role_definitions.safety_stop.max_retries', 6);
$maxRetries = max(0, $maxRetries);
for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
$response = $this->graph->request($method, $path, $options);
if ($response->successful()) {
return $response;
}
$status = (int) ($response->status ?? 0);
if (! in_array($status, [429, 503], true) || $attempt >= $maxRetries) {
return $response;
}
$baseDelaySeconds = min(30, 1 << $attempt);
$jitterMillis = random_int(0, 250);
usleep(($baseDelaySeconds * 1000 + $jitterMillis) * 1000);
}
return new GraphResponse(success: false, data: [], status: 500, errors: [['message' => 'Retry loop exceeded']]);
}
/**
* @return array{0:string,1:string,2:string}
*/
private function categorizeError(GraphResponse $response): array
{
$status = (int) ($response->status ?? 0);
if (in_array($status, [401, 403], true)) {
return ['permission_denied', 'permission', 'Graph permission denied for role definitions listing.'];
}
if ($status === 429) {
return ['throttled', 'throttling', 'Graph throttled the role definitions listing request.'];
}
if (in_array($status, [500, 502, 503, 504], true)) {
return ['graph_unavailable', 'transient', 'Graph returned a transient server error.'];
}
return ['graph_request_failed', 'unknown', 'Graph request failed.'];
}
private function stripGraphBaseUrl(string $nextLink): string
{
$base = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/')
.'/'.trim((string) config('graph.version', 'v1.0'), '/');
if (str_starts_with($nextLink, $base)) {
return ltrim((string) substr($nextLink, strlen($base)), '/');
}
return ltrim($nextLink, '/');
}
}

View File

@ -37,6 +37,18 @@ public function directoryGroupsListPath(): string
return '/'.ltrim($resource, '/');
}
public function directoryRoleDefinitionsPolicyType(): string
{
return 'directoryRoleDefinitions';
}
public function directoryRoleDefinitionsListPath(): string
{
$resource = $this->resourcePath($this->directoryRoleDefinitionsPolicyType()) ?? 'deviceManagement/roleDefinitions';
return '/'.ltrim($resource, '/');
}
/**
* @return array<string, mixed>
*/

View File

@ -3,7 +3,7 @@
namespace App\Services\Inventory;
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\BackupScheduling\PolicyTypeResolver;
use Illuminate\Database\Eloquent\Collection;
@ -17,7 +17,7 @@ public function __construct(
/**
* @param array<string, mixed> $selectionPayload
* @return array{latestRun: InventorySyncRun|null, missing: Collection<int, InventoryItem>, lowConfidence: bool}
* @return array{latestRun: OperationRun|null, missing: Collection<int, InventoryItem>, lowConfidence: bool}
*/
public function missingForSelection(Tenant $tenant, array $selectionPayload): array
{
@ -25,16 +25,12 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar
$normalized['policy_types'] = $this->policyTypeResolver->filterRuntime($normalized['policy_types']);
$selectionHash = $this->selectionHasher->hash($normalized);
$latestRun = InventorySyncRun::query()
$latestRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_hash', $selectionHash)
->whereIn('status', [
InventorySyncRun::STATUS_SUCCESS,
InventorySyncRun::STATUS_PARTIAL,
InventorySyncRun::STATUS_FAILED,
InventorySyncRun::STATUS_SKIPPED,
])
->orderByDesc('finished_at')
->where('type', 'inventory.sync')
->where('status', 'completed')
->where('context->selection_hash', $selectionHash)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
@ -51,11 +47,11 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar
->whereIn('policy_type', $normalized['policy_types'])
->where(function ($query) use ($latestRun): void {
$query
->whereNull('last_seen_run_id')
->orWhere('last_seen_run_id', '!=', $latestRun->getKey());
->whereNull('last_seen_operation_run_id')
->orWhere('last_seen_operation_run_id', '!=', $latestRun->getKey());
});
$lowConfidence = $latestRun->status !== InventorySyncRun::STATUS_SUCCESS || (bool) ($latestRun->had_errors ?? false);
$lowConfidence = $latestRun->outcome !== 'succeeded';
return [
'latestRun' => $latestRun,

View File

@ -4,18 +4,21 @@
use App\Models\InventoryItem;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BackupScheduling\PolicyTypeResolver;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
@ -31,7 +34,9 @@ public function __construct(
) {}
/**
* Runs an inventory sync inline (no queue), enforcing locks/concurrency and creating an observable run record.
* Runs an inventory sync immediately and persists a corresponding InventorySyncRun.
*
* This is primarily used in tests and for synchronous workflows.
*
* @param array<string, mixed> $selectionPayload
*/
@ -41,30 +46,100 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
$normalizedSelection = $computed['selection'];
$selectionHash = $computed['selection_hash'];
$now = CarbonImmutable::now('UTC');
$operationRun = OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'initiator_name' => 'System',
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()),
'context' => $normalizedSelection,
'started_at' => now(),
]);
$run = InventorySyncRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'operation_run_id' => (int) $operationRun->getKey(),
'selection_hash' => $selectionHash,
'selection_payload' => $normalizedSelection,
'status' => InventorySyncRun::STATUS_RUNNING,
'had_errors' => false,
'started_at' => now(),
]);
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection);
$status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED);
$hadErrors = (bool) ($result['had_errors'] ?? true);
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null;
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
$run->update([
'status' => $status,
'had_errors' => $hadErrors,
'error_codes' => $errorCodes,
'error_context' => $errorContext,
'items_observed_count' => (int) ($result['items_observed_count'] ?? 0),
'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0),
'errors_count' => (int) ($result['errors_count'] ?? 0),
'finished_at' => now(),
]);
$policyTypes = $normalizedSelection['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
$operationOutcome = match ($status) {
'success' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value,
'skipped' => OperationRunOutcome::Blocked->value,
default => OperationRunOutcome::Failed->value,
};
$operationRun->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => $operationOutcome,
'summary_counts' => [
'total' => count($policyTypes),
'processed' => count($policyTypes),
'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)),
'failed' => (int) ($result['errors_count'] ?? 0),
'items' => (int) ($result['items_observed_count'] ?? 0),
'updated' => (int) ($result['items_upserted_count'] ?? 0),
],
'completed_at' => now(),
]);
return $run->refresh();
}
/**
* Runs an inventory sync (inline), enforcing locks/concurrency.
*
* This method MUST NOT create or update InventorySyncRun rows; OperationRun is canonical.
*
* @param array<string, mixed> $selectionPayload
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
*/
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
{
$computed = $this->normalizeAndHashSelection($selectionPayload);
$normalizedSelection = $computed['selection'];
$selectionHash = $computed['selection_hash'];
$globalSlot = $this->concurrencyLimiter->acquireGlobalSlot();
if (! $globalSlot instanceof Lock) {
return $this->createSkippedRun(
tenant: $tenant,
selectionHash: $selectionHash,
selectionPayload: $normalizedSelection,
now: $now,
errorCode: 'concurrency_limit_global',
);
return $this->skippedResult('concurrency_limit_global');
}
$tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id);
if (! $tenantSlot instanceof Lock) {
$globalSlot->release();
return $this->createSkippedRun(
tenant: $tenant,
selectionHash: $selectionHash,
selectionPayload: $normalizedSelection,
now: $now,
errorCode: 'concurrency_limit_tenant',
);
return $this->skippedResult('concurrency_limit_tenant');
}
$selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900);
@ -72,33 +147,11 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
$tenantSlot->release();
$globalSlot->release();
return $this->createSkippedRun(
tenant: $tenant,
selectionHash: $selectionHash,
selectionPayload: $normalizedSelection,
now: $now,
errorCode: 'lock_contended',
);
return $this->skippedResult('lock_contended');
}
$run = InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => null,
'selection_hash' => $selectionHash,
'selection_payload' => $normalizedSelection,
'status' => InventorySyncRun::STATUS_RUNNING,
'had_errors' => false,
'error_codes' => [],
'error_context' => null,
'started_at' => $now,
'finished_at' => null,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
try {
return $this->executeRun($run, $tenant, $normalizedSelection);
return $this->executeSelectionUnderLock($operationRun, $tenant, $normalizedSelection, $onPolicyTypeProcessed);
} finally {
$selectionLock->release();
$tenantSlot->release();
@ -135,110 +188,12 @@ public function normalizeAndHashSelection(array $selectionPayload): array
];
}
/**
* Creates a pending run record attributed to the initiating user so the run remains observable even if queue workers are down.
*
* @param array<string, mixed> $selectionPayload
*/
public function createPendingRunForUser(Tenant $tenant, User $user, array $selectionPayload): InventorySyncRun
{
$computed = $this->normalizeAndHashSelection($selectionPayload);
return InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'selection_hash' => $computed['selection_hash'],
'selection_payload' => $computed['selection'],
'status' => InventorySyncRun::STATUS_PENDING,
'had_errors' => false,
'error_codes' => [],
'error_context' => null,
'started_at' => null,
'finished_at' => null,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
}
/**
* Executes an existing pending run under locks/concurrency, updating that run to running/skipped/terminal.
*/
/**
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
*/
public function executePendingRun(InventorySyncRun $run, Tenant $tenant, ?callable $onPolicyTypeProcessed = null): InventorySyncRun
{
$computed = $this->normalizeAndHashSelection($run->selection_payload ?? []);
$normalizedSelection = $computed['selection'];
$selectionHash = $computed['selection_hash'];
$now = CarbonImmutable::now('UTC');
$run->update([
'tenant_id' => $tenant->getKey(),
'selection_hash' => $selectionHash,
'selection_payload' => $normalizedSelection,
'status' => InventorySyncRun::STATUS_RUNNING,
'had_errors' => false,
'error_codes' => [],
'error_context' => null,
'started_at' => $now,
'finished_at' => null,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
$globalSlot = $this->concurrencyLimiter->acquireGlobalSlot();
if (! $globalSlot instanceof Lock) {
return $this->markExistingRunSkipped(
run: $run,
now: $now,
errorCode: 'concurrency_limit_global',
);
}
$tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id);
if (! $tenantSlot instanceof Lock) {
$globalSlot->release();
return $this->markExistingRunSkipped(
run: $run,
now: $now,
errorCode: 'concurrency_limit_tenant',
);
}
$selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900);
if (! $selectionLock->get()) {
$tenantSlot->release();
$globalSlot->release();
return $this->markExistingRunSkipped(
run: $run,
now: $now,
errorCode: 'lock_contended',
);
}
try {
return $this->executeRun($run, $tenant, $normalizedSelection, $onPolicyTypeProcessed);
} finally {
$selectionLock->release();
$tenantSlot->release();
$globalSlot->release();
}
}
/**
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
*/
/**
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
*/
private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): InventorySyncRun
private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array
{
$observed = 0;
$upserted = 0;
@ -349,7 +304,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
'platform' => $typeConfig['platform'] ?? null,
'meta_jsonb' => $meta,
'last_seen_at' => now(),
'last_seen_run_id' => $run->getKey(),
'last_seen_operation_run_id' => (int) $operationRun->getKey(),
]
);
@ -368,10 +323,8 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
}
$status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS;
$run->update([
'status' => $status,
return [
'status' => $hadErrors ? 'partial' : 'success',
'had_errors' => $hadErrors,
'error_codes' => array_values(array_unique($errorCodes)),
'error_context' => [
@ -380,29 +333,39 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
'items_observed_count' => $observed,
'items_upserted_count' => $upserted,
'errors_count' => $errors,
'finished_at' => CarbonImmutable::now('UTC'),
]);
return $run->refresh();
];
} catch (Throwable $throwable) {
$errorContext = $this->safeErrorContext($throwable);
$errorContext['warnings'] = array_values($warnings);
$run->update([
'status' => InventorySyncRun::STATUS_FAILED,
return [
'status' => 'failed',
'had_errors' => true,
'error_codes' => ['unexpected_exception'],
'error_context' => $errorContext,
'items_observed_count' => $observed,
'items_upserted_count' => $upserted,
'errors_count' => $errors + 1,
'finished_at' => CarbonImmutable::now('UTC'),
]);
return $run->refresh();
];
}
}
/**
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
*/
private function skippedResult(string $errorCode): array
{
return [
'status' => 'skipped',
'had_errors' => true,
'error_codes' => [$errorCode],
'error_context' => null,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
];
}
private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, array $policyData): bool
{
$configurationPolicyTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
@ -556,50 +519,6 @@ private function selectionLockKey(Tenant $tenant, string $selectionHash): string
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
}
/**
* @param array<string, mixed> $selectionPayload
*/
private function createSkippedRun(
Tenant $tenant,
string $selectionHash,
array $selectionPayload,
CarbonImmutable $now,
string $errorCode,
): InventorySyncRun {
return InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => null,
'selection_hash' => $selectionHash,
'selection_payload' => $selectionPayload,
'status' => InventorySyncRun::STATUS_SKIPPED,
'had_errors' => true,
'error_codes' => [$errorCode],
'error_context' => null,
'started_at' => $now,
'finished_at' => $now,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
}
private function markExistingRunSkipped(InventorySyncRun $run, CarbonImmutable $now, string $errorCode): InventorySyncRun
{
$run->update([
'status' => InventorySyncRun::STATUS_SKIPPED,
'had_errors' => true,
'error_codes' => [$errorCode],
'error_context' => null,
'started_at' => $run->started_at ?? $now,
'finished_at' => $now,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
return $run->refresh();
}
private function mapGraphFailureToErrorCode(GraphResponse $response): string
{
$status = (int) ($response->status ?? 0);

View File

@ -184,6 +184,64 @@ public function ensureRunWithIdentity(
}
}
public function ensureRunWithIdentityStrict(
Tenant $tenant,
string $type,
array $identityInputs,
array $context,
?User $initiator = null,
): OperationRun {
$workspaceId = (int) ($tenant->workspace_id ?? 0);
if ($workspaceId <= 0) {
throw new InvalidArgumentException('Tenant must belong to a workspace to start an operation run.');
}
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('workspace_id', $workspaceId)
->where('type', $type)
->where('run_identity_hash', $hash)
->first();
if ($existing instanceof OperationRun) {
return $existing;
}
try {
return OperationRun::create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->id,
'user_id' => $initiator?->id,
'initiator_name' => $initiator?->name ?? 'System',
'type' => $type,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'run_identity_hash' => $hash,
'context' => $context,
]);
} catch (QueryException $e) {
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
throw $e;
}
$existing = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('workspace_id', $workspaceId)
->where('type', $type)
->where('run_identity_hash', $hash)
->first();
if ($existing instanceof OperationRun) {
return $existing;
}
throw $e;
}
}
/**
* Standardized enqueue helper for bulk operations.
*

View File

@ -10,6 +10,7 @@
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\BlockedVerificationReportFactory;
use App\Support\Verification\StaleQueuedVerificationReportFactory;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
@ -73,8 +74,27 @@ public function start(
->active()
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
->orderByDesc('id')
->lockForUpdate()
->first();
if ($activeRun instanceof OperationRun) {
if ($this->runs->isStaleQueuedRun($activeRun)) {
$this->runs->failStaleQueuedRun($activeRun);
if ($activeRun->type === 'provider.connection.check') {
VerificationReportWriter::write(
run: $activeRun,
checks: StaleQueuedVerificationReportFactory::checks($activeRun),
identity: StaleQueuedVerificationReportFactory::identity($activeRun),
);
$activeRun->refresh();
}
$activeRun = null;
}
}
if ($activeRun instanceof OperationRun) {
if ($activeRun->type === $operationType) {
return ProviderOperationStartResult::deduped($activeRun);

View File

@ -34,6 +34,12 @@ public function handle(Request $request, Closure $next): Response
$existingTenant = Filament::getTenant();
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
Filament::setTenant(null, true);
$existingTenant = null;
}
$user = $request->user();
if ($existingTenant instanceof Tenant && $user instanceof User && ! $user->canAccessTenant($existingTenant)) {
Filament::setTenant(null, true);
}
if ($path === '/livewire/update') {
@ -59,7 +65,14 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$tenantParameter = null;
if ($request->route()?->hasParameter('tenant')) {
$tenantParameter = $request->route()->parameter('tenant');
} elseif (filled($request->query('tenant'))) {
$tenantParameter = $request->query('tenant');
}
if ($tenantParameter !== null) {
$user = $request->user();
if ($user === null) {
@ -70,12 +83,9 @@ public function handle(Request $request, Closure $next): Response
abort(404);
}
if (! $panel->hasTenancy()) {
return $next($request);
}
$tenantParameter = $request->route()->parameter('tenant');
$tenant = $panel->getTenant($tenantParameter);
$tenant = $tenantParameter instanceof Tenant
? $tenantParameter
: Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
if (! $tenant instanceof Tenant) {
abort(404);
@ -129,8 +139,6 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
$this->configureNavigationForRequest($panel);

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Support\OperateHub;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
final class OperateHubShell
{
public function __construct(
private WorkspaceContext $workspaceContext,
private CapabilityResolver $capabilityResolver,
) {}
public function scopeLabel(?Request $request = null): string
{
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return 'Scope: Tenant — '.$activeTenant->name;
}
return 'Scope: Workspace — all tenants';
}
/**
* @return array{label: string, url: string}|null
*/
public function returnAffordance(?Request $request = null): ?array
{
$activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof Tenant) {
return [
'label' => 'Back to '.$activeTenant->name,
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
];
}
return null;
}
/**
* @return array<Action>
*/
public function headerActions(
string $scopeActionName = 'operate_hub_scope',
string $returnActionName = 'operate_hub_return',
?Request $request = null,
): array {
$actions = [
Action::make($scopeActionName)
->label($this->scopeLabel($request))
->color('gray')
->disabled(),
];
$returnAffordance = $this->returnAffordance($request);
if (is_array($returnAffordance)) {
$actions[] = Action::make($returnActionName)
->label($returnAffordance['label'])
->icon('heroicon-o-arrow-left')
->color('gray')
->url($returnAffordance['url']);
}
return $actions;
}
public function activeEntitledTenant(?Request $request = null): ?Tenant
{
return $this->resolveActiveTenant($request);
}
private function resolveActiveTenant(?Request $request = null): ?Tenant
{
$tenant = Filament::getTenant();
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
return $tenant;
}
$rememberedTenantId = $this->workspaceContext->lastTenantId($request);
if ($rememberedTenantId === null) {
return null;
}
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
if (! $rememberedTenant instanceof Tenant) {
return null;
}
if (! $this->isEntitled($rememberedTenant, $request)) {
return null;
}
return $rememberedTenant;
}
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
{
if (! $tenant->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
}
return $this->capabilityResolver->isMember($user, $tenant);
}
}

View File

@ -30,7 +30,9 @@ public static function labels(): array
'backup_set.force_delete' => 'Delete backup sets',
'backup_schedule.run_now' => 'Backup schedule run',
'backup_schedule.retry' => 'Backup schedule retry',
'backup_schedule.scheduled' => 'Backup schedule run',
'restore.execute' => 'Restore execution',
'directory_role_definitions.sync' => 'Role definitions sync',
'restore_run.delete' => 'Delete restore runs',
'restore_run.restore' => 'Restore restore runs',
'restore_run.force_delete' => 'Force delete restore runs',

View File

@ -13,6 +13,8 @@ enum OperationRunType: string
case BackupSetRemovePolicies = 'backup_set.remove_policies';
case BackupScheduleRunNow = 'backup_schedule.run_now';
case BackupScheduleRetry = 'backup_schedule.retry';
case BackupScheduleScheduled = 'backup_schedule.scheduled';
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
case RestoreExecute = 'restore.execute';
public static function values(): array

View File

@ -0,0 +1,32 @@
<?php
namespace App\Support\Operations;
use App\Support\Auth\Capabilities;
final class OperationRunCapabilityResolver
{
public function requiredCapabilityForType(string $operationType): ?string
{
$operationType = trim($operationType);
if ($operationType === '') {
return null;
}
return match ($operationType) {
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'directory_groups.sync' => Capabilities::TENANT_SYNC,
'backup_schedule.run_now', 'backup_schedule.retry', 'backup_schedule.scheduled' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
'restore.execute' => Capabilities::TENANT_MANAGE,
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
// Viewing verification reports should be possible for readonly members.
// Starting verification is separately guarded by the verification service.
'provider.connection.check' => Capabilities::PROVIDER_VIEW,
// Keep legacy / unknown types viewable by membership+entitlement only.
default => null,
};
}
}

View File

@ -27,6 +27,10 @@ public function forReason(Tenant $tenant, string $reasonCode, ?ProviderConnectio
ProviderReasonCodes::ProviderCredentialInvalid,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderConsentMissing => [
[
'label' => 'Grant admin consent',
'url' => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
],
[
'label' => $connection instanceof ProviderConnection ? 'Update Credentials' : 'Manage Provider Connections',
'url' => $connection instanceof ProviderConnection

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Models\OperationRun;
final class StaleQueuedVerificationReportFactory
{
/**
* @return array<int, array<string, mixed>>
*/
public static function checks(OperationRun $run): array
{
$context = is_array($run->context ?? null) ? $run->context : [];
return [[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'unknown_error',
'message' => 'Run was queued but never started. A queue worker may not be running.',
'evidence' => self::evidence($run, $context),
'next_steps' => [],
]];
}
/**
* @return array<string, mixed>
*/
public static function identity(OperationRun $run): array
{
$context = is_array($run->context ?? null) ? $run->context : [];
$identity = [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
if (is_numeric($providerConnectionId)) {
$identity['provider_connection_id'] = (int) $providerConnectionId;
}
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
}
return $identity;
}
/**
* @param array<string, mixed> $context
* @return array<int, array{kind: string, value: int|string}>
*/
private static function evidence(OperationRun $run, array $context): array
{
$evidence = [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
if (is_numeric($providerConnectionId)) {
$evidence[] = [
'kind' => 'provider_connection_id',
'value' => (int) $providerConnectionId,
];
}
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
];
}
$evidence[] = [
'kind' => 'operation_run_id',
'value' => (int) $run->getKey(),
];
return $evidence;
}
}

View File

@ -28,6 +28,11 @@
'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'],
'allowed_expand' => [],
],
'directoryRoleDefinitions' => [
'resource' => 'deviceManagement/roleDefinitions',
'allowed_select' => ['id', 'displayName', 'isBuiltIn'],
'allowed_expand' => [],
],
'managedDevices' => [
'resource' => 'deviceManagement/managedDevices',
'allowed_select' => ['id', 'complianceState'],

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\EntraRoleDefinition;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EntraRoleDefinition>
*/
class EntraRoleDefinitionFactory extends Factory
{
protected $model = EntraRoleDefinition::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'entra_id' => fake()->uuid(),
'display_name' => fake()->jobTitle(),
'is_built_in' => false,
'last_seen_at' => now('UTC'),
];
}
}

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('operation_runs')) {
return;
}
$driver = Schema::getConnection()->getDriverName();
if (! in_array($driver, ['pgsql', 'sqlite'], true)) {
return;
}
DB::statement(<<<'SQL'
CREATE UNIQUE INDEX IF NOT EXISTS operation_runs_backup_schedule_scheduled_unique
ON operation_runs (tenant_id, run_identity_hash)
WHERE type = 'backup_schedule.scheduled'
SQL);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (! Schema::hasTable('operation_runs')) {
return;
}
$driver = Schema::getConnection()->getDriverName();
if (! in_array($driver, ['pgsql', 'sqlite'], true)) {
return;
}
DB::statement('DROP INDEX IF EXISTS operation_runs_backup_schedule_scheduled_unique');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventory_sync_runs', function (Blueprint $table) {
$table
->foreignId('operation_run_id')
->nullable()
->constrained('operation_runs')
->nullOnDelete()
->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_sync_runs', function (Blueprint $table) {
$table->dropForeign(['operation_run_id']);
$table->dropColumn('operation_run_id');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('entra_group_sync_runs', function (Blueprint $table) {
$table
->foreignId('operation_run_id')
->nullable()
->constrained('operation_runs')
->nullOnDelete()
->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('entra_group_sync_runs', function (Blueprint $table) {
$table->dropForeign(['operation_run_id']);
$table->dropColumn('operation_run_id');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backup_schedule_runs', function (Blueprint $table) {
$table
->foreignId('operation_run_id')
->nullable()
->constrained('operation_runs')
->nullOnDelete()
->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('backup_schedule_runs', function (Blueprint $table) {
$table->dropForeign(['operation_run_id']);
$table->dropColumn('operation_run_id');
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventory_items', function (Blueprint $table) {
$table->foreignId('last_seen_operation_run_id')
->nullable()
->after('last_seen_run_id')
->constrained('operation_runs')
->nullOnDelete();
$table->index('last_seen_operation_run_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_items', function (Blueprint $table) {
$table->dropConstrainedForeignId('last_seen_operation_run_id');
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('restore_runs', function (Blueprint $table) {
$table
->foreignId('operation_run_id')
->nullable()
->constrained('operation_runs')
->nullOnDelete()
->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('restore_runs', function (Blueprint $table) {
$table->dropConstrainedForeignId('operation_run_id');
});
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('entra_role_definitions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->uuid('entra_id');
$table->string('display_name');
$table->boolean('is_built_in')->default(false);
$table->timestampTz('last_seen_at')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'entra_id']);
$table->index(['tenant_id', 'display_name']);
$table->index(['tenant_id', 'last_seen_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('entra_role_definitions');
}
};

View File

@ -18,7 +18,7 @@
</include>
</source>
<php>
<ini name="memory_limit" value="512M"/>
<ini name="memory_limit" value="2048M"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:z63PQuXp3rUOQ0L4o8xp76xeakrn5X3owja1qFX3ccY="/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>

View File

@ -1,6 +1,7 @@
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Alerts is reserved for future work.
<x-filament-panels::page>
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Alerts is reserved for future work.
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -1,6 +1,7 @@
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Audit Log is reserved for future work.
<x-filament-panels::page>
<div class="space-y-2">
<div class="text-sm text-gray-600 dark:text-gray-300">
Audit Log is reserved for future work.
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -5,6 +5,7 @@
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
@ -40,9 +41,12 @@
}
}
$currentTenant = Filament::getTenant();
$operateHubShell = app(OperateHubShell::class);
$currentTenant = $operateHubShell->activeEntitledTenant(request());
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
$path = '/'.ltrim(request()->path(), '/');
$route = request()->route();
$routeName = (string) ($route?->getName() ?? '');
@ -65,7 +69,7 @@
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
@endphp
<div class="flex items-center gap-3">
@ -83,7 +87,7 @@
<x-filament::dropdown.list>
<a
href="{{ ChooseWorkspace::getUrl(panel: 'admin') }}"
href="{{ route('filament.admin.resources.workspaces.index') }}"
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
Switch workspace
@ -174,7 +178,7 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
@csrf
<x-filament::button color="gray" size="sm" outlined>
<x-filament::button type="submit" color="gray" size="sm" outlined>
Clear tenant context
</x-filament::button>
</form>

View File

@ -27,7 +27,7 @@
Route::get('/admin/consent/start', TenantOnboardingController::class)
->name('admin.consent.start');
// Panel root override: keep the app's workspace-first flow.
// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
// when no default tenant can be resolved.
Route::middleware([
@ -143,6 +143,7 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
->name('admin.operations.index');
@ -155,6 +156,20 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/t/{tenant:external_id}/operations', fn () => redirect()->route('admin.operations.index'))
->name('admin.operations.legacy-tenant-index');
Route::middleware([
'web',
'panel:admin',
'ensure-correct-guard:web',
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
->name('admin.monitoring.alerts');
@ -167,6 +182,7 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
->name('admin.monitoring.audit-log');
@ -179,6 +195,7 @@
DispatchServingFilamentEvent::class,
FilamentAuthenticate::class,
'ensure-workspace-selected',
'ensure-filament-tenant-selected',
])
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
->name('admin.operations.view');

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Tenant Operate Hub / Tenant Overview IA
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-09
**Feature**: [specs/085-tenant-operate-hub/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
- Dependencies/assumptions used: canonical monitoring surfaces exist at `/admin/operations`, `/admin/operations/{run}`, `/admin/alerts`, `/admin/audit-log`; tenant plane exists at `/admin/t/{tenant}`; tenant context can be active or absent; authorization semantics remain consistent (deny-as-not-found vs forbidden); Monitoring views are view-only render surfaces that must not initiate outbound calls on render.

View File

@ -0,0 +1,76 @@
openapi: 3.0.3
info:
title: Tenant Operate Hub / Central Monitoring (UI Route Contracts)
version: 0.1.0
description: |
Internal documentation of canonical central Monitoring surfaces.
These are Filament page routes (not a public API). The contract is used to
pin down URL shapes and security semantics (404 vs 403) for acceptance.
servers:
- url: /
paths:
/admin/operations:
get:
summary: Central Monitoring - Operations index
description: |
Canonical operations list. Must render without outbound calls.
Scope semantics:
- If tenant context is active AND entitled: page is tenant-filtered by default and shows tenant-scoped header/CTAs.
- If tenant context is absent: page is workspace-wide.
- If tenant context is active but not entitled: page behaves workspace-wide and must not reveal tenant identity.
responses:
'200':
description: OK
'302':
description: Redirect to choose workspace if none selected
'403':
description: Authenticated but forbidden (capability denial after membership)
'404':
description: Deny-as-not-found when not entitled to workspace scope
/admin/clear-tenant-context:
post:
summary: Exit tenant context (Monitoring)
description: |
Clears the active tenant context for the current session.
Used by “Show all tenants” on central Monitoring pages.
responses:
'302':
description: Redirect back to a canonical Monitoring page
'404':
description: Deny-as-not-found when not entitled to workspace scope
/admin/operations/{run}:
get:
summary: Central Monitoring - Run detail
parameters:
- in: path
name: run
required: true
schema:
type: integer
responses:
'200':
description: OK
'403':
description: Authenticated but forbidden (policy denies view)
'404':
description: Deny-as-not-found when run is outside entitled scope
/admin/alerts:
get:
summary: Central Monitoring - Alerts
responses:
'200':
description: OK
'404':
description: Deny-as-not-found when not entitled to workspace scope
/admin/audit-log:
get:
summary: Central Monitoring - Audit log
responses:
'200':
description: OK
'404':
description: Deny-as-not-found when not entitled to workspace scope

View File

@ -0,0 +1,63 @@
# Data Model: Tenant Operate Hub / Tenant Overview IA
**Date**: 2026-02-09
**Branch**: 085-tenant-operate-hub
This feature is primarily UI/IA + navigation behavior. It introduces **no new database tables**.
## Entities (existing)
### Workspace
- Purpose: primary isolation boundary and monitoring scope.
- Source of truth: `workspaces` + membership.
### Tenant
- Purpose: managed environment; tenant-plane routes live under `/admin/t/{tenant}`.
- Access: entitlement-based.
### OperationRun
- Purpose: canonical run tracking for all operational workflows.
- Surface:
- Index: `/admin/operations`
- Detail: `/admin/operations/{run}`
### Alert (placeholder)
- Purpose: future operator signals.
- Surface: `/admin/alerts`.
### Audit Event / Audit Log (placeholder)
- Purpose: immutable record of sensitive actions.
- Surface: `/admin/audit-log`.
## Session / Context State (existing)
### Workspace context
- Key: `WorkspaceContext::SESSION_KEY` (`current_workspace_id`)
- Meaning: selected workspace id for the current session.
### Last tenant per workspace (session-based)
- Key: `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` (`workspace_last_tenant_ids`)
- Shape:
- Map keyed by workspace id string → tenant id int
- Example:
- `{"12": 345}`
- APIs:
- `WorkspaceContext::rememberLastTenantId(int $workspaceId, int $tenantId, Request $request)`
- `WorkspaceContext::lastTenantId(Request $request): ?int`
- `WorkspaceContext::clearLastTenantId(Request $request)`
### Filament tenant context
- Source: `Filament::getTenant()` (may persist across panels depending on Filament tenancy configuration).
- Used to determine “active tenant context” for Monitoring UX.
**Spec 085 scope note**: Monitoring may use session-based last-tenant memory as a tenant-context signal when Filament tenant context is absent (e.g., when navigating from the tenant panel into central Monitoring). It must not be inferred from arbitrary deep links.
### Stale tenant context behavior (no entitlement)
- If tenant context is active but the user is not entitled, Monitoring pages behave as workspace-wide views and must not display tenant identity.
## Validation / Rules
- Tenant context MUST NOT be implicitly mutated by canonical monitoring pages.
- Deny-as-not-found (404) applies when the actor is not entitled to tenant/workspace scope.
- Forbidden (403) applies only after membership is established but capability is missing.

View File

@ -0,0 +1,123 @@
# Implementation Plan: Spec 085 — Tenant Operate Hub / Tenant Overview IA
**Branch**: `085-tenant-operate-hub` | **Date**: 2026-02-09 | **Spec**: specs/085-tenant-operate-hub/spec.md
**Input**: specs/085-tenant-operate-hub/spec.md
## Summary
Make central Monitoring pages feel context-aware when entered from the tenant panel, without introducing tenant-scoped monitoring routes and without implicit tenant switching.
Key outcomes:
- Tenant panel sidebar replaces “Operations” with a “Monitoring” group of shortcuts (Runs/Alerts/Audit Log) that open central Monitoring surfaces.
- `/admin/operations` becomes context-aware when tenant context is active: scope label shows tenant, table defaults to tenant filter, and header includes `Back to <tenant>` + `Show all tenants` (clears tenant context).
- `/admin/operations/{run}` adds deterministic “back” affordances: tenant back link when tenant context is active + entitled, plus secondary `Show all operations`; otherwise `Back to Operations`.
- Monitoring page render remains DB-only: no outbound calls and no background work triggered by view-only GET.
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
**Storage**: PostgreSQL (Sail)
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
**Target Platform**: Web (enterprise SaaS admin UI)
**Project Type**: Laravel monolith (Filament panels + Livewire)
**Performance Goals**: Monitoring page renders are DB-only, low-latency, and avoid N+1 regressions
**Constraints**:
- Canonical monitoring URLs must not change (`/admin/operations`, `/admin/operations/{run}`)
- No new tenant-scoped monitoring routes
- No implicit tenant switching (tenant selection remains explicit POST)
- Deny-as-not-found (404) for non-members/non-entitled; 403 only after membership established
- No outbound calls on render; no render-time side effects (jobs/notifications)
**Scale/Scope**: Small-to-medium UX change touching tenant navigation + 2 monitoring pages + Pest tests
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshots: Not applicable (read-only monitoring UX).
- Read/write separation: PASS (changes are navigation + view-only rendering; the only mutation is explicit “clear tenant context” action).
- Graph contract path: PASS (no new Graph calls).
- Deterministic capabilities: PASS (uses existing membership/entitlement checks; no new capability strings).
- Workspace isolation: PASS (non-member workspace access remains 404).
- Tenant isolation: PASS (no tenant identity leaks when not entitled; tenant pages remain 404).
- Run observability: PASS (view-only pages do not start operations; Monitoring stays DB-only).
- RBAC-UX destructive confirmation: PASS (no destructive actions added).
- Filament UI Action Surface Contract: PASS (were modifying Pages; we will provide explicit header actions and table/default filter behavior; no new list resources are added).
## Project Structure
### Documentation (this feature)
```text
specs/085-tenant-operate-hub/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ ├── Resources/
│ └── ...
├── Http/
│ ├── Controllers/
│ └── Middleware/
├── Providers/
└── Support/
resources/views/
tests/Feature/
routes/web.php
```
**Structure Decision**: Laravel monolith with Filament panels. Changes will be localized to existing panel providers, page classes, shared helpers (if present), and feature tests.
## Phase Plan
### Phase 0 — Research (complete)
Outputs:
- specs/085-tenant-operate-hub/research.md (decisions + alternatives)
### Phase 1 — Design & Contracts (complete)
Outputs:
- specs/085-tenant-operate-hub/data-model.md (no schema changes; context rules)
- specs/085-tenant-operate-hub/contracts/openapi.yaml (canonical routes + clear-tenant-context POST)
- specs/085-tenant-operate-hub/quickstart.md (manual verification)
### Phase 2 — Implementation Planning (next)
Implementation will be executed as small, test-driven slices:
1) Tenant panel navigation IA
- Replace tenant-panel “Operations” entry with “Monitoring” group.
- Add 3 shortcut items (Runs/Alerts/Audit Log).
- Verify no new tenant-scoped monitoring routes are introduced.
2) Operations index context-aware header + default scope
- If tenant context active + entitled: show scope `Tenant — <name>`, default table filter = tenant, CTAs `Back to <tenant>` and `Show all tenants`.
- If no tenant context: show scope `Workspace — all tenants`.
- If tenant context active but not entitled: behave workspace-wide (no tenant name, no back-to-tenant).
3) Run detail deterministic back affordances
- If tenant context active + entitled: `← Back to <tenant>` plus secondary `Show all operations`.
- Else: `Back to Operations`.
4) Pest tests (security + UX)
- OperationsIndexScopeTest (tenant vs workspace scope labels + CTAs)
- RunDetailBackToTenantTest (tenant-context vs no-context actions)
- Deny-as-not-found coverage for non-entitled tenant pages
- “No outbound calls on render” guard for `/admin/operations` and `/admin/operations/{run}`
## Complexity Tracking
No constitution violations expected.

View File

@ -0,0 +1,39 @@
# Quickstart: Tenant Operate Hub / Tenant Overview IA
**Date**: 2026-02-09
**Branch**: 085-tenant-operate-hub
## Local setup
- Start containers: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install`
## Manual verification steps (happy path)
1. Sign in.
2. Select a workspace (via the context bar).
3. Enter a tenant context (e.g., go to `/admin/t/{tenant}` via the tenant panel).
4. In the tenant panel sidebar, open the **Monitoring** group and click:
- Runs → lands on `/admin/operations`
5. Verify `/admin/operations` shows:
- Header scope: `Scope: Tenant — <tenant name>`
- CTAs: `Back to <tenant name>` and `Show all tenants`
- The table default scope is tenant-filtered to the active tenant.
6. Click `Show all tenants`.
7. Verify you stay on `/admin/operations` and scope becomes `Scope: Workspace — all tenants`.
8. Open an operation run detail at `/admin/operations/{run}`.
9. Verify the header shows:
- `← Back to <tenant name>`
- secondary `Show all operations``/admin/operations`
10. Click `← Back to <tenant name>` and verify it lands on the tenant dashboard (`/admin/t/{tenant}`).
## Negative verification (security)
- With tenant context active, revoke tenant entitlement for the test user.
- Reload `/admin/operations`.
- Verify scope is workspace-wide and no tenant name / “Back to tenant” affordance appears.
- Request the tenant dashboard URL directly (`/admin/t/{tenant}`) and verify deny-as-not-found.
## Test commands (to be added in Phase 2)
- Targeted suite: `vendor/bin/sail artisan test --compact --filter=OperationsIndexScopeTest`

View File

@ -0,0 +1,70 @@
# Research: Tenant Operate Hub / Tenant Overview IA (Spec 085)
**Date**: 2026-02-09
**Branch**: 085-tenant-operate-hub
**Spec**: specs/085-tenant-operate-hub/spec.md
This research consolidates repo evidence + the final clarification decisions, so the implementation plan and tests can be deterministic.
## Repository Evidence (high-signal)
- Canonical monitoring pages already exist in the Admin panel:
- Operations index: app/Filament/Pages/Monitoring/Operations.php
- Run detail (tenantless viewer): app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
- Alerts: app/Filament/Pages/Monitoring/Alerts.php
- Audit log: app/Filament/Pages/Monitoring/AuditLog.php
- Tenant selection + clear tenant context already exist (UI + route/controller):
- Context bar partial: resources/views/filament/partials/context-bar.blade.php
- Tenant select controller: app/Http/Controllers/SelectTenantController.php
## Decisions (resolved)
### Decision: Tenant context may use last-tenant memory for cross-panel flows
- Decision: “Tenant context is active” on Monitoring pages is resolved from the active Filament tenant when present, otherwise from the remembered last-tenant id for the current workspace.
- Rationale: Central Monitoring routes are canonical and tenantless, but users enter them from the tenant panel and expect consistent scoping.
- Alternatives considered:
- Query param `?tenant=`: rejected (would introduce an implicit switching vector).
### Decision: “Show all tenants” explicitly exits tenant context
- Decision: The CTA “Show all tenants” clears tenant context (single meaning) and returns the user to workspace-wide Monitoring.
- Rationale: Prevents the confusing state where tenant context is still active but filters are reset.
- Alternatives considered:
- Only reset table filter: rejected (context remains active and confuses scope semantics).
### Decision: Stale tenant context is handled without leaks
- Decision: If tenant context is active but the user is no longer entitled to that tenant, Monitoring pages behave as workspace-wide:
- Scope shows `Workspace — all tenants`
- No tenant name is shown
- No “Back to tenant” is rendered
- Tenant pages remain deny-as-not-found
- Rationale: Preserves deny-as-not-found and avoids tenant existence hints.
- Alternatives considered:
- 404 the Monitoring page: rejected (feels like being “thrown out”).
- Auto-clear tenant context implicitly: rejected (implicit context mutation).
### Decision: Run detail shows an explicit tenant return + secondary escape hatch
- Decision: When tenant context is active and entitled, run detail shows:
- `← Back to <tenant name>` (tenant dashboard)
- secondary `Show all operations``/admin/operations`
- Rationale: “Back to tenant” is deterministic; the secondary link provides a canonical escape hatch.
- Alternatives considered:
- Only show `Back to tenant`: rejected by clarification (secondary escape hatch approved).
### Decision: Tenant panel navigation uses a “Monitoring” group with central shortcuts
- Decision: Replace the tenant-panel “Operations” item with group “Monitoring” containing shortcuts:
- Runs → `/admin/operations`
- Alerts → `/admin/alerts`
- Audit Log → `/admin/audit-log`
- Rationale: Keep tenant sidebar labels minimal while still providing correct central Monitoring entry points, while preserving canonical URLs and avoiding tenant-scoped monitoring routes.
- Alternatives considered:
- New tenant-scoped monitoring routes: rejected (explicitly forbidden).
### Decision: “No outbound calls on render” is enforced by tests
- Decision: Monitoring GET renders must not trigger outbound network calls or start background work as a side effect.
- Rationale: Aligns with constitution (“Monitoring pages MUST be DB-only at render time”).
- Alternatives considered:
- Rely on convention: rejected; this needs regression protection.
## Open Questions
None remaining for Phase 0. The spec clarifications cover all scope-affecting ambiguities.

View File

@ -0,0 +1,194 @@
# Feature Specification: Tenant Operate Hub / Tenant Overview IA
**Feature Branch**: `085-tenant-operate-hub`
**Created**: 2026-02-09
**Status**: Draft
**Input**: User description: "Make central Monitoring surfaces feel context-aware when entered from a tenant, without changing canonical URLs, and without weakening deny-as-not-found security boundaries."
## Clarifications
### Session 2026-02-09 (work order alignment)
- Q: What is the source of truth for “Back to tenant”? → A: The active entitled tenant context (Filament tenant if present, otherwise the remembered last-tenant id for the current workspace).
- Q: Should “Back to last tenant” be implemented as a separate feature? → A: No; remembered tenant context is used only to preserve context when navigating from a tenant into central Monitoring.
- Q: What does “Show all tenants” do? → A: It explicitly exits tenant context to return to workspace-wide monitoring (no mixed behavior with filter resets).
- Q: How is Monitoring reached from tenant context? → A: Tenant navigation offers a “Monitoring” group with shortcuts that open central Monitoring surfaces.
- Q: How should stale tenant context (tenant context active but user no longer entitled) behave on Monitoring pages? → A: Monitoring renders workspace-wide (no tenant name, no “Back to tenant”), preserving deny-as-not-found for tenant pages.
- Q: Should run detail offer a secondary escape hatch when tenant context is active? → A: Yes — show a secondary “Show all operations” link to `/admin/operations`.
- Q: How should tenant Monitoring shortcuts indicate “opens central monitoring”? → A: Keep labels minimal (no “↗ Central” suffix).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Monitoring feels context-aware (Priority: P1)
As an operator, when I open central Monitoring from within a tenant, I immediately understand:
1) whether the Monitoring view is scoped to the current tenant or to all tenants, and
2) how to get back to the tenant I came from.
**Why this priority**: This is the core usability and safety problem: monitoring and tenant work should not feel like different apps, but they must not blur security boundaries.
**Independent Test**: With a tenant context active, a test user can open the Operations index and a run detail, see an explicit scope indicator and a deterministic “Back to tenant”, and exit to workspace-wide monitoring intentionally.
**Acceptance Scenarios**:
1. **Given** a user is a member of a workspace and has access to at least one tenant, **When** they open central Monitoring (Operations), **Then** the page clearly shows whether tenant context is active.
2. **Given** a tenant context is active, **When** the user navigates to a canonical monitoring detail page, **Then** the UI provides a single, clear “Back to tenant” affordance that returns to that tenant dashboard.
3. **Given** a user is not entitled to the current tenant, **When** they try to access tenant-scoped pages via a direct link, **Then** they receive a not-found experience (deny-as-not-found), without any tenant existence hints.
---
### User Story 2 - Canonical URLs with explicit scope (Priority: P2)
As an operator, I can use canonical Monitoring URLs at all times. When tenant context is active, Monitoring views can be tenant-filtered by default, but they must not implicitly change tenant selection.
**Why this priority**: Avoids mistakes and misinterpretation of data by preventing silent scoping changes.
**Independent Test**: With a tenant selected, open monitoring index and detail views and verify the scope is consistent and clearly communicated.
**Acceptance Scenarios**:
1. **Given** a tenant context is active, **When** the user opens the monitoring index, **Then** the default view is tenant-scoped (or clearly offers a one-click tenant scope), and the UI visibly indicates the scope.
2. **Given** no tenant context is active, **When** the user opens monitoring, **Then** the view is workspace-wide and does not imply a tenant is selected.
---
### User Story 3 - Deep links are safe and recoverable (Priority: P3)
As an operator working inside a tenant, when I land on a canonical run detail via a deep link, I can safely return to the tenant if tenant context is still active and I am still entitled.
**Why this priority**: These workflows are frequent. Deep links are where users most often “lose” tenant context.
**Independent Test**: With a tenant context active, open a canonical run detail and verify the “Back to tenant” affordance is present and correct.
**Acceptance Scenarios**:
1. **Given** a tenant context is active and the user is still entitled, **When** they open a canonical run detail, **Then** they see a “Back to tenant” affordance.
2. **Given** tenant context is not active, **When** the user opens a canonical run detail, **Then** they see only a “Back to Operations” affordance.
### Edge Cases
- User has no workspace selected: Monitoring must not show cross-workspace data; user must select a workspace first.
- User has workspace access but zero tenant access: Monitoring must still work in workspace-wide mode, without tenant selection.
- Users tenant access is revoked while they have a deep link open: subsequent tenant-scoped navigation must be deny-as-not-found.
- User opens a bookmarked canonical run detail directly: the UI must provide a deterministic “Back” behavior without inventing tenant context.
- Tenant context is active, but entitlement was revoked: Monitoring must not leak tenant identity; tenant return affordance must not appear (or must be safe).
- Monitoring views must remain view-only render surfaces: rendering must not trigger outbound calls.
## Requirements *(mandatory)*
### Target State (hard decision)
This spec adopts a single, deterministic interpretation:
- Monitoring URLs are canonical and do not change with tenant context.
- Tenant context makes Monitoring *feel* scoped (scope indicators, default filters, and deterministic exits) without implicit tenant switching.
- Operations index: `/admin/operations`
- Operations run detail: `/admin/operations/{run}`
- Alerts: `/admin/alerts`
- Audit log: `/admin/audit-log`
Tenant plane remains under `/admin/t/{tenant}` for tenant dashboards and workflows. Monitoring views are central, but when tenant context is active they become tenant-filtered by default and provide a deterministic “Back to tenant” affordance.
**Constitution alignment (required):** This feature is information architecture + navigation behavior. It MUST NOT introduce new outbound calls for monitoring pages. If it introduces or changes any write/change behavior (e.g., starting workflows), it MUST maintain existing safety gates (preview/confirmation/audit), tenant isolation, run observability, and tests.
**Constitution alignment (RBAC-UX):** This feature changes how users reach surfaces; it MUST preserve and test authorization semantics:
- Non-member / not entitled to workspace scope OR tenant scope → deny-as-not-found (404 semantics)
- Member but missing capability → forbidden (403 semantics)
**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound communication on auth endpoints. This MUST NOT be used for Monitoring pages.
**Constitution alignment (BADGE-001):** If any status/severity/outcome badges are added or changed on hub pages, their meaning MUST be centralized and covered by tests.
**Constitution alignment (UI Action Surfaces):** If this feature adds/modifies any admin or tenant UI surfaces, the “UI Action Matrix” MUST be updated and action gating MUST remain consistent (confirmation for destructive-like actions; server-side authorization for mutations).
### Functional Requirements
- **FR-085-001**: Tenant navigation MUST offer a “Monitoring” group with shortcuts to central Monitoring surfaces:
- Runs (Operations) → `/admin/operations`
- Alerts → `/admin/alerts`
- Audit Log → `/admin/audit-log`
These shortcuts MUST NOT introduce new tenant-scoped monitoring URLs.
- **FR-085-002**: The Operations index (`/admin/operations`) MUST show a clear scope indicator in the page header:
- `Scope: Workspace — all tenants` when no tenant context is active
- `Scope: Tenant — <tenant name>` when tenant context is active
- **FR-085-003**: Canonical Monitoring URLs MUST NOT implicitly change tenant context. Tenant context MAY influence default filters on Monitoring views.
- **FR-085-004**: When tenant context is active on the Operations index, the default tenant filter MUST be set to the current tenant, and the UI MUST make this tenant scoping obvious.
- **FR-085-005**: The Operations index MUST provide two explicit CTAs when tenant context is active:
- `Show all tenants` (explicitly exits tenant context and returns to workspace-wide monitoring)
- `Back to <tenant name>` (navigates to tenant dashboard)
- **FR-085-006**: The run detail (`/admin/operations/{run}`) MUST provide a deterministic “Back” affordance:
- If tenant context is active AND the user is still entitled: `← Back to <tenant name>` (tenant dashboard) AND a secondary `Show all operations` (to `/admin/operations`)
- Else: `Back to Operations` (Operations index)
- **FR-085-007**: “Back to tenant” MUST be based only on active entitled tenant context (Filament tenant, or remembered tenant for the current workspace). It MUST NOT be inferred from arbitrary deep-link parameters.
- **FR-085-008**: Deny-as-not-found MUST remain: users not entitled to workspace or tenant scope MUST receive a not-found experience (404 semantics), with no tenant existence hints.
- **FR-085-009**: Monitoring views (`/admin/operations` and `/admin/operations/{run}`) MUST remain view-only render surfaces and MUST NOT trigger outbound calls during render.
- **FR-085-010**: If tenant context is active but the user is not entitled to that tenant, Monitoring pages MUST behave as workspace-wide views:
- Scope indicator MUST show `Scope: Workspace — all tenants`
- No tenant name MUST be displayed
- No “Back to <tenant>” affordance MUST be rendered
- Direct access to tenant pages MUST continue to be deny-as-not-found
## UI Action Matrix *(mandatory when UI surfaces are 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Central Operations (index) | `/admin/operations` | Scope indicator; `Show all tenants` (when tenant context active); deterministic back affordance | Linked run rows to open run detail | N/A | N/A | N/A | N/A | N/A | No | Must not implicitly change tenant context; default tenant filter when tenant context active |
| Central Operations (run detail) | `/admin/operations/{run}` | `← Back to <tenant>` (when tenant context active + entitled) OR `Back to Operations`; secondary `Show all operations` allowed when tenant context active + entitled | N/A | N/A | N/A | N/A | N/A | N/A | No | Must not reveal tenant identity when user is not entitled |
| Tenant navigation shortcuts | Tenant sidebar | N/A | N/A | N/A | N/A | N/A | N/A | N/A | No | “Monitoring” group with central shortcuts |
### Key Entities *(include if feature involves data)*
- **Workspace**: A security and organizational boundary for operations and monitoring.
- **Tenant**: A managed environment within a workspace; access is entitlement-based.
- **Monitoring (Operations)**: Central monitoring views that can be workspace-wide or tenant-scoped when tenant context is active.
- **Operation Run**: A tracked execution of an operational workflow; viewable via canonical run detail.
- **Alert**: An operator-facing signal about an issue or state requiring attention.
- **Audit Event**: An immutable record of important user-triggered actions and sensitive operations.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-085-001**: In a usability walkthrough, 90% of operators can correctly identify whether Operations is scoped to a tenant or to all tenants within 10 seconds of opening `/admin/operations`.
- **SC-085-002**: With tenant context active, operators can return to the tenant dashboard from `/admin/operations` and `/admin/operations/{run}` in ≤ 1 click.
- **SC-085-003**: Support tickets tagged “lost tenant context / where am I?” decrease by 30% within 30 days after rollout.
- **SC-085-004**: Authorization regression checks show zero cases where a non-entitled user can infer existence of a tenant or view tenant-scoped monitoring data.
### Engineering Acceptance Outcomes
- **SC-085-005**: When tenant context is active, `/admin/operations` and `/admin/operations/{run}` clearly show tenant scope and a “Back to <tenant>” affordance.
- **SC-085-006**: When tenant context is not active, `/admin/operations/{run}` shows “Back to Operations” and no “Back to tenant”.
- **SC-085-007**: Viewing Monitoring pages does not initiate outbound network requests or start background work as a side effect of rendering.
## Test Plan *(mandatory)*
1. **Operations index scope label + CTAs (tenant context)**
- With tenant context active and user entitled, request `/admin/operations`.
- Assert the page indicates `Scope: Tenant — <tenant name>`.
- Assert `Show all tenants` and `Back to <tenant name>` are available.
2. **Operations index scope label (no tenant context)**
- With no tenant context active, request `/admin/operations`.
- Assert the page indicates `Scope: Workspace — all tenants`.
3. **Run detail back affordance (tenant context)**
- With tenant context active and user entitled, request `/admin/operations/{run}`.
- Assert `← Back to <tenant name>` is available.
- Assert secondary `Show all operations` is available and links to `/admin/operations`.
4. **Run detail back affordance (no tenant context)**
- With no tenant context active, request `/admin/operations/{run}`.
- Assert only `Back to Operations` is available.
5. **Deny-as-not-found regression**
- As a user without tenant entitlement, request `/admin/t/{tenant}`.
- Assert deny-as-not-found behavior (404 semantics) and that no tenant identity hints are revealed via Monitoring CTAs.
6. **Stale tenant context behaves workspace-wide**
- With tenant context active but user not entitled, request `/admin/operations`.
- Assert scope indicates workspace-wide and no tenant name or “Back to tenant” is present.
7. **No outbound calls on render**
- Assert rendering `/admin/operations` and `/admin/operations/{run}` does not initiate outbound network calls and does not start background work from a view-only GET.

View File

@ -0,0 +1,192 @@
---
description: "Task list for Spec 085 — Tenant Operate Hub / Tenant Overview IA"
---
# Tasks: Spec 085 — Tenant Operate Hub / Tenant Overview IA
**Input**: Design documents from `/specs/085-tenant-operate-hub/`
**Required**:
- `specs/085-tenant-operate-hub/plan.md`
- `specs/085-tenant-operate-hub/spec.md`
**Additional docs present**:
- `specs/085-tenant-operate-hub/research.md`
- `specs/085-tenant-operate-hub/data-model.md`
- `specs/085-tenant-operate-hub/contracts/openapi.yaml`
- `specs/085-tenant-operate-hub/quickstart.md`
**Tests**: REQUIRED (runtime UX + security semantics; Pest)
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Confirm the existing code touchpoints and test harness for Spec 085.
- [X] T001 Confirm canonical Monitoring routes + existing clear-context endpoint in routes/web.php and app/Http/Controllers/ClearTenantContextController.php
- [X] T002 Confirm the Monitoring pages exist and are canonical: app/Filament/Pages/Monitoring/Operations.php, app/Filament/Pages/Operations/TenantlessOperationRunViewer.php, app/Filament/Pages/Monitoring/Alerts.php, app/Filament/Pages/Monitoring/AuditLog.php
- [X] T003 Confirm Tenant panel provider is the entry point for tenant sidebar Monitoring shortcuts in app/Providers/Filament/TenantPanelProvider.php
- [X] T004 Confirm Laravel 11+/12 panel provider registration is in bootstrap/providers.php (not bootstrap/app.php)
- [X] T005 [P] Identify existing monitoring/tenant scoping tests to extend (tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/Operations/TenantlessOperationRunViewerTest.php)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared helper behavior must match Spec 085 semantics before story work.
- [X] T006 Update scope-label copy and semantics in app/Support/OperateHub/OperateHubShell.php (MUST match FR-085-002 exactly: "Scope: Workspace — all tenants" / "Scope: Tenant — <tenant name>")
- [X] T007 Ensure OperateHubShell resolves active entitled tenant context safely (Filament tenant when present, otherwise remembered last-tenant id for the current workspace)
- [X] T008 Update OperateHubShell return affordance label to include tenant name ("Back to <tenant name>") in app/Support/OperateHub/OperateHubShell.php
- [X] T009 Add a helper method to resolve “active tenant AND still entitled” in app/Support/OperateHub/OperateHubShell.php (used by Operations index + run detail to implement stale-tenant-context behavior)
- [X] T010 Ensure Monitoring renders remain DB-only (no outbound calls / no side effects) by standardizing test guards with Http::preventStrayRequests() in tests/Feature/Spec085/*.php and existing coverage tests/Feature/Monitoring/OperationsTenantScopeTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php
**Checkpoint**: Shared semantics locked; user story work can begin.
---
## Phase 3: User Story 1 — Monitoring feels context-aware (Priority: P1) 🎯 MVP
**Goal**: When tenant context is active, Monitoring clearly shows tenant scope + deterministic “Back to tenant” and offers explicit “Show all tenants” to exit.
**Independent Test**: With tenant context active + entitled, GET `/admin/operations` shows `Scope: Tenant — <tenant>` and buttons `Back to <tenant>` and `Show all tenants`; clicking “Show all tenants” clears tenant context and returns to workspace-wide operations.
### Tests for User Story 1 (write first)
- [X] T011 [P] [US1] Add Spec 085 operations header tests in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant scope label + both CTAs)
- [X] T012 [P] [US1] Add stale-tenant-context test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant context set but user not entitled → workspace scope + no tenant name + no back-to-tenant)
- [X] T013 [P] [US1] Add explicit-exit behavior test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (POST /admin/clear-tenant-context clears Filament tenant + last tenant id)
- [X] T014 [P] [US1] Add tenant navigation shortcuts test in tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php (tenant sidebar shows “Monitoring” group with Runs/Alerts/Audit Log)
- [X] T015 [P] [US1] Add “deny-as-not-found” regression tests for canonical Monitoring access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-workspace-member → 404 for /admin/operations and /admin/operations/{run})
- [X] T016 [P] [US1] Add “deny-as-not-found” regression test for tenant dashboard direct access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-entitled to tenant → 404 for /admin/t/{tenant})
### Implementation for User Story 1
- [X] T017 [US1] Replace Tenant sidebar "Operations" item with "Monitoring" group shortcuts in app/Providers/Filament/TenantPanelProvider.php (Runs→/admin/operations, Alerts→/admin/alerts, Audit Log→/admin/audit-log)
- [X] T018 [US1] Implement Operations index scope indicator per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (workspace vs tenant; stale context treated as workspace)
- [X] T019 [US1] Implement Operations index CTAs per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (Back to <tenant> using App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); Show all tenants exits tenant context)
- [X] T020 [US1] Ensure “Show all tenants” uses an explicit server-side action (no implicit GET mutation) in app/Filament/Pages/Monitoring/Operations.php (perform the same mutations as app/Http/Controllers/ClearTenantContextController.php: Filament::setTenant(null, true) + WorkspaceContext::clearLastTenantId(); then redirect to /admin/operations)
**Checkpoint**: US1 fully testable and meets FR-085-001/002/005/007/010.
---
## Phase 4: User Story 2 — Canonical URLs with explicit scope (Priority: P2)
**Goal**: Canonical Monitoring URLs never implicitly change tenant context; tenant context may only affect default filtering and must be obvious.
**Independent Test**: With tenant context active, GET `/admin/operations` does not change tenant context and defaults the list to the active tenant (or otherwise clearly shows its tenant-scoped by default).
### Tests for User Story 2
- [X] T021 [P] [US2] Add non-mutation test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (GET /admin/operations does not set/clear tenant context)
- [X] T022 [P] [US2] Add scope label test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (no tenant context → "Scope: Workspace — all tenants")
- [X] T023 [P] [US2] Add default-tenant-filter test in tests/Feature/Monitoring/OperationsTenantScopeTest.php (tenant context active → list defaults to active tenant)
### Implementation for User Story 2
- [X] T024 [US2] Ensure Operations index query applies workspace scoping and (when tenant context is active + entitled) tenant scoping without mutating tenant context in app/Filament/Pages/Monitoring/Operations.php
- [X] T025 [US2] Ensure any default tenant filter is applied as a query/filter default only (no calls to Filament::setTenant() during GET) in app/Filament/Pages/Monitoring/Operations.php
**Checkpoint**: US2 meets FR-085-003/004/009.
---
## Phase 5: User Story 3 — Deep links are safe and recoverable (Priority: P3)
**Goal**: On `/admin/operations/{run}`, tenant-context users get a deterministic “Back to <tenant>” plus a secondary “Show all operations”; otherwise only “Back to Operations”.
**Independent Test**: With tenant context active + entitled, GET `/admin/operations/{run}` shows `← Back to <tenant>` and `Show all operations`; without tenant context it shows `Back to Operations` only.
### Tests for User Story 3
- [X] T026 [P] [US3] Add run detail header-action tests in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context vs no context)
- [X] T027 [P] [US3] Add stale-tenant-context run detail test in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context set but not entitled → no tenant name, no back-to-tenant)
### Implementation for User Story 3
- [X] T028 [US3] Implement deterministic back affordances for run detail in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (tenant-context+entitled → “← Back to <tenant>” + “Show all operations”; else “Back to Operations”)
- [X] T029 [US3] Ensure run detail never reveals tenant identity when the viewer is not entitled (stale tenant context treated as workspace-wide) in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
**Checkpoint**: US3 meets FR-085-006/008/010.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T030 [P] Confirm Spec 085 UI Action Matrix matches implemented header actions in specs/085-tenant-operate-hub/spec.md
- [X] T031 [P] Validate manual verification steps in specs/085-tenant-operate-hub/quickstart.md against actual behavior (update doc only if it drifted)
- [X] T037 Ensure “Show all tenants” clears Operations table tenant filter state (prevents stale Livewire table filter state from keeping the list scoped)
- [X] T032 Run formatting on changed files under app/ and tests/ using vendor/bin/sail bin pint --dirty
- [X] T033 Run focused test suite: vendor/bin/sail artisan test --compact tests/Feature/Spec085 tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- [X] T034 Fix Filament auth-pattern guard compliance by removing Gate:: usage in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (use $this->authorize(...))
- [X] T035 Ensure canonical Operate Hub routes sanitize stale/non-entitled tenant context by applying ensure-filament-tenant-selected middleware to /admin/operations, /admin/alerts, /admin/audit-log, and /admin/operations/{run}
- [X] T036 Harden Spec 085-related tests to match final copy/semantics and avoid brittle Livewire DOM assertions (tests/Feature/OpsUx/OperateHubShellTest.php, tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php)
---
## Dependencies & Execution Order
### Dependency Graph
```mermaid
graph TD
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
P2 --> US1[US1 (P1): Context-aware Monitoring entry]
US1 --> US2[US2 (P2): Canonical URLs + explicit scope]
US1 --> US3[US3 (P3): Deep-link back affordances]
US2 --> P6[Phase 6: Polish]
US3 --> P6
```
### User Story Dependencies
- US1 is the MVP.
- US2 and US3 depend on the shared foundational semantics (scope labels + entitled active tenant resolution).
---
## Parallel Execution Examples
### US1
```text
In parallel:
- T011 (tests) in tests/Feature/Spec085/OperationsIndexHeaderTest.php
- T017 (tenant nav shortcuts) in app/Providers/Filament/TenantPanelProvider.php
Then:
- T018T020 in app/Filament/Pages/Monitoring/Operations.php
```
### US2
```text
In parallel:
- T021T022 (non-mutation + scope label tests)
- T023 (default tenant filter test) in tests/Feature/Monitoring/OperationsTenantScopeTest.php
Then:
- T024T025 in app/Filament/Pages/Monitoring/Operations.php
```
### US3
```text
In parallel:
- T026T027 (run detail tests) in tests/Feature/Spec085/RunDetailBackAffordanceTest.php
Then:
- T028T029 in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
```
---
## Implementation Strategy
### MVP Scope
- Implement US1 only (T011T020), run T033, then manually validate via specs/085-tenant-operate-hub/quickstart.md.
### Incremental Delivery
- US1 → US2 → US3, keeping each story independently testable.

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Retire Legacy Runs Into Operation Runs
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-09
**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
- The spec uses product-specific terms (e.g., 404 vs 403 semantics) to make authorization behavior testable, but avoids naming specific frameworks or code-level implementation choices.

View File

@ -0,0 +1,41 @@
# Contracts (Spec 086)
This spec does not introduce a new public HTTP API surface.
## Canonical OperationRun contract (internal)
Spec 086 tightens and standardizes the internal contract for how operations are created, identified, and displayed.
### Run creation contract
- Start surfaces must create the `operation_runs` row **before** dispatching asynchronous work.
- Jobs must receive the `OperationRun` (or its id) and must **not** attempt a fallback-create.
### Identity / idempotency contract
Operation run identity is enforced by a partial unique index for active states.
Planned identity rules by type:
- `inventory.sync` and `directory_groups.sync`: deterministic identity (while-active dedupe)
- `backup_schedule.run_now` and `backup_schedule.retry`: unique-per-click identity (nonce)
- `backup_schedule.scheduled`: deterministic identity by `(backup_schedule_id, scheduled_for)` (strict)
### Context contract (selected keys)
The `operation_runs.context` JSON is used for:
- “Target” display (via `target_scope`)
- “Related” deep links (via `OperationRunLinks::related`)
- provenance (trigger source, schedule id, initiating user)
Keys referenced in existing UI code:
- `provider_connection_id`
- `backup_schedule_id`
- `backup_schedule_run_id`
- `restore_run_id`
- `target_scope`
## Graph Contract Registry
All Microsoft Graph calls remain required to go through `GraphClientInterface` and be modeled in `config/graph_contracts.php`.
Spec 086 removes Graph calls from Filament render/search/label callbacks (DB-only rendering), and moves those lookups behind cached tables + asynchronous sync operations.

View File

@ -0,0 +1,131 @@
# Data Model (Spec 086)
This feature consolidates execution tracking into `operation_runs` while keeping legacy run tables as read-only history.
## Entities
### 1) OperationRun (canonical)
**Table:** `operation_runs`
**Purpose:** Single source of truth for execution tracking: status/progress, results (counts), failures, provenance/context.
**Fields (current):**
- `id`
- `workspace_id` (FK, required)
- `tenant_id` (FK, nullable)
- `user_id` (FK, nullable)
- `initiator_name` (string)
- `type` (string; see OperationRunType registry)
- `status` (string; queued|running|completed)
- `outcome` (string; pending|succeeded|partially_succeeded|failed|blocked…)
- `run_identity_hash` (string; deterministic hash for idempotency)
- `summary_counts` (json/array; normalized counts + key metadata)
- `failure_summary` (json/array; structured failures, sanitized)
- `context` (json/array; provenance + inputs + target scope)
- `started_at`, `completed_at`, `created_at`, `updated_at`
**Indexes / constraints (current):**
- `(workspace_id, type, created_at)` and `(workspace_id, created_at)`
- `(tenant_id, type, created_at)` and `(tenant_id, created_at)`
- Partial unique indexes for active runs:
- tenant-scoped: unique `(tenant_id, run_identity_hash)` where `tenant_id IS NOT NULL` and `status IN ('queued','running')`
- workspace-scoped: unique `(workspace_id, run_identity_hash)` where `tenant_id IS NULL` and `status IN ('queued','running')`
**Context contract (current patterns):**
The `context` JSON is used for “related links” and display. Existing keys include (non-exhaustive):
- `provider_connection_id`
- `backup_schedule_id`
- `backup_schedule_run_id`
- `backup_set_id`
- `policy_id`
- `restore_run_id`
- `target_scope` (nested object)
- `selection` and `idempotency` objects for bulk operations
**Required additions for Spec 086 (planned):**
- New `type` values:
- `backup_schedule.scheduled`
- `directory_role_definitions.sync`
- Scheduled backup context keys:
- `backup_schedule_id`
- `scheduled_for` (UTC timestamp/minute)
- Optional `backup_schedule_run_id` if the legacy table remains for history during transition
### 2) InventorySyncRun (legacy)
**Table:** `inventory_sync_runs`
**Purpose:** Historical record (read-only) for pre-cutover tracking.
**Key fields:**
- `tenant_id`
- `selection_hash`
- `selection_payload` (nullable)
- status + timestamps + counters
**Planned optional mapping:**
- Add nullable `operation_run_id` FK to enable deterministic redirect to canonical viewer when present. No backfill required.
### 3) EntraGroupSyncRun (legacy)
**Table:** `entra_group_sync_runs`
**Purpose:** Historical record (read-only) for pre-cutover group sync tracking.
**Key fields:**
- `tenant_id`
- `selection_key`, `slot_key`
- status + error fields + counters
**Planned optional mapping:**
- Add nullable `operation_run_id` FK to enable deterministic redirect when present.
### 4) BackupScheduleRun (legacy)
**Table:** `backup_schedule_runs`
**Purpose:** Historical record of backup schedule executions.
**Planned behavior change:**
- Distinguish scheduled fires vs manual/retry at the OperationRun level by introducing `backup_schedule.scheduled` type.
**Planned optional mapping:**
- Add nullable `operation_run_id` FK to enable deterministic redirect when present.
### 5) RestoreRun (domain)
**Table:** `restore_runs`
**Purpose:** Domain workflow record (requested items, dry-run, preview/results). Execution tracking and “View run” uses `operation_runs`.
**Current linkage approach:**
- Canonical runs store `restore_run_id` in `operation_runs.context` (used by `OperationRunLinks::related`).
## Enumerations / Registries
### OperationRunType
**Location:** `app/Support/OperationRunType.php`
**Planned additions:**
- `BackupScheduleScheduled = 'backup_schedule.scheduled'`
- `DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'`
### OperationCatalog
**Location:** `app/Support/OperationCatalog.php`
**Planned additions:**
- Human label for `backup_schedule.scheduled`
- Human label for `directory_role_definitions.sync`
- Optional expected durations (if known)
## State transitions
### OperationRun
- `queued``running``completed`
- `outcome` starts as `pending`, transitions to one of: `succeeded`, `partially_succeeded`, `failed`, `blocked`.
The canonical update surface is `OperationRunService` (`dispatchOrFail`, `updateRun`, `appendFailures`, `incrementSummaryCounts`, etc.).

View File

@ -0,0 +1,109 @@
# Implementation Plan: Retire Legacy Runs Into Operation Runs
**Branch**: `086-retire-legacy-runs-into-operation-runs` | **Date**: 2026-02-10 | **Spec**: `specs/086-retire-legacy-runs-into-operation-runs/spec.md`
**Input**: Feature specification from `specs/086-retire-legacy-runs-into-operation-runs/spec.md`
## Summary
Retire legacy “run tracking” tables as the primary execution tracker for in-scope operations (inventory sync, directory groups sync, backup schedule runs, restore execution, and directory role definitions sync) and make `operation_runs` the canonical source of truth.
Key implementation approach:
- Use the existing tenantless canonical viewer `/admin/operations/{run}` (Filament page `TenantlessOperationRunViewer`) and ensure it remains DB-only at render time.
- Enforce the clarified 404/403 semantics for run viewing: non-members 404, members missing capability 403, where the view capability equals the start capability.
- Enforce dispatch-time OperationRun creation for every start surface; jobs never fallback-create.
- Apply explicit run identity rules per operation type (dedupe vs unique-per-click vs strict schedule dedupe), including strict scheduled backup idempotency: at most one canonical run ever per (schedule_id, intended fire-time).
- Remove Graph calls from UI render/search/label callbacks by using cached directory data (groups + role definitions) and “Sync now” operations.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL (via Sail)
**Testing**: Pest v4 (PHPUnit v12 runner)
**Target Platform**: Web application (Laravel + Filament admin panel)
**Project Type**: web
**Performance Goals**: Operations viewer + Monitoring pages render from DB state only; canonical viewer loads in ~2s under normal conditions
**Constraints**: No outbound HTTP in Monitoring/Operations rendering/search/label callbacks (OPS-EX-AUTH-001); dispatch-time OperationRun creation; jobs must never fallback-create; strict 404/403 isolation semantics
**Scale/Scope**: TenantPilot admin workflows; multiple operation families; staged cutover with legacy history preserved
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: No change to the meaning of inventory vs snapshots/backups; this spec only changes execution tracking.
- Read/write separation: Start surfaces remain enqueue-only; destructive-like actions are not added here; audit logging remains required for mutations.
- Graph contract path: UI render/search/label callbacks must become DB-only; any remaining Graph calls stay behind `GraphClientInterface` + `config/graph_contracts.php`.
- Deterministic capabilities: Run viewing must be capability-gated using the existing capability registry (no raw strings).
- RBAC-UX: Enforce clarified semantics for run viewing: non-members 404, members missing capability 403; authorization enforced server-side via Policy/Gate.
- Workspace isolation: Canonical tenantless `/admin/operations/{run}` continues to enforce workspace membership (deny-as-not-found).
- Global search: `OperationRunResource` stays non-globally-searchable; no new global-search surfaces introduced.
- Run observability: All in-scope long-running/scheduled/remote operations are tracked via `OperationRun`; Monitoring pages remain DB-only.
- Automation: Scheduled backup run creation uses strict idempotency per schedule + intended fire-time.
- Badge semantics (BADGE-001): Run status/outcome badges already use `BadgeRenderer`; do not introduce ad-hoc mappings.
- Filament UI Action Surface Contract: Legacy resources remain read-only; canonical operations pages already define inspection affordances.
## Project Structure
### Documentation (this feature)
```text
specs/086-retire-legacy-runs-into-operation-runs/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── README.md
└── tasks.md # To be created by /speckit.tasks
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── Monitoring/
│ │ └── Operations/
│ └── Resources/
├── Http/
│ └── Middleware/
├── Jobs/
├── Models/
├── Policies/
├── Services/
└── Support/
config/
├── graph.php
└── graph_contracts.php
database/
└── migrations/
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Laravel web application (monolith) with Filament admin panel.
## Complexity Tracking
No constitution violations are required for this feature.
## Phase Plan
Phase 0/1 deliverables are already captured in:
- `specs/086-retire-legacy-runs-into-operation-runs/research.md`
- `specs/086-retire-legacy-runs-into-operation-runs/data-model.md`
- `specs/086-retire-legacy-runs-into-operation-runs/contracts/README.md`
- `specs/086-retire-legacy-runs-into-operation-runs/quickstart.md`
Phase 2 (tasks) will be produced via `/speckit.tasks` and should slice work by operation family:
1) Authorization: capability-gate canonical run viewing (404 vs 403 semantics).
2) Backup schedules: add `backup_schedule.scheduled` + strict idempotency; make manual runs unique-per-click.
3) Directory groups: stop writing legacy rows; keep legacy pages read-only; ensure dispatch-time OperationRun creation.
4) Inventory sync: stop writing legacy rows; ensure dispatch-time OperationRun creation and no UI Graph calls.
5) Tenant configuration: remove Graph calls from render/search/labels; add role definitions cache + “Sync now” operation.
6) Restore: ensure execution tracking uses OperationRun only; legacy restore domain records remain as domain entities.

View File

@ -0,0 +1,42 @@
# Quickstart (Spec 086)
This quickstart is for validating Spec 086 changes locally using Sail.
## Prereqs
- `vendor/bin/sail up -d`
## Run formatting
- `vendor/bin/sail bin pint --dirty`
## Run targeted tests
Use the minimal test subset relevant to the PR slice you are working on:
- `vendor/bin/sail artisan test --compact --filter=OperationRun`
- `vendor/bin/sail artisan test --compact tests/Feature` (narrow further to the new/changed files)
## Manual verification checklist
### Canonical viewer
- Trigger an operation that creates an `OperationRun`.
- Open the canonical URL (from notification action): `/admin/operations/{runId}`.
- Confirm the viewer renders from persisted DB state only.
### Authorization semantics
- As a non-workspace-member user, opening `/admin/operations/{runId}` returns 404.
- As a workspace member without the required capability for that run type, opening the viewer returns 403.
### Dedupe semantics
- Inventory sync / directory group sync: attempting to start while active reuses the existing active run and links to it.
- Manual backup schedule run now/retry: each click produces a distinct `OperationRun`.
- Scheduled backup: double-fire for the same schedule + intended minute produces at most one `OperationRun`.
### DB-only forms
- Tenant configuration selectors (directory groups, role definitions) render and search without outbound HTTP calls.
- “Sync now” actions enqueue operations and provide “View run” link.

View File

@ -0,0 +1,87 @@
# Research (Spec 086)
This document resolves the unknowns needed to write an implementation plan for “Retire Legacy Runs Into Operation Runs”. It is based on repository inspection (no new external dependencies).
## Decisions
### 1) Canonical run viewer is already implemented; keep the route shape
- **Decision:** Use the existing tenantless canonical viewer route `admin.operations.view` (path pattern `/admin/operations/{run}`) implemented by the Filament page `TenantlessOperationRunViewer`.
- **Rationale:** This already enforces “tenantless deep link” while still doing workspace / tenant entitlement checks server-side through `Gate::authorize('view', $run)`.
- **Alternatives considered:** Create a second viewer page or route. Rejected because it would introduce duplicate UX and increase the chance of policy drift.
Repository anchors:
- Canonical viewer page: `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- Link helper: `app/Support/OperationRunLinks.php`
- Workspace selection middleware explicitly treats `/admin/operations/{id}` as workspace-optional: `app/Http/Middleware/EnsureWorkspaceSelected.php`
### 2) OperationRun is persisted and DB-rendered; schema supports workspace-tenant and workspace-only runs
- **Decision:** Treat `operation_runs` as the canonical persistence format for status/progress/results.
- **Rationale:** The schema already includes `workspace_id` (required) and `tenant_id` (nullable), enabling both tenant-plane and workspace-plane operations.
- **Alternatives considered:** Separate tables per operation family. Rejected because it breaks the Monitoring → Operations single source of truth principle.
Repository anchors:
- Migrations: `database/migrations/2026_01_16_180642_create_operation_runs_table.php`, `database/migrations/2026_02_04_090030_add_workspace_id_to_operation_runs_table.php`
- Model: `app/Models/OperationRun.php`
### 3) View authorization must be capability-gated per operation type (in addition to membership)
- **Decision:** Extend run viewing authorization to require the same capability used to start the operation type.
- **Rationale:** Spec 086 clarifications require: non-members get 404; members without capability get 403; and the “view” capability equals the “start” capability.
- **Implementation approach (planned):** Update `OperationRunPolicy::view()` to:
1) Keep existing workspace membership and tenant entitlement checks (deny-as-not-found).
2) Resolve required capability from `OperationRun->type` using a centralized mapping helper.
3) If capability is known and tenant-scoped, enforce `403` when the member lacks it.
Repository anchors:
- Current policy (membership + tenant entitlement only): `app/Policies/OperationRunPolicy.php`
- Existing capability enforcement in start surfaces (examples):
- Inventory sync start: `Capabilities::TENANT_INVENTORY_SYNC_RUN` in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
- Directory groups sync start: `Capabilities::TENANT_SYNC` in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
- Backup schedule run/retry: `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` in `app/Filament/Resources/BackupScheduleResource.php`
### 4) Run identity / dedupe strategy varies by operation type
- **Decision:** Use existing `OperationRunService` helpers but apply type-specific identity rules:
- `inventory.sync` and `directory_groups.sync`: **while-active dedupe** based on deterministic inputs (continue using `ensureRun(...)`-style identity).
- `backup_schedule.run_now` and `backup_schedule.retry`: **unique per click** (no dedupe). Create a new run each time by including a nonce in identity inputs (e.g., UUID).
- `backup_schedule.scheduled`: **strict dedupe** per `(backup_schedule_id, scheduled_for)`; create a new operation type `backup_schedule.scheduled` and use `ensureRunWithIdentity(...)` keyed by schedule + intended fire-time.
- **Rationale:** Matches explicit spec clarifications and protects against scheduler double-fire.
- **Alternatives considered:**
- Keep using `ensureRun(...)` for manual runs → rejected (dedupes while active).
- Use legacy table unique constraints as idempotency → rejected (spec requires OperationRun is canonical).
Repository anchors:
- `ensureRun(...)` and `ensureRunWithIdentity(...)`: `app/Services/OperationRunService.php`
- Existing partial unique index for active runs: `operation_runs_active_unique_*` in the migrations above.
### 5) Legacy run tables are real and currently written to; deterministic redirect requires an explicit mapping field
- **Decision:** Legacy tables remain viewable and read-only, but should not be relied on for current execution tracking.
- **Rationale:** Spec requires “no new legacy rows” for in-scope operations. Today, some start surfaces still create legacy rows (e.g., inventory/group sync, backup schedule runs).
- **Planned design:**
- Stop creating new legacy rows as part of the cutover PRs.
- Implement legacy “view” redirect behavior only when a record has a canonical mapping.
- To make redirects deterministic without a backfill, add an optional `operation_run_id` FK column to legacy tables that we intend to redirect (only populated for rows created after the migration; older rows remain legacy-only view).
- **Alternatives considered:** Derive mapping by recomputing hashes and searching by time window. Rejected as non-deterministic and likely to pick the wrong run when identities collide historically.
Repository anchors (legacy tables):
- Inventory sync runs: `database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php`
- Directory group sync runs: `database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php`
- Backup schedule runs: `database/migrations/**create_backup_schedule_runs**` (used in `BackupScheduleResource`)
- Restore runs (domain): `database/migrations/2025_12_10_000150_create_restore_runs_table.php`
### 6) DB-only rendering constraint is already enforced in Monitoring pages, but Tenant configuration forms still call Graph
- **Decision:** Remove outbound Graph calls from configuration-form search/labels by introducing cached directory role definitions and using cached directory groups.
- **Rationale:** Constitution OPS-EX-AUTH-001 + Spec 086 FR-006/FR-015 require render/search/label resolution to be DB-only.
- **Repository finding:** `TenantResource` currently queries Graph for role definitions in selector callbacks.
Repository anchors:
- Graph call sites inside UI callbacks: `app/Filament/Resources/TenantResource.php` (roleDefinitions search/label methods)
## Open items (resolved enough for planning)
- Exact schema for the new role definition cache tables and the sync job contract will be specified in `data-model.md` and implemented in Phase PR(s).
- The capability mapping for run viewing will be implemented via a centralized helper; the plan will enumerate required capabilities per in-scope operation type.

View File

@ -0,0 +1,160 @@
# Feature Specification: Retire Legacy Runs Into Operation Runs
**Feature Branch**: `086-retire-legacy-runs-into-operation-runs`
**Created**: 2026-02-09
**Status**: Draft
**Input**: User description: "Retire legacy run tracking into canonical operation runs, with DB-only rendering and dispatch-time run creation. Legacy run tables remain read-only history."
## Clarifications
### Session 2026-02-10
- Q: For manual backup schedule runs (`backup_schedule.run_now`) and retries (`backup_schedule.retry`), should the system dedupe while a run is active, or always create a new run per click? → A: Always create a new run per click (no dedupe).
- Q: Who may view the canonical run detail page (“View run”)? → A: Workspace members may view runs only if they also have the required capability for that operation type; non-members get 404, members without capability get 403.
- Q: Which capability should be required to view a run (“View run”)? → A: Use the same capability as starting that operation type.
- Q: For `backup_schedule.scheduled`, how should dedupe work? → A: Strict dedupe per schedule and intended fire-time (at most one run).
- Q: For the role definitions cache “Sync now” operation, should it use a new dedicated operation type or reuse an existing one? → A: Use a new dedicated operation type.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Start an operation with an immediate canonical run link (Priority: P1)
As a workspace member, I can start long-running operations (inventory sync, directory groups sync, scheduled backups, restore execution, directory role definitions sync) and immediately receive a stable “View run” link that I can open and share.
**Why this priority**: This removes the “run link appears later / changes” ambiguity, improves auditability, and prevents duplicate tracking paths.
**Independent Test**: Trigger each supported operation start surface and verify a canonical run record exists before work begins, and that the canonical viewer loads from persisted state.
**Acceptance Scenarios**:
1. **Given** a workspace member with the required capability, **When** they start an inventory sync, **Then** a canonical run exists immediately and the UI shows a stable “View run” link.
2. **Given** a scheduled backup fire event, **When** the scheduler dispatches work, **Then** a canonical run exists immediately and the same fire event cannot create duplicates.
3. **Given** a workspace member without the required capability, **When** they attempt to start the operation, **Then** the request is rejected with a capability error (403) and no run is created.
---
### User Story 2 - Monitor executions from a single canonical viewer (Priority: P2)
As a workspace member, I can open an operations viewer link for any run and see status, progress, results, and errors without the page triggering outbound calls.
Legacy “run history” pages remain available for older historical rows but cannot start or retry anything.
**Why this priority**: A single viewer reduces support load, enables consistent deep linking, and avoids UI latency and rate-limiting from outbound calls.
**Independent Test**: Load the canonical viewer and legacy history pages using outbound client fakes/mocks and assert no outbound calls occur during rendering/search.
**Acceptance Scenarios**:
1. **Given** a run exists, **When** a user opens its canonical operations link, **Then** the page renders only from persisted state and performs no outbound calls.
2. **Given** a legacy run history record that has a known canonical mapping, **When** a user opens the legacy “view” page, **Then** they are redirected to the canonical operations viewer.
3. **Given** a legacy run history record without a canonical mapping, **When** a user opens the legacy “view” page, **Then** they see a read-only historical record and no new canonical run is created.
---
### User Story 3 - Use cached directory data in forms without blocking calls (Priority: P3)
As a workspace member configuring tenant-related settings, I can search/select directory groups and role definitions using cached data. If cached data is missing or stale, I can trigger an asynchronous sync (“Sync now”) without the form making outbound calls.
**Why this priority**: Prevents slow, flaky UI and rate-limits from inline lookups, while keeping the configuration flow usable.
**Independent Test**: Render the configuration form and exercise search/label rendering while asserting outbound clients are not called.
**Acceptance Scenarios**:
1. **Given** cached directory groups exist, **When** the user searches for groups, **Then** results and labels come from cached data.
2. **Given** cached role definitions are missing, **When** the user opens the role definition selector, **Then** the UI indicates “data not available yet” and offers a non-destructive “Sync now” action.
3. **Given** the user triggers “Sync now”, **When** the sync starts, **Then** a canonical run is created immediately and the user can open its canonical “View run” link.
### Edge Cases
- A scheduler fires the same scheduled backup more than once for the same intended time.
- A user triggers the same sync while an identical sync is still active (dedupe/while-active semantics).
- A job fails before writing progress; the canonical run still exists and shows a clear failure state.
- A legacy history row exists but has no canonical mapping; it must remain viewable without creating new canonical runs.
- A non-member attempts to access a canonical operations link; response must be deny-as-not-found (404).
- A member lacks capability: start surfaces must reject (403) and the UI must reflect disabled affordances.
- Cached directory data is empty or stale; UI must not block on outbound calls and must provide a safe way to sync.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature includes long-running/queued/scheduled work. The spec MUST describe tenant isolation, run observability (type/identity/visibility), and tests.
**Constitution alignment (RBAC-UX):** This feature changes authorization behavior and navigation paths. It MUST define 404 vs 403 semantics and ensure server-side enforcement for operation-start flows.
**Constitution alignment (OPS-EX-AUTH-001):** Outbound HTTP without a canonical run is not allowed on Monitoring/Operations pages.
**Constitution alignment (BADGE-001):** Any new/changed status presentation for runs MUST remain centralized and covered by tests.
**Constitution alignment (Admin UI Action Surfaces):** This feature changes multiple admin UI surfaces and MUST satisfy the UI Action Surface Contract (see matrix below).
### Functional Requirements
- **FR-001 (Canonical tracking)**: The system MUST treat the canonical run record as the single source of truth for execution tracking (status, progress, results, errors) for the in-scope operations.
- **FR-002 (Dispatch-time creation)**: Every start surface (UI action, console command, scheduler, internal service) MUST create the canonical run record before dispatching any asynchronous work.
- **FR-003 (No job fallback-create)**: Background workers MUST NOT create canonical run records as a fallback; missing run identifiers are treated as a fatal contract violation.
- **FR-004 (Canonical deep-link)**: The system MUST support exactly one canonical deep-link format for viewing runs which is tenantless and stable.
- **FR-005 (Membership + capability rules)**: Access to operation runs MUST follow these rules:
- Non-members of the workspace scope MUST receive deny-as-not-found (404).
- Workspace members who lack the required capability for the operation type MUST receive 403.
- **FR-005a (View capability mapping)**: “View run” MUST require the same capability as “Start” for the corresponding operation type.
- **FR-006 (DB-only rendering)**: Operations/monitoring and run viewer pages MUST render solely from persisted data and MUST NOT perform outbound calls during rendering/search/label resolution.
- **FR-007 (Legacy history read-only)**: Legacy run history records MUST remain viewable as historical data, but MUST be strictly read-only (no start/retry/execute actions).
- **FR-008 (Legacy redirects)**: If a legacy history record includes a canonical mapping, the legacy “view” page MUST redirect deterministically to the canonical viewer; otherwise it MUST display legacy-only history.
- **FR-009 (No new legacy rows)**: For the in-scope operations, the system MUST stop writing new legacy run history rows. Existing legacy history remains unchanged.
- **FR-010 (Scheduled backup classification)**: Scheduled backup executions MUST be represented with a distinct operation type (not conflated with manual runs).
- **FR-011 (Run identity & dedupe)**: The system MUST compute deterministic run identities for dedupe and scheduler double-fire protection, and MUST define whether each type dedupes “while active” or is strictly unique.
- **FR-011b (Scheduled backups are strict)**: Scheduled backup executions MUST use strict dedupe per schedule and intended fire-time (at most one canonical run ever per schedule per intended fire-time).
- **FR-011a (Backup manual runs are unique)**: Manual backup schedule runs (“run now”) and retries MUST be unique per user action (no while-active dedupe).
- **FR-012 (Inputs & provenance)**: The system MUST store operation inputs and provenance (target tenant/schedule, trigger source, optional initiating user) on the canonical run record.
- **FR-013 (Structured results)**: The system MUST store a standard, structured summary of results (counts) and failures (structured error entries) on the canonical run record.
- **FR-014 (Restore domain vs execution)**: Restore workflow domain records may remain as domain entities, but execution tracking and “View run” affordances MUST use the canonical run record exclusively.
- **FR-015 (Cached directory data)**: The system MUST provide cached directory group data and cached role definition data to support search and label rendering in configuration forms without outbound calls.
- **FR-015a (Role definitions sync type)**: The role definitions cache sync MUST use a dedicated operation type (e.g., `directory_role_definitions.sync`) to keep identities, results, and auditability distinct from other sync operations.
- **FR-016 (Safe “Sync now”)**: When cached directory data is missing, the UI MUST provide a non-destructive “Sync now” action that starts an asynchronous sync and immediately exposes the canonical run link.
#### Assumptions
- A canonical run model/viewer already exists and is suitable for monitoring long-running operations.
- Outbound calls to external services are permitted only in asynchronous execution paths and are observable via the canonical run record.
#### Out of Scope
- Backfilling legacy history into canonical runs.
- Dropping/removing legacy run history tables.
- Introducing new cross-workspace analytics.
## UI Action Matrix *(mandatory when admin 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Operations viewer | Canonical run viewer route | None | Open by canonical link | None | None | None | None | N/A | Yes (canonical run record metadata) | Must be DB-only rendering; non-member is 404 |
| Inventory sync start | Inventory admin UI | Start sync | View run link appears after start | View run | None | None | N/A | N/A | Yes | Capability-gated; creates canonical run before dispatch |
| Directory groups sync start | Directory groups admin UI & console | Sync now | View run link appears after start | View run | None | Sync now (when cache empty) | N/A | N/A | Yes | Single dispatcher entry; legacy start actions removed |
| Backup schedule runs list | Backup schedule detail | None | List links open canonical viewer | View run | None | None | N/A | N/A | Yes | Includes scheduled/manual/retry runs; scheduled has distinct type |
| Tenant configuration selectors | Tenant settings forms | Sync now (when cache empty) | Search from cached data | None | None | Sync now | N/A | Save/Cancel | Yes | No outbound calls in search/label resolution |
| Legacy run history pages | Archive/history areas | None | View (read-only) | View only | None | None | None | N/A | Yes (historical) | No Start/Retry; redirect only if canonical mapping exists |
### Key Entities *(include if feature involves data)*
- **Canonical Run**: A single, shareable execution record containing type, identity, provenance, status, progress, results, and errors.
- **Legacy Run History Record**: A historical record for prior run-tracking paths; viewable but not mutable.
- **Managed Tenant**: The tenant context targeted by operations.
- **Backup Schedule**: A schedule configuration that can trigger executions automatically.
- **Restore Run (Domain Record)**: The domain workflow record for restore; links to canonical execution runs.
- **Directory Group Cache**: Cached group metadata used for searching/label rendering in forms.
- **Role Definition Cache**: Cached role definition metadata used for searching/label rendering in forms.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of newly started in-scope operations create a canonical run record before any asynchronous work is dispatched.
- **SC-002**: Over a 30-day staging observation window, 0 new legacy run history rows are created for in-scope operations.
- **SC-003**: Operations viewer and monitoring pages perform 0 outbound calls during rendering/search/label resolution (verified by automated tests).
- **SC-004**: For scheduled backups, duplicate scheduler fires for the same schedule and intended fire-time result in at most 1 canonical run.
- **SC-005**: Users can open a canonical “View run” link and see status/progress within 2 seconds in typical conditions.

View File

@ -0,0 +1,143 @@
---
description: "Task list for Spec 086 implementation"
---
# Tasks: Retire Legacy Runs Into Operation Runs (086)
**Input**: Design documents from `specs/086-retire-legacy-runs-into-operation-runs/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
**Tests**: REQUIRED (Pest) — runtime behavior changes must be covered.
## Phase 1: Setup (Shared Infrastructure)
- [x] T001 Confirm baseline green test subset via `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
- [x] T002 Confirm Filament v5 + Livewire v4 constraints are respected for any touched pages/resources in `app/Filament/**`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives required by all stories.
- [x] T003 Add centralized “run type → required capability” resolver in `app/Support/Operations/OperationRunCapabilityResolver.php`
- [x] T004 Update `app/Policies/OperationRunPolicy.php` to enforce clarified 404/403 semantics (non-member 404; member missing capability 403) using T003
- [x] T005 [P] Add/extend operation type registry for new types in `app/Support/OperationRunType.php`
- [x] T006 [P] Add/extend operation labels/catalog entries in `app/Support/OperationCatalog.php`
- [x] T007 Add tests covering view authorization semantics in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` (404 vs 403 + capability-gated view)
**Checkpoint**: Canonical viewer authorization matches spec; new run types exist in registries.
---
## Phase 3: User Story 1 — Start an operation with an immediate canonical run link (Priority: P1)
**Goal**: All start surfaces create an `operation_runs` record at dispatch time; no job fallback-create; “View run” link is stable.
**Independent Test**: Start each in-scope operation and assert the `operation_runs` row exists before work begins, with correct type/identity/context and a stable tenantless view URL.
### Tests (US1)
- [x] T008 [P] [US1] Add/extend tests for OperationRun dispatch-time creation in `tests/Feature/OperationRunServiceTest.php`
- [X] T009 [P] [US1] Add/extend tests for start-surface authorization (403 prevents run creation) in `tests/Feature/RunStartAuthorizationTest.php`
### Implementation (US1)
- [X] T010 [US1] Ensure inventory sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
- [X] T011 [US1] Ensure directory groups sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
- [X] T012 [US1] Ensure backup schedule manual run-now creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php`
- [X] T013 [US1] Ensure backup schedule retry creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php`
- [x] T014 [US1] Ensure scheduled backup dispatcher creates OperationRun before dispatch with strict identity by (schedule_id, scheduled_for) and type `backup_schedule.scheduled` in `app/Services/BackupScheduling/BackupScheduleDispatcher.php`
- [x] T014a [US1] Enforce strict scheduled backup idempotency (at most one canonical run ever per schedule_id + intended fire-time), using an explicit DB constraint and/or lock strategy aligned with `OperationRunService` identities
- [x] T015 [US1] Enforce “no job fallback-create” by validating required OperationRun context is present; fail fast if missing in `app/Jobs/RunInventorySyncJob.php`, `app/Jobs/EntraGroupSyncJob.php`, and `app/Jobs/RunBackupScheduleJob.php`
### Restore (US1)
- [X] T015a [P] [US1] Add/extend tests that starting a restore execution creates an OperationRun at dispatch time (target existing restore execution tests under `tests/Feature/RestoreRunWizardExecuteTest.php` and/or `tests/Feature/ExecuteRestoreRunJobTest.php`)
- [X] T015b [US1] Ensure the restore execution start surface creates OperationRun before dispatch and surfaces the stable canonical “View run” link (adjust the Filament restore execution action/page used in the wizard flow)
- [x] T015c [US2] Ensure restore domain records link to canonical OperationRuns for observability (align with FR-014; no legacy fallback-create)
**Checkpoint**: Starting operations always yields a stable `/admin/operations/{run}` link immediately.
---
## Phase 4: User Story 2 — Monitor executions from a single canonical viewer (Priority: P2)
**Goal**: Canonical viewer and Monitoring pages remain DB-only; legacy run history pages are read-only and redirect only when a deterministic mapping exists.
**Independent Test**: Load canonical viewer and legacy view pages while asserting no outbound Graph calls occur during render/search/label callbacks.
### Tests (US2)
- [X] T016 [P] [US2] Add tests asserting Monitoring pages render DB-only (no Graph calls) in `tests/Feature/Monitoring/MonitoringOperationsTest.php`
- [X] T017 [P] [US2] Add tests for legacy-to-canonical redirect when mapping exists and no redirect when mapping absent in `tests/Feature/Operations/` (new file: `tests/Feature/Operations/LegacyRunRedirectTest.php`)
### Implementation (US2)
- [X] T018 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `inventory_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_inventory_sync_runs_table.php`)
- [X] T019 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `entra_group_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_entra_group_sync_runs_table.php`)
- [X] T020 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `backup_schedule_runs` (new migration in `database/migrations/**_add_operation_run_id_to_backup_schedule_runs_table.php`)
- [x] T021 [US2] Stop writing NEW legacy run rows for inventory sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Inventory/InventorySyncService.php` and any start surfaces)
- [x] T022 [US2] Stop writing NEW legacy run rows for directory group sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Directory/EntraGroupSyncService.php` and any start surfaces)
- [x] T023 [US2] Stop writing NEW legacy run rows for backup schedule executions and use `operation_runs` only for execution tracking; keep legacy table strictly read-only history for existing rows (adjust dispatcher and UI surfaces in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` and `app/Filament/Resources/BackupScheduleResource.php`)
- [x] T023a [US2] Update Backup Schedule UI to show new executions from `operation_runs` (query by type + context like schedule_id) and link to canonical viewer; legacy runs list remains history-only
- [X] T024 [US2] Implement deterministic redirect on legacy “view” pages when `operation_run_id` exists in `app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php` and `app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php`
- [x] T025 [US2] Ensure legacy run history pages remain strictly read-only (remove/disable start/retry actions) in `app/Filament/Resources/InventorySyncRunResource.php`, `app/Filament/Resources/EntraGroupSyncRunResource.php`, and `app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php`
**Checkpoint**: Canonical viewer is the only execution-tracker UI; legacy is view-only and redirects only when mapped.
---
## Phase 5: User Story 3 — Use cached directory data in forms without blocking calls (Priority: P3)
**Goal**: Tenant configuration selectors use cached directory groups + cached role definitions; “Sync now” triggers async sync with an immediate canonical run link; no outbound calls during render/search/label callbacks.
**Independent Test**: Render Tenant configuration forms and exercise search/label callbacks while asserting Graph client is not called.
### Tests (US3)
- [x] T026 [P] [US3] Add tests that TenantResource role definition selectors render/search DB-only (no Graph calls) in `tests/Feature/Filament/` (new file: `tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php`)
- [x] T027 [P] [US3] Add tests that “Sync now” creates an OperationRun and returns a canonical view link in `tests/Feature/DirectoryGroups/` or `tests/Feature/TenantRBAC/` (choose closest existing folder)
- [x] T027a [P] [US3] Add tests that directory group selectors render/search DB-only (no Graph calls) and use cached DB tables (new file under `tests/Feature/DirectoryGroups/` or `tests/Feature/Filament/`)
### Implementation (US3)
- [x] T028 [US3] Create cached role definitions table + model + factory (new migration in `database/migrations/**_create_entra_role_definitions_table.php`, model in `app/Models/EntraRoleDefinition.php`, factory in `database/factories/EntraRoleDefinitionFactory.php`)
- [x] T029 [US3] Add “role definitions sync” operation type `directory_role_definitions.sync` to `app/Support/OperationRunType.php` and label in `app/Support/OperationCatalog.php` (if not already completed in T005/T006)
- [x] T030 [US3] Implement role definitions sync service + job that updates the cache and records progress/failures on the OperationRun (service in `app/Services/Directory/RoleDefinitionsSyncService.php`, job in `app/Jobs/SyncRoleDefinitionsJob.php`)
- [x] T030a [US3] Register/verify Graph contract entries required for role definitions sync in `config/graph_contracts.php` and ensure the sync uses `GraphClientInterface` only (no ad-hoc endpoints)
- [x] T031 [US3] Update `app/Filament/Resources/TenantResource.php` roleDefinitions search/label callbacks to query cached DB tables only (remove Graph calls from callbacks)
- [x] T032 [US3] Add a non-destructive “Sync now” Filament action that dispatches `directory_role_definitions.sync` and provides a canonical “View run” link (in `app/Filament/Resources/TenantResource.php`)
**Checkpoint**: Tenant configuration selectors are DB-only; cache sync is async and observable via canonical run.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [x] T033 Ensure new/modified destructive-like actions (if any) use `Action::make(...)->action(...)->requiresConfirmation()` and are authorized server-side (audit existing touched Filament actions under `app/Filament/**`)
- [x] T034 Run Pint on changed files via `vendor/bin/sail bin pint --dirty`
- [x] T035 Run targeted test subset per quickstart: `vendor/bin/sail artisan test --compact --filter=OperationRun` and the new/changed test files
- [x] T036 Allow re-running onboarding verification while status is `in_progress` (prevents dead-end when a prior run is stuck and the current connection would immediately block with next steps) in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [x] T037 Auto-fail stale queued provider operation runs to allow rerun (prevents permanent dedupe when a worker isnt running) in `app/Services/Providers/ProviderOperationStartGate.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → Polish (Phase 6)
### User Story Dependencies
- US1 is the MVP: it enables stable canonical run creation + links.
- US2 depends on Foundational + US1 (viewer/auth semantics), but can be implemented in parallel once viewer auth is stable.
- US3 depends on Foundational + cache primitives, but can proceed after Foundational even if US2 is in progress.
### Parallel Execution Examples
- US1 parallelizable: T008 + T009 (tests) can be written in parallel; start-surface patches T010T014 can be split across different files.
- US2 parallelizable: migrations T018T020 can be done in parallel; legacy resource updates T024T025 can be split by resource.
- US3 parallelizable: schema/model/factory T028 can be done while tests T026T027 are being drafted.

View File

@ -2,8 +2,9 @@
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun;
use App\Services\BackupScheduling\BackupScheduleDispatcher;
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Bus;
@ -34,12 +35,24 @@
$dispatcher->dispatchDue([$tenant->external_id]);
$dispatcher->dispatchDue([$tenant->external_id]);
expect(BackupScheduleRun::query()->count())->toBe(1);
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'backup_schedule.scheduled')
->count())->toBe(1);
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
Bus::assertDispatched(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($tenant): bool {
return $job->backupScheduleId !== null
&& $job->backupScheduleRunId === 0
&& $job->operationRun?->tenant_id === $tenant->getKey()
&& $job->operationRun?->type === 'backup_schedule.scheduled';
});
});
it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () {
it('treats an existing canonical run as already-dispatched and advances next_run_at', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -59,22 +72,36 @@
'next_run_at' => null,
]);
BackupScheduleRun::query()->create([
'backup_schedule_id' => $schedule->id,
'tenant_id' => $tenant->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRunService->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'backup_schedule.scheduled',
identityInputs: [
'backup_schedule_id' => (int) $schedule->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
],
context: [
'backup_schedule_id' => (int) $schedule->id,
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
'trigger' => 'scheduled',
],
);
Bus::fake();
$dispatcher = app(BackupScheduleDispatcher::class);
$dispatcher->dispatchDue([$tenant->external_id]);
expect(BackupScheduleRun::query()->count())->toBe(1);
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
Bus::assertNotDispatched(RunBackupScheduleJob::class);
expect(OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'backup_schedule.scheduled')
->count())->toBe(1);
$schedule->refresh();
expect($schedule->next_run_at)->not->toBeNull();
expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00');

View File

@ -9,6 +9,7 @@
use App\Services\OperationRunService;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Contracts\Queue\Job;
it('creates a backup set and marks the run successful', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
@ -160,7 +161,7 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
]);
});
it('updates the operation run based on the backup schedule run id when not passed into the job', function () {
it('fails fast when operation run context is not passed into the job', function () {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -187,50 +188,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
'status' => BackupScheduleRun::STATUS_RUNNING,
]);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$operationRun = $operationRunService->ensureRun(
tenant: $tenant,
type: 'backup_schedule.run_now',
inputs: ['backup_schedule_id' => (int) $schedule->id],
initiator: $user,
);
$queueJob = \Mockery::mock(Job::class);
$queueJob->shouldReceive('fail')->once();
$operationRun->update([
'context' => array_merge($operationRun->context ?? [], [
'backup_schedule_run_id' => (int) $run->getKey(),
]),
]);
$job = new RunBackupScheduleJob($run->id);
$job->setJob($queueJob);
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
{
public function __construct() {}
public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array
{
return ['synced' => [], 'failures' => []];
}
});
$backupSet = BackupSet::factory()->create([
'tenant_id' => $tenant->id,
'status' => 'completed',
'item_count' => 0,
]);
app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService
{
public function __construct(private readonly BackupSet $backupSet) {}
public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet
{
return $this->backupSet;
}
});
Cache::flush();
(new RunBackupScheduleJob($run->id))->handle(
$job->handle(
app(PolicySyncService::class),
app(BackupService::class),
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
@ -239,14 +203,6 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
app(\App\Services\BackupScheduling\RunErrorMapper::class),
);
$operationRun->refresh();
expect($operationRun->status)->toBe('completed');
expect($operationRun->outcome)->toBe('succeeded');
expect($operationRun->context)->toMatchArray([
'backup_schedule_run_id' => (int) $run->id,
'backup_set_id' => (int) $backupSet->id,
]);
expect($operationRun->summary_counts)->toMatchArray([
'created' => 1,
]);
$run->refresh();
expect($run->status)->toBe(BackupScheduleRun::STATUS_RUNNING);
});

View File

@ -3,11 +3,11 @@
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Jobs\RunBackupScheduleJob;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\OperationRun;
use App\Models\User;
use App\Notifications\OperationRunQueued;
use App\Services\Graph\GraphClientInterface;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
@ -49,12 +49,8 @@
Livewire::test(ListBackupSchedules::class)
->callTableAction('runNow', $schedule);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(1);
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->id)
@ -62,13 +58,15 @@
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun->user_id)->toBe($user->id);
expect($operationRun->context)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id,
'trigger' => 'run_now',
]);
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($schedule, $operationRun): bool {
return $job->backupScheduleRunId === 0
&& $job->backupScheduleId === (int) $schedule->getKey()
&& $job->operationRun instanceof OperationRun
&& $job->operationRun->is($operationRun);
});
@ -88,6 +86,49 @@
->toBe(OperationRunLinks::view($operationRun, $tenant));
});
test('run now is unique per click (no dedupe)', function () {
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableAction('runNow', $schedule);
Livewire::test(ListBackupSchedules::class)
->callTableAction('runNow', $schedule);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$runs = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now')
->pluck('id')
->all();
expect($runs)->toHaveCount(2);
expect($runs[0])->not->toBe($runs[1]);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 2);
});
test('operator can retry and it persists a database notification', function () {
Queue::fake([RunBackupScheduleJob::class]);
@ -112,12 +153,8 @@
Livewire::test(ListBackupSchedules::class)
->callTableAction('retry', $schedule);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(1);
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->id)
@ -125,13 +162,15 @@
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun->user_id)->toBe($user->id);
expect($operationRun->context)->toMatchArray([
'backup_schedule_id' => (int) $schedule->id,
'backup_schedule_run_id' => (int) $run->id,
'trigger' => 'retry',
]);
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
return $job->backupScheduleRunId === (int) $run->id
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($schedule, $operationRun): bool {
return $job->backupScheduleRunId === 0
&& $job->backupScheduleId === (int) $schedule->getKey()
&& $job->operationRun instanceof OperationRun
&& $job->operationRun->is($operationRun);
});
@ -150,6 +189,49 @@
->toBe(OperationRunLinks::view($operationRun, $tenant));
});
test('retry is unique per click (no dedupe)', function () {
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
$schedule = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListBackupSchedules::class)
->callTableAction('retry', $schedule);
Livewire::test(ListBackupSchedules::class)
->callTableAction('retry', $schedule);
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
$runs = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry')
->pluck('id')
->all();
expect($runs)->toHaveCount(2);
expect($runs[0])->not->toBe($runs[1]);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 2);
});
test('readonly cannot dispatch run now or retry', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -183,7 +265,7 @@
// Action should be hidden/blocked for readonly users.
}
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
->toBe(0);
expect(OperationRun::query()
@ -230,11 +312,8 @@
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB]));
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(2);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(0);
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
@ -242,6 +321,15 @@
->count())
->toBe(2);
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.run_now')
->pluck('user_id')
->unique()
->values()
->all())
->toBe([$user->id]);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
@ -293,11 +381,8 @@
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(2);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(0);
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
@ -305,6 +390,15 @@
->count())
->toBe(2);
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry')
->pluck('user_id')
->unique()
->values()
->all())
->toBe([$user->id]);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
$this->assertDatabaseCount('notifications', 1);
$this->assertDatabaseHas('notifications', [
@ -319,66 +413,75 @@
->toBe(OperationRunLinks::index($tenant));
});
test('operator can bulk retry even if a run already exists for this minute', function () {
test('operator can bulk retry even if a previous canonical run exists', function () {
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'operator');
$frozenNow = CarbonImmutable::parse('2026-02-10 01:04:06', 'UTC');
CarbonImmutable::setTestNow($frozenNow);
$scheduleA = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly A',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
try {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$scheduleB = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly B',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$scheduleA = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly A',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '01:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
BackupScheduleRun::query()->create([
'backup_schedule_id' => $scheduleA->id,
'tenant_id' => $tenant->id,
'scheduled_for' => $scheduledFor->toDateTimeString(),
'status' => BackupScheduleRun::STATUS_RUNNING,
'summary' => null,
]);
$scheduleB = BackupSchedule::query()->create([
'tenant_id' => $tenant->id,
'name' => 'Nightly B',
'is_enabled' => true,
'timezone' => 'UTC',
'frequency' => 'daily',
'time_of_day' => '02:00:00',
'days_of_week' => null,
'policy_types' => ['deviceConfiguration'],
'include_foundations' => true,
'retention_keep_last' => 30,
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
/** @var OperationRunService $operationRunService */
$operationRunService = app(OperationRunService::class);
$existing = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'backup_schedule.retry',
identityInputs: [
'backup_schedule_id' => (int) $scheduleA->getKey(),
'nonce' => 'existing',
],
context: [
'backup_schedule_id' => (int) $scheduleA->getKey(),
'trigger' => 'retry',
],
initiator: $user,
);
$operationRunService->updateRun($existing, status: 'completed', outcome: 'succeeded');
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
$this->actingAs($user);
Filament::setTenant($tenant, true);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count())
->toBe(2);
Livewire::test(ListBackupSchedules::class)
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
$newRunA = BackupScheduleRun::query()
->where('backup_schedule_id', $scheduleA->id)
->orderByDesc('id')
->first();
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
->toBe(0);
expect($newRunA)->not->toBeNull();
expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString())
->toBe($scheduledFor->addMinute()->toDateTimeString());
expect(OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'backup_schedule.retry')
->count())
->toBe(3);
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count())
->toBe(1);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
Queue::assertPushed(RunBackupScheduleJob::class, 2);
} finally {
CarbonImmutable::setTestNow();
}
});

View File

@ -16,6 +16,8 @@
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant);
$policies = Policy::factory()
->count(3)
->create([

View File

@ -53,6 +53,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('extracts edges during inventory sync and marks missing appropriately', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps);
$svc = app(InventorySyncService::class);
@ -73,6 +74,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('respects 50-edge limit for outbound extraction', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
// Fake client returning 60 group assignments
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface
@ -132,6 +134,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('persists unsupported reference warnings on the sync run record', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface
@ -276,6 +279,7 @@ public function request(string $method, string $path, array $options = []): Grap
it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () {
$tenant = Tenant::factory()->create();
ensureDefaultProviderConnection($tenant);
$this->app->bind(GraphClientInterface::class, function () {
return new class implements GraphClientInterface

View File

@ -97,6 +97,8 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => 'tenant-1',
]);
ensureDefaultProviderConnection($tenant);
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'dcs-1',

View File

@ -48,7 +48,7 @@
$this->actingAs($this->user);
$response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
$response = $this->get(route('filament.tenant.resources.policy-versions.view', array_merge(
filamentTenantRouteParams($this->tenant),
['record' => $version],
)));

View File

@ -1,7 +1,7 @@
<?php
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
@ -17,22 +17,34 @@
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC'));
$legacyCountBefore = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
Artisan::call('tenantpilot:directory-groups:dispatch', [
'--tenant' => [$tenant->tenant_id],
]);
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
$run = EntraGroupSyncRun::query()
$legacyCountAfter = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->where('selection_key', 'groups-v1:all')
->where('slot_key', $slotKey)
->count();
expect($legacyCountAfter)->toBe($legacyCountBefore);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'directory_groups.sync')
->where('context->slot_key', $slotKey)
->first();
expect($run)->not->toBeNull()
->and($run->initiator_user_id)->toBeNull();
expect($opRun)->not->toBeNull();
expect($opRun?->user_id)->toBeNull();
Queue::assertPushed(EntraGroupSyncJob::class);
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($opRun): bool {
return (int) ($job->operationRun?->getKey() ?? 0) === (int) $opRun->getKey();
});
CarbonImmutable::setTestNow();
});

View File

@ -2,7 +2,6 @@
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun;
use App\Services\Graph\GraphClientInterface;
use Filament\Facades\Filament;
@ -27,17 +26,18 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$legacyCountBefore = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
Livewire::test(ListEntraGroups::class)
->callAction('sync_groups');
$run = EntraGroupSyncRun::query()
$legacyCountAfter = \App\Models\EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->latest('id')
->first();
->count();
expect($run)->not->toBeNull();
expect($run?->status)->toBe(EntraGroupSyncRun::STATUS_PENDING);
expect($run?->selection_key)->toBe('groups-v1:all');
expect($legacyCountAfter)->toBe($legacyCountBefore);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
@ -47,10 +47,12 @@
expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued');
expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all');
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $run, $opRun): bool {
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey()
&& $job->runId === (int) $run?->getKey()
&& $job->selectionKey === 'groups-v1:all'
&& $job->runId === null
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});

View File

@ -1,7 +1,7 @@
<?php
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun;
use App\Services\Directory\EntraGroupSyncService;
use Illuminate\Support\Facades\Queue;
@ -14,11 +14,12 @@
$run = $service->startManualSync($tenant, $user);
expect($run)->toBeInstanceOf(EntraGroupSyncRun::class)
expect($run)->toBeInstanceOf(OperationRun::class)
->and($run->tenant_id)->toBe($tenant->getKey())
->and($run->initiator_user_id)->toBe($user->getKey())
->and($run->selection_key)->toBe('groups-v1:all')
->and($run->status)->toBe(EntraGroupSyncRun::STATUS_PENDING);
->and($run->user_id)->toBe($user->getKey())
->and($run->type)->toBe('directory_groups.sync')
->and($run->status)->toBe('queued')
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all');
Queue::assertPushed(EntraGroupSyncJob::class);
});

View File

@ -2,23 +2,16 @@
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroup;
use App\Models\EntraGroupSyncRun;
use App\Services\Directory\EntraGroupSyncService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
it('sync job upserts groups and updates run counters', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = EntraGroupSyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => 'groups-v1:all',
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
EntraGroup::factory()->create([
'tenant_id' => $tenant->getKey(),
'entra_id' => '11111111-1111-1111-1111-111111111111',
@ -57,23 +50,32 @@
app()->instance(GraphClientInterface::class, $mock);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'directory_groups.sync',
inputs: ['selection_key' => 'groups-v1:all'],
initiator: $user,
);
$job = new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: 'groups-v1:all',
slotKey: null,
runId: (int) $run->getKey(),
runId: null,
operationRun: $opRun,
);
$job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class));
$run->refresh();
$opRun->refresh();
expect($run->status)->toBe(EntraGroupSyncRun::STATUS_SUCCEEDED)
->and($run->pages_fetched)->toBe(2)
->and($run->items_observed_count)->toBe(2)
->and($run->items_upserted_count)->toBe(2)
->and($run->error_count)->toBe(0)
->and($run->finished_at)->not->toBeNull();
expect($opRun->status)->toBe('completed')
->and($opRun->outcome)->toBe('succeeded')
->and($opRun->summary_counts['processed'] ?? null)->toBe(2)
->and($opRun->summary_counts['updated'] ?? null)->toBe(2)
->and($opRun->summary_counts['failed'] ?? null)->toBe(0);
expect(EntraGroup::query()->where('tenant_id', $tenant->getKey())->count())->toBe(2);

Some files were not shown because too many files have changed in this diff Show More