Merge branch '086-retire-legacy-runs-into-operation-runs-session-1770683729' into 085-tenant-operate-hub

# Conflicts:
#	.github/agents/copilot-instructions.md
This commit is contained in:
Ahmed Darrazi 2026-02-11 01:04:09 +01:00
commit c8e5996a1a
93 changed files with 4649 additions and 1599 deletions

View File

@ -46,10 +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
- 085-tenant-operate-hub: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
- 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

@ -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)
->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', '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;
@ -384,104 +384,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 +453,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 +508,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 +543,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 +604,7 @@ public static function table(Table $table): Table
$notification->send();
if (count($createdRunIds) > 0) {
if (count($createdOperationRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
})
@ -815,96 +640,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 +701,7 @@ public static function table(Table $table): Table
$notification->send();
if (count($createdRunIds) > 0) {
if (count($createdOperationRunIds) > 0) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
})
@ -931,6 +712,7 @@ public static function table(Table $table): Table
DeleteBulkAction::make('bulk_delete')
)
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
->destructive()
->apply(),
]),
]);
@ -949,6 +731,7 @@ public static function getEloquentQuery(): Builder
public static function getRelations(): array
{
return [
BackupScheduleOperationRunsRelationManager::class,
BackupScheduleRunsRelationManager::class,
];
}

View File

@ -0,0 +1,82 @@
<?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 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 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(fn (string $state): string => OperationCatalog::label($state)),
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

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

@ -125,8 +125,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

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

@ -19,6 +19,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\Rbac\UiEnforcement;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
@ -84,9 +85,61 @@ protected static function resolveScopedTenant(): ?Tenant
->first();
}
$externalId = static::resolveTenantExternalIdFromLivewireRequest();
if (is_string($externalId) && $externalId !== '') {
return Tenant::query()
->where('external_id', $externalId)
->first();
}
return Tenant::current();
}
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
{
return $schema
@ -589,15 +642,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();

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' : '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();
@ -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

@ -876,7 +876,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 +916,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 +1752,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,13 +7,17 @@
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Jobs\SyncRoleDefinitionsJob;
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\Graph\GraphClientInterface;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacOnboardingService;
use App\Services\OperationRunService;
@ -949,18 +953,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 +982,8 @@ 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))
->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 +1008,8 @@ 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))
->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...'),
])
@ -1133,6 +1127,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 +1160,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 +1223,20 @@ 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;
}
$exists = EntraRoleDefinition::query()
->where('tenant_id', $tenant->getKey())
->exists();
return $exists ? null : 'Role definitions not synced yet. Use “Sync now” to load.';
}
/**
@ -1227,114 +1252,52 @@ public static function roleSearchOptions(?Tenant $tenant, string $search): array
*/
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,67 +1307,14 @@ 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
{
if (! $tenant) {
return null;
}
return static::delegatedToken($tenant) ? null : 'Login to search groups';
return null;
}
/**
@ -1416,78 +1326,82 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra
return [];
}
$token = static::delegatedToken($tenant);
$needle = mb_strtolower($search);
if (! $token) {
return [];
}
$filter = sprintf(
"securityEnabled eq true and startswith(displayName,'%s')",
static::escapeOdataValue($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

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

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

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

@ -3,16 +3,14 @@
namespace App\Services\Inventory;
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\Providers\ProviderReasonCodes;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
@ -31,40 +29,30 @@ public function __construct(
) {}
/**
* Runs an inventory sync inline (no queue), enforcing locks/concurrency and creating an observable run record.
* 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 syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
{
$computed = $this->normalizeAndHashSelection($selectionPayload);
$normalizedSelection = $computed['selection'];
$selectionHash = $computed['selection_hash'];
$now = CarbonImmutable::now('UTC');
$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 +60,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 +101,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 +217,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 +236,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 +246,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 +432,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

@ -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,30 @@
<?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,
'provider.connection.check' => Capabilities::PROVIDER_RUN,
// 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

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

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

View File

@ -2,11 +2,11 @@
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;
use Illuminate\Support\Facades\Config;
it('purges cached groups older than the retention window', function () {
@ -24,24 +24,27 @@
'last_seen_at' => now('UTC')->subDays(10),
]);
$run = EntraGroupSyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => 'groups-v1:all',
'status' => EntraGroupSyncRun::STATUS_PENDING,
'initiator_user_id' => $user->getKey(),
]);
$mock = \Mockery::mock(GraphClientInterface::class);
$mock->shouldReceive('request')
->once()
->andReturn(new GraphResponse(success: true, data: ['value' => []], status: 200));
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));

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\EntraGroup;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('searches cached directory groups without Graph calls', function (): void {
bindFailHardGraphClient();
/** @var Tenant $tenant */
$tenant = Tenant::factory()->create();
EntraGroup::factory()
->for($tenant)
->create([
'entra_id' => '33333333-3333-3333-3333-333333333333',
'display_name' => 'TenantPilot Operators',
]);
$options = assertNoOutboundHttp(fn () => TenantResource::groupSearchOptions($tenant, 'Ten'));
expect($options)->toMatchArray([
'33333333-3333-3333-3333-333333333333' => 'TenantPilot Operators (…33333333)',
]);
});
it('resolves a directory group label from cached data without Graph calls', function (): void {
bindFailHardGraphClient();
/** @var Tenant $tenant */
$tenant = Tenant::factory()->create();
EntraGroup::factory()
->for($tenant)
->create([
'entra_id' => '44444444-4444-4444-4444-444444444444',
'display_name' => 'TenantPilot Admins',
]);
$label = assertNoOutboundHttp(fn () => TenantResource::groupLabelFromCache($tenant, '44444444-4444-4444-4444-444444444444'));
expect($label)->toBe('TenantPilot Admins (…44444444)');
});

View File

@ -8,6 +8,7 @@
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
@ -15,7 +16,7 @@
uses(RefreshDatabase::class);
test('execute restore run job moves queued to running and calls the executor', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
@ -60,7 +61,18 @@
});
});
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
],
initiator: null,
);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$job->handle($restoreService, app(AuditLogger::class));
$restoreRun->refresh();
@ -70,7 +82,7 @@
});
test('execute restore run job persists per-item outcomes keyed by backup_item_id', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-results',
'name' => 'Tenant Results',
'metadata' => [],
@ -116,8 +128,43 @@
'metadata' => [],
]);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
$job->handle(app(RestoreService::class), app(AuditLogger::class));
$restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($backupItem): void {
$mock->shouldReceive('executeForRun')
->once()
->andReturnUsing(function (...$args) use ($backupItem): RestoreRun {
/** @var RestoreRun $run */
$run = $args[0];
$run->update([
'status' => RestoreRunStatus::Completed->value,
'completed_at' => now(),
'results' => [
'items' => [
(string) $backupItem->id => [
'backup_item_id' => $backupItem->id,
'status' => 'skipped',
],
],
],
]);
return $run->refresh();
});
});
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
],
initiator: null,
);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$job->handle($restoreService, app(AuditLogger::class));
$restoreRun->refresh();

View File

@ -2,18 +2,14 @@
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Filament\Resources\EntraGroupSyncRunResource\Pages\ListEntraGroupSyncRuns;
use App\Jobs\EntraGroupSyncJob;
use App\Models\EntraGroupSyncRun;
use App\Models\Tenant;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Support\Auth\UiTooltips;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
@ -69,7 +65,7 @@
->assertForbidden();
});
test('sync groups action enqueues job and writes database notification', function () {
test('legacy sync runs list is read-only (no sync action)', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -79,76 +75,10 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroupSyncRuns::class)
->callAction('sync_groups');
Queue::assertPushed(EntraGroupSyncJob::class);
$run = EntraGroupSyncRun::query()->where('tenant_id', $tenant->getKey())->latest('id')->first();
expect($run)->not->toBeNull();
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->getKey(),
'notifiable_type' => $user->getMorphClass(),
'type' => RunStatusChangedNotification::class,
]);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull();
expect($notification->data['actions'][0]['url'] ?? null)
->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
});
test('sync groups action is forbidden for readonly members when disabled check is bypassed', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListEntraGroupSyncRuns::class)->instance();
$action = $component->getAction([['name' => 'sync_groups']]);
expect($action)->not->toBeNull();
$thrown = null;
try {
$action->callBefore();
$action->call();
} catch (HttpException $exception) {
$thrown = $exception;
}
expect($thrown)->not->toBeNull();
expect($thrown?->getStatusCode())->toBe(403);
Queue::assertNothingPushed();
$runCount = EntraGroupSyncRun::query()
->where('tenant_id', $tenant->getKey())
->count();
expect($runCount)->toBe(0);
});
test('sync groups action is disabled for readonly users with standard tooltip', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListEntraGroupSyncRuns::class)
->assertActionVisible('sync_groups')
->assertActionDisabled('sync_groups')
->assertActionExists('sync_groups', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
expect($action)->toBeNull();
Queue::assertNothingPushed();
});

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource;
use App\Models\EntraRoleDefinition;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('searches cached role definitions without Graph calls', function (): void {
bindFailHardGraphClient();
/** @var Tenant $tenant */
$tenant = Tenant::factory()->create();
EntraRoleDefinition::factory()
->for($tenant)
->create([
'entra_id' => '11111111-1111-1111-1111-111111111111',
'display_name' => 'Policy and Profile Manager',
]);
$options = assertNoOutboundHttp(fn () => TenantResource::roleSearchOptions($tenant, 'Pol'));
expect($options)->toMatchArray([
'11111111-1111-1111-1111-111111111111' => 'Policy and Profile Manager (11111111)',
]);
});
it('resolves a role definition label from cached data without Graph calls', function (): void {
bindFailHardGraphClient();
/** @var Tenant $tenant */
$tenant = Tenant::factory()->create();
EntraRoleDefinition::factory()
->for($tenant)
->create([
'entra_id' => '22222222-2222-2222-2222-222222222222',
'display_name' => 'Read Only Operator',
]);
$label = assertNoOutboundHttp(fn () => TenantResource::roleLabelFromCache($tenant, '22222222-2222-2222-2222-222222222222'));
expect($label)->toBe('Read Only Operator (22222222)');
});

View File

@ -7,6 +7,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
@ -30,10 +31,7 @@
Queue::assertPushed(RunInventorySyncJob::class);
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->user_id)->toBe($user->id);
expect($run->status)->toBe(InventorySyncRun::STATUS_PENDING);
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
$opRun = OperationRun::query()
->where('tenant_id', $tenant->id)
@ -43,6 +41,10 @@
->first();
expect($opRun)->not->toBeNull();
expect($opRun->status)->toBe('queued');
$context = is_array($opRun->context) ? $opRun->context : [];
expect($context['selection_hash'] ?? null)->not->toBeNull();
});
it('dispatches inventory sync for selected policy types', function () {
@ -67,9 +69,17 @@
Queue::assertPushed(RunInventorySyncJob::class);
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->selection_payload['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes);
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
$opRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'inventory.sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
$context = is_array($opRun->context) ? $opRun->context : [];
expect($context['policy_types'] ?? [])->toEqualCanonicalizing($selectedTypes);
});
it('persists include dependencies toggle into the run selection payload', function () {
@ -92,9 +102,17 @@
])
->assertHasNoActionErrors();
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect((bool) ($run->selection_payload['include_dependencies'] ?? true))->toBeFalse();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
$opRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'inventory.sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
$context = is_array($opRun->context) ? $opRun->context : [];
expect((bool) ($context['include_dependencies'] ?? true))->toBeFalse();
});
it('defaults include foundations toggle to true and persists it into the run selection payload', function () {
@ -117,9 +135,17 @@
->callMountedAction()
->assertHasNoActionErrors();
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect((bool) ($run->selection_payload['include_foundations'] ?? false))->toBeTrue();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
$opRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'inventory.sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
$context = is_array($opRun->context) ? $opRun->context : [];
expect((bool) ($context['include_foundations'] ?? false))->toBeTrue();
});
it('persists include foundations toggle into the run selection payload', function () {
@ -142,9 +168,17 @@
])
->assertHasNoActionErrors();
$run = InventorySyncRun::query()->where('tenant_id', $tenant->id)->latest('id')->first();
expect($run)->not->toBeNull();
expect((bool) ($run->selection_payload['include_foundations'] ?? true))->toBeFalse();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
$opRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'inventory.sync')
->latest('id')
->first();
expect($opRun)->not->toBeNull();
$context = is_array($opRun->context) ? $opRun->context : [];
expect((bool) ($context['include_foundations'] ?? true))->toBeFalse();
});
it('rejects cross-tenant initiation attempts (403) with no side effects', function () {
@ -180,27 +214,29 @@
$selectionPayload = $sync->defaultSelectionPayload();
$computed = $sync->normalizeAndHashSelection($selectionPayload);
InventorySyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'selection_hash' => $computed['selection_hash'],
'selection_payload' => $computed['selection'],
'status' => InventorySyncRun::STATUS_RUNNING,
'had_errors' => false,
'error_codes' => [],
'error_context' => null,
$opService = app(OperationRunService::class);
$existing = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'inventory.sync',
identityInputs: [
'selection_hash' => $computed['selection_hash'],
],
context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'],
]),
initiator: $user,
);
$existing->forceFill([
'status' => 'running',
'started_at' => now(),
'finished_at' => null,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'errors_count' => 0,
]);
])->save();
Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: ['policy_types' => $computed['selection']['policy_types']]);
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1);
});

View File

@ -5,12 +5,17 @@
use App\Models\BackupScheduleRun;
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Inventory\InventoryMetaSanitizer;
use App\Services\Inventory\InventoryMissingService;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
@ -66,6 +71,84 @@ public function request(string $method, string $path, array $options = []): Grap
};
}
/**
* Executes an inventory sync against a canonical OperationRun.
*
* @param array<string, mixed> $selection
* @return array{opRun: \App\Models\OperationRun, result: array<string, mixed>, selection: array<string, mixed>, selection_hash: string}
*/
function executeInventorySyncNow(Tenant $tenant, array $selection): array
{
$service = app(InventorySyncService::class);
$opService = app(OperationRunService::class);
$defaultConnection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('is_default', true)
->first();
if (! $defaultConnection instanceof ProviderConnection) {
$defaultConnection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => $tenant->tenant_id,
'is_default' => true,
'status' => 'ok',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $defaultConnection->getKey(),
'type' => 'client_secret',
'payload' => [
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
],
]);
}
$computed = $service->normalizeAndHashSelection($selection);
$context = array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'],
]);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'inventory.sync',
identityInputs: [
'selection_hash' => $computed['selection_hash'],
],
context: $context,
initiator: null,
);
$result = $service->executeSelection($opRun, $tenant, $context);
$status = (string) ($result['status'] ?? 'failed');
$outcome = match ($status) {
'success' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value,
default => OperationRunOutcome::Failed->value,
};
$opService->updateRun(
$opRun,
status: OperationRunStatus::Completed->value,
outcome: $outcome,
summaryCounts: [
'total' => count($computed['selection']['policy_types'] ?? []),
],
);
return [
'opRun' => $opRun->refresh(),
'result' => $result,
'selection' => $computed['selection'],
'selection_hash' => $computed['selection_hash'],
];
}
test('inventory sync upserts and updates last_seen fields without duplicates', function () {
$tenant = Tenant::factory()->create();
@ -75,8 +158,6 @@ public function request(string $method, string $path, array $options = []): Grap
],
]));
$service = app(InventorySyncService::class);
$selection = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
@ -84,21 +165,21 @@ public function request(string $method, string $path, array $options = []): Grap
'include_dependencies' => false,
];
$runA = $service->syncNow($tenant, $selection);
expect($runA->status)->toBe('success');
$runA = executeInventorySyncNow($tenant, $selection);
expect($runA['result']['status'] ?? null)->toBe('success');
$item = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->first();
expect($item)->not->toBeNull();
expect($item->external_id)->toBe('cfg-1');
expect($item->last_seen_run_id)->toBe($runA->id);
expect($item->last_seen_operation_run_id)->toBe($runA['opRun']->id);
$runB = $service->syncNow($tenant, $selection);
$runB = executeInventorySyncNow($tenant, $selection);
$items = \App\Models\InventoryItem::query()->where('tenant_id', $tenant->id)->get();
expect($items)->toHaveCount(1);
$items->first()->refresh();
expect($items->first()->last_seen_run_id)->toBe($runB->id);
expect($items->first()->last_seen_operation_run_id)->toBe($runB['opRun']->id);
});
test('inventory sync includes foundation types when include_foundations is true', function () {
@ -117,16 +198,14 @@ public function request(string $method, string $path, array $options = []): Grap
],
]));
$service = app(InventorySyncService::class);
$run = $service->syncNow($tenant, [
$run = executeInventorySyncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => true,
'include_dependencies' => false,
]);
expect($run->status)->toBe('success');
expect($run['result']['status'] ?? null)->toBe('success');
expect(\App\Models\InventoryItem::query()
->where('tenant_id', $tenant->id)
@ -159,16 +238,14 @@ public function request(string $method, string $path, array $options = []): Grap
],
]));
$service = app(InventorySyncService::class);
$run = $service->syncNow($tenant, [
$run = executeInventorySyncNow($tenant, [
'policy_types' => ['roleScopeTag'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => false,
]);
expect($run->status)->toBe('success');
expect($run['result']['status'] ?? null)->toBe('success');
expect(\App\Models\InventoryItem::query()
->where('tenant_id', $tenant->id)
@ -192,16 +269,14 @@ public function request(string $method, string $path, array $options = []): Grap
],
]));
$service = app(InventorySyncService::class);
$run = $service->syncNow($tenant, [
$run = executeInventorySyncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => true,
'include_dependencies' => false,
]);
expect($run->status)->toBe('success');
expect($run['result']['status'] ?? null)->toBe('success');
$foundationItem = \App\Models\InventoryItem::query()
->where('tenant_id', $tenant->id)
@ -247,29 +322,27 @@ public function request(string $method, string $path, array $options = []): Grap
],
]));
$service = app(InventorySyncService::class);
$runA = $service->syncNow($tenantA, [
$runA = executeInventorySyncNow($tenantA, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => true,
'include_dependencies' => false,
]);
expect($runA->status)->toBe('success');
expect($runA->items_observed_count)->toBe(4);
expect($runA->items_upserted_count)->toBe(4);
expect($runA['result']['status'] ?? null)->toBe('success');
expect((int) ($runA['result']['items_observed_count'] ?? 0))->toBe(4);
expect((int) ($runA['result']['items_upserted_count'] ?? 0))->toBe(4);
$runB = $service->syncNow($tenantB, [
$runB = executeInventorySyncNow($tenantB, [
'policy_types' => ['deviceConfiguration'],
'categories' => [],
'include_foundations' => false,
'include_dependencies' => false,
]);
expect($runB->status)->toBe('success');
expect($runB->items_observed_count)->toBe(1);
expect($runB->items_upserted_count)->toBe(1);
expect($runB['result']['status'] ?? null)->toBe('success');
expect((int) ($runB['result']['items_observed_count'] ?? 0))->toBe(1);
expect((int) ($runB['result']['items_upserted_count'] ?? 0))->toBe(1);
});
test('configuration policy inventory filtering: settings catalog is not stored as security baseline', function () {
@ -306,7 +379,7 @@ public function request(string $method, string $path, array $options = []): Grap
'include_dependencies' => false,
];
app(InventorySyncService::class)->syncNow($tenant, $selection);
executeInventorySyncNow($tenant, $selection);
expect(\App\Models\InventoryItem::query()
->where('tenant_id', $tenant->id)
@ -375,13 +448,13 @@ public function request(string $method, string $path, array $options = []): Grap
],
]));
app(InventorySyncService::class)->syncNow($tenant, $selection);
executeInventorySyncNow($tenant, $selection);
app()->instance(GraphClientInterface::class, fakeGraphClient([
'deviceConfiguration' => [],
]));
app(InventorySyncService::class)->syncNow($tenant, $selection);
executeInventorySyncNow($tenant, $selection);
$missingService = app(InventoryMissingService::class);
$result = $missingService->missingForSelection($tenant, $selection);
@ -393,7 +466,7 @@ public function request(string $method, string $path, array $options = []): Grap
'deviceConfiguration' => [],
], failedTypes: ['deviceConfiguration']));
app(InventorySyncService::class)->syncNow($tenant, $selection);
executeInventorySyncNow($tenant, $selection);
$result2 = $missingService->missingForSelection($tenant, $selection);
expect($result2['missing'])->toHaveCount(1);
@ -426,10 +499,8 @@ public function request(string $method, string $path, array $options = []): Grap
],
]));
$service = app(InventorySyncService::class);
$service->syncNow($tenant, $selectionX);
$service->syncNow($tenant, $selectionY);
executeInventorySyncNow($tenant, $selectionX);
executeInventorySyncNow($tenant, $selectionY);
$missingService = app(InventoryMissingService::class);
$resultX = $missingService->missingForSelection($tenant, $selectionX);
@ -444,8 +515,6 @@ public function request(string $method, string $path, array $options = []): Grap
'deviceConfiguration' => [],
]));
$service = app(InventorySyncService::class);
$selection = [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
@ -457,10 +526,11 @@ public function request(string $method, string $path, array $options = []): Grap
$lock = Cache::lock("inventory_sync:tenant:{$tenant->id}:selection:{$hash}", 900);
expect($lock->get())->toBeTrue();
$run = $service->syncNow($tenant, $selection);
$run = executeInventorySyncNow($tenant, $selection);
expect($run->status)->toBe('skipped');
expect($run->error_codes)->toContain('lock_contended');
expect($run['result']['status'] ?? null)->toBe('skipped');
$codes = is_array($run['result']['error_codes'] ?? null) ? $run['result']['error_codes'] : [];
expect($codes)->toContain('lock_contended');
$lock->release();
});
@ -480,9 +550,7 @@ public function request(string $method, string $path, array $options = []): Grap
'deviceConfiguration' => [],
]));
$service = app(InventorySyncService::class);
$service->syncNow($tenant, [
executeInventorySyncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
@ -503,18 +571,16 @@ public function request(string $method, string $path, array $options = []): Grap
app()->instance(GraphClientInterface::class, fakeGraphClient(throwable: $throwable));
$service = app(InventorySyncService::class);
$run = $service->syncNow($tenant, [
$run = executeInventorySyncNow($tenant, [
'policy_types' => ['deviceConfiguration'],
'categories' => ['Configuration'],
'include_foundations' => false,
'include_dependencies' => false,
]);
expect($run->status)->toBe('failed');
expect($run['result']['status'] ?? null)->toBe('failed');
$context = is_array($run->error_context) ? $run->error_context : [];
$context = is_array($run['result']['error_context'] ?? null) ? $run['result']['error_context'] : [];
$message = (string) ($context['message'] ?? '');
expect($message)->not->toContain('abc.def.ghi');

View File

@ -1,10 +1,8 @@
<?php
use App\Jobs\RunInventorySyncJob;
use App\Models\InventorySyncRun;
use App\Notifications\OperationRunCompleted;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
@ -14,17 +12,31 @@
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->mock(GraphClientInterface::class, function (MockInterface $mock) {
$mock->shouldReceive('listPolicies')
->atLeast()
->once()
->andReturn(new GraphResponse(true, [], 200));
$mock->shouldReceive('listPolicies')->never();
});
$sync = app(InventorySyncService::class);
$selectionPayload = $sync->defaultSelectionPayload();
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
$run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']);
$mockSync = \Mockery::mock(InventorySyncService::class);
$mockSync
->shouldReceive('executeSelection')
->once()
->andReturn([
'status' => 'success',
'had_errors' => false,
'error_codes' => [],
'error_context' => [],
'errors_count' => 0,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'skipped_policy_types' => [],
'processed_policy_types' => $computed['selection']['policy_types'],
'failed_policy_types' => [],
'selection_hash' => $computed['selection_hash'],
]);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
@ -38,20 +50,13 @@
$job = new RunInventorySyncJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->getKey(),
operationRun: $opRun,
);
$job->handle($sync, app(AuditLogger::class), $opService);
$job->handle($mockSync, app(AuditLogger::class), $opService);
$run->refresh();
$opRun->refresh();
expect($run->user_id)->toBe($user->id);
expect($run->status)->toBe(InventorySyncRun::STATUS_SUCCESS);
expect($run->started_at)->not->toBeNull();
expect($run->finished_at)->not->toBeNull();
expect($opRun->status)->toBe('completed');
expect($opRun->outcome)->toBe('succeeded');
@ -75,13 +80,10 @@
$sync = app(InventorySyncService::class);
$selectionPayload = $sync->defaultSelectionPayload();
$run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload);
$computed = $sync->normalizeAndHashSelection($selectionPayload);
$policyTypes = $computed['selection']['policy_types'];
$run->update(['selection_payload' => $computed['selection']]);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
@ -93,34 +95,32 @@
$mockSync = \Mockery::mock(InventorySyncService::class);
$mockSync
->shouldReceive('executePendingRun')
->shouldReceive('executeSelection')
->once()
->andReturnUsing(function (InventorySyncRun $inventorySyncRun) {
$inventorySyncRun->forceFill([
'status' => InventorySyncRun::STATUS_SKIPPED,
'error_codes' => ['locked'],
'selection_payload' => $inventorySyncRun->selection_payload ?? [],
'started_at' => now(),
'finished_at' => now(),
])->save();
return $inventorySyncRun;
});
->andReturn([
'status' => 'skipped',
'had_errors' => true,
'error_codes' => ['locked'],
'error_context' => [],
'errors_count' => 0,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'skipped_policy_types' => $computed['selection']['policy_types'],
'processed_policy_types' => [],
'failed_policy_types' => [],
'selection_hash' => $computed['selection_hash'],
]);
$job = new RunInventorySyncJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
inventorySyncRunId: (int) $run->getKey(),
operationRun: $opRun,
);
$job->handle($mockSync, app(AuditLogger::class), $opService);
$run->refresh();
$opRun->refresh();
expect($run->status)->toBe(InventorySyncRun::STATUS_SKIPPED);
expect($opRun->status)->toBe('completed');
expect($opRun->outcome)->toBe('failed');

View File

@ -746,6 +746,74 @@
Bus::assertNotDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('fails a stale queued verification run and allows starting a new verification run', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = '99999999-9999-9999-9999-999999999999';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$staleRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => sha1('stale-queued-verify-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'created_at' => now()->subMinutes(10),
'updated_at' => now()->subMinutes(10),
]);
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
$component->call('startVerification');
$staleRun->refresh();
expect($staleRun->status)->toBe('completed');
expect($staleRun->outcome)->toBe('failed');
expect($staleRun->context)->toBeArray();
expect($staleRun->context['verification_report'] ?? null)->toBeArray();
$report = $staleRun->context['verification_report'] ?? null;
expect($report['checks'] ?? null)->toBeArray();
expect($report['checks'][0]['message'] ?? null)->toBe('Run was queued but never started. A queue worker may not be running.');
$newRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->firstOrFail();
expect((int) $newRun->getKey())->not->toBe((int) $staleRun->getKey());
Bus::assertDispatched(\App\Jobs\ProviderConnectionHealthCheckJob::class);
});
it('registers the onboarding capability in the canonical registry', function (): void {
expect(Capabilities::isKnown(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD))->toBeTrue();
});

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
it('renders Monitoring pages DB-only (no outbound HTTP, no background work)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => 'queued',
'outcome' => 'pending',
'initiator_name' => 'System',
]);
$this->actingAs($user);
Bus::fake();
Filament::setTenant(null, true);
assertNoOutboundHttp(function () use ($tenant, $run) {
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations')
->assertOk();
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk();
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/alerts')
->assertOk();
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/audit-log')
->assertOk();
});
Bus::assertNothingDispatched();
});

View File

@ -127,6 +127,78 @@
expect(OperationRun::query()->count())->toBe(2);
});
it('reuses the same run even after completion when using strict identity', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$runA = $service->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'backup_schedule.scheduled',
identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'],
context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'],
);
$runA->update(['status' => 'completed', 'outcome' => 'succeeded']);
$runB = $service->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'backup_schedule.scheduled',
identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'],
context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'],
);
expect($runA->getKey())->toBe($runB->getKey());
expect(OperationRun::query()->count())->toBe(1);
});
it('handles strict unique-index race collisions by returning the existing run', function () {
$tenant = Tenant::factory()->create();
$service = new OperationRunService;
$fired = false;
$dispatcher = OperationRun::getEventDispatcher();
OperationRun::creating(function (OperationRun $model) use (&$fired, $tenant): void {
if ($fired) {
return;
}
$fired = true;
OperationRun::withoutEvents(function () use ($model, $tenant): void {
OperationRun::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $model->tenant_id,
'user_id' => $model->user_id,
'initiator_name' => $model->initiator_name,
'type' => $model->type,
'status' => $model->status,
'outcome' => $model->outcome,
'run_identity_hash' => $model->run_identity_hash,
'context' => $model->context,
]);
});
});
try {
$run = $service->ensureRunWithIdentityStrict(
tenant: $tenant,
type: 'backup_schedule.scheduled',
identityInputs: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'],
context: ['backup_schedule_id' => 123, 'scheduled_for' => '2026-01-05 10:00:00'],
);
} finally {
OperationRun::flushEventListeners();
OperationRun::setEventDispatcher($dispatcher);
}
expect($run)->toBeInstanceOf(OperationRun::class);
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'backup_schedule.scheduled')->count())
->toBe(1);
});
it('updates run lifecycle fields and summaries', function () {
$tenant = Tenant::factory()->create();

View File

@ -0,0 +1,88 @@
<?php
use App\Filament\Resources\EntraGroupSyncRunResource;
use App\Filament\Resources\InventorySyncRunResource;
use App\Models\EntraGroupSyncRun;
use App\Models\InventorySyncRun;
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Http::preventStrayRequests();
});
it('redirects legacy inventory sync run view to canonical OperationRun when mapped', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$opRun = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'inventory.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$legacyRun = InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'operation_run_id' => (int) $opRun->getKey(),
]);
$this->actingAs($user)
->get(InventorySyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant))
->assertRedirect(OperationRunLinks::tenantlessView($opRun->getKey()));
});
it('does not redirect legacy inventory sync run view when not mapped', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$legacyRun = InventorySyncRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'operation_run_id' => null,
]);
$this->actingAs($user)
->get(InventorySyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant))
->assertOk();
});
it('redirects legacy directory group sync run view to canonical OperationRun when mapped', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$opRun = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'type' => 'directory_groups.sync',
'status' => 'queued',
'outcome' => 'pending',
]);
$legacyRun = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => 'groups-v1:all',
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_SUCCEEDED,
'operation_run_id' => (int) $opRun->getKey(),
]);
$this->actingAs($user)
->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant))
->assertRedirect(OperationRunLinks::tenantlessView($opRun->getKey()));
});
it('does not redirect legacy directory group sync run view when not mapped', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$legacyRun = EntraGroupSyncRun::query()->create([
'tenant_id' => $tenant->getKey(),
'selection_key' => 'groups-v1:all',
'slot_key' => null,
'status' => EntraGroupSyncRun::STATUS_SUCCEEDED,
'operation_run_id' => null,
]);
$this->actingAs($user)
->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $legacyRun], tenant: $tenant))
->assertOk();
});

View File

@ -3,11 +3,13 @@
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\TenantRole;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Http;
@ -61,6 +63,42 @@
->assertNotFound();
});
it('returns 403 for members missing the required capability for the operation type', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant->users()->attach((int) $user->getKey(), [
'role' => TenantRole::Readonly->value,
'source' => 'manual',
'source_ref' => null,
'created_by_user_id' => null,
]);
session()->forget(WorkspaceContext::SESSION_KEY);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => 'inventory.sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$this->actingAs($user)
->get("/admin/operations/{$run->getKey()}")
->assertForbidden();
});
it('renders stored target scope and failure details for a completed run', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -48,6 +48,8 @@
expect($opRun?->status)->toBe('completed');
expect($opRun?->outcome)->toBe('succeeded');
expect((int) ($restoreRun->refresh()->operation_run_id ?? 0))->toBe((int) ($opRun?->getKey() ?? 0));
expect($opRun?->summary_counts['total'] ?? null)->toBe(10);
expect($opRun?->summary_counts['succeeded'] ?? null)->toBe(8);
expect($opRun?->summary_counts['failed'] ?? null)->toBe(1);

View File

@ -7,6 +7,7 @@
use App\Models\RestoreRun;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
it('syncs restore execution into OperationRun even if restore status updates bypass model events', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -24,12 +25,17 @@
'completed_at' => null,
]);
// Observer should create the adapter OperationRun row on create.
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'restore.execute')
->where('context->restore_run_id', $restoreRun->id)
->first();
// Canonical OperationRun must exist at dispatch time and be passed into the job.
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
],
initiator: $user,
);
expect($operationRun)->not->toBeNull();
expect($operationRun?->status)->toBe('queued');
@ -48,7 +54,7 @@
});
});
$job = new ExecuteRestoreRunJob($restoreRun->id);
$job = new ExecuteRestoreRunJob($restoreRun->id, null, null, $operationRun);
$job->handle(
app(RestoreService::class),
app(AuditLogger::class),

View File

@ -7,6 +7,8 @@
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
it('updates connection health and marks the run succeeded on success', function (): void {
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
@ -109,6 +111,102 @@ public function request(string $method, string $path, array $options = []): Grap
]);
});
it('finalizes the verification run as blocked when admin consent is missing', function (): void {
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
throw new RuntimeException('provider_consent_missing');
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
});
[$user, $tenant] = createUserWithTenant(role: 'operator');
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
'health_status' => 'ok',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
],
]);
$job = new ProviderConnectionHealthCheckJob(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
$job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class));
$run->refresh();
expect($run->status)->toBe(OperationRunStatus::Completed->value);
expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value);
$context = is_array($run->context ?? null) ? $run->context : [];
expect($context['reason_code'] ?? null)->toBe('provider_consent_missing');
$nextSteps = $context['next_steps'] ?? null;
expect($nextSteps)->toBeArray();
expect($nextSteps)->not->toBeEmpty();
$first = $nextSteps[0] ?? null;
expect($first)->toBeArray();
expect($first['label'] ?? null)->toBe('Grant admin consent');
expect($first['url'] ?? null)->toBeString()->not->toBeEmpty();
});
it('uses provider connection credentials when refreshing observed permissions', function (): void {
$graph = new class implements GraphClientInterface
{

View File

@ -1,7 +1,6 @@
<?php
use App\Filament\Resources\EntraGroupSyncRunResource\Pages\ListEntraGroupSyncRuns;
use App\Jobs\EntraGroupSyncJob;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Notification;
@ -14,7 +13,7 @@
Notification::fake();
});
it('hides sync action for non-members', function () {
it('does not expose a sync action for non-members', function () {
// Mount as a valid tenant member first, then revoke membership mid-session.
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -22,45 +21,39 @@
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListEntraGroupSyncRuns::class)
->assertActionVisible('sync_groups');
$component = Livewire::test(ListEntraGroupSyncRuns::class);
$user->tenants()->detach($tenant->getKey());
app(\App\Services\Auth\CapabilityResolver::class)->clearCache();
$component->assertActionHidden('sync_groups');
expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull();
Queue::assertNothingPushed();
});
it('shows sync action as visible but disabled for readonly members', function () {
it('does not expose a sync action for readonly members', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroupSyncRuns::class)
->assertActionVisible('sync_groups')
->assertActionDisabled('sync_groups');
$component = Livewire::test(ListEntraGroupSyncRuns::class);
expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull();
Queue::assertNothingPushed();
});
it('allows owner members to execute sync action (dispatches job)', function () {
it('does not expose a sync action for owner members', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroupSyncRuns::class)
->assertActionVisible('sync_groups')
->assertActionEnabled('sync_groups')
->mountAction('sync_groups')
->callMountedAction()
->assertHasNoActionErrors();
$component = Livewire::test(ListEntraGroupSyncRuns::class);
expect($component->instance()->getAction([['name' => 'sync_groups']]))->toBeNull();
Queue::assertPushed(EntraGroupSyncJob::class);
Queue::assertNothingPushed();
});
});

View File

@ -5,6 +5,7 @@
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
@ -14,7 +15,7 @@
uses(RefreshDatabase::class);
test('live restore execution emits an auditable event linked to the run', function () {
$tenant = Tenant::create([
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-audit',
'name' => 'Tenant Audit',
'metadata' => [],
@ -52,7 +53,18 @@
});
});
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
$operationRun = app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'is_dry_run' => false,
],
initiator: null,
);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor', $operationRun);
$job->handle($restoreService, app(AuditLogger::class));
$audit = AuditLog::query()

View File

@ -4,6 +4,7 @@
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
@ -172,5 +173,20 @@
expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com');
expect($run->metadata['confirmed_at'] ?? null)->toBeString();
Bus::assertDispatched(ExecuteRestoreRunJob::class);
$operationRun = OperationRun::query()
->where('tenant_id', $tenant->id)
->where('type', 'restore.execute')
->latest('id')
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun?->status)->toBe('queued');
expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey());
expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0));
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool {
return $job->restoreRunId === (int) $run->getKey()
&& $job->operationRun instanceof OperationRun
&& $job->operationRun->getKey() === $operationRun?->getKey();
});
});

View File

@ -1,7 +1,14 @@
<?php
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Jobs\EntraGroupSyncJob;
use App\Jobs\RunBackupScheduleJob;
use App\Models\InventorySyncRun;
use App\Models\BackupSchedule;
use App\Models\BackupScheduleRun;
use App\Models\EntraGroupSyncRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Inventory\InventorySyncService;
@ -32,3 +39,100 @@
expect(InventorySyncRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
expect(OperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
});
it('prevents run creation when a readonly member tries to start inventory sync', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class);
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
Livewire::test(ListInventoryItems::class)
->assertActionVisible('run_inventory_sync')
->assertActionDisabled('run_inventory_sync')
->callAction('run_inventory_sync', data: ['tenant_id' => $tenant->getKey(), 'policy_types' => $allTypes]);
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
});
it('prevents run creation when a readonly member tries to start directory group sync', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroups::class)
->assertActionVisible('sync_groups')
->assertActionDisabled('sync_groups')
->callAction('sync_groups');
Queue::assertNotPushed(EntraGroupSyncJob::class);
expect(EntraGroupSyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
});
it('prevents run creation when a readonly member tries to run a backup schedule now', function () {
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$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,
]);
Livewire::test(ListBackupSchedules::class)
->assertTableActionVisible('runNow', $schedule)
->assertTableActionDisabled('runNow', $schedule)
->callTableAction('runNow', $schedule);
Queue::assertNothingPushed();
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse();
expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
});
it('prevents run creation when a readonly member tries to retry a backup schedule', function () {
Queue::fake([RunBackupScheduleJob::class]);
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$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,
]);
Livewire::test(ListBackupSchedules::class)
->assertTableActionVisible('retry', $schedule)
->assertTableActionDisabled('retry', $schedule)
->callTableAction('retry', $schedule);
Queue::assertNothingPushed();
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->exists())->toBeFalse();
expect(OperationRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
});

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Support\OperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
it('starts a role definitions sync with an immediate canonical view link', function (): void {
Bus::fake();
/** @var Tenant $tenant */
$tenant = Tenant::factory()->create([
'app_client_id' => 'client-123',
'app_client_secret' => 'secret',
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
$service = app(RoleDefinitionsSyncService::class);
$run = $service->startManualSync($tenant, $user);
expect($run->type)->toBe('directory_role_definitions.sync');
$url = OperationRunLinks::tenantlessView($run);
expect($url)->toContain('/admin/operations/');
Bus::assertDispatched(
App\Jobs\SyncRoleDefinitionsJob::class,
fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run)
);
});

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('EditProviderConnection prefers scoped tenant external id over Tenant::current', function (): void {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$tenantB->makeCurrent();
$page = app(EditProviderConnection::class);
$page->scopedTenantExternalId = (string) $tenantA->external_id;
$method = new ReflectionMethod($page, 'currentTenant');
$method->setAccessible(true);
$resolvedTenant = $method->invoke($page);
expect($resolvedTenant)->toBeInstanceOf(Tenant::class);
expect($resolvedTenant->is($tenantA))->toBeTrue();
});
test('EditProviderConnection falls back to Tenant::current when no scoped tenant is set', function (): void {
$tenantA = Tenant::factory()->create();
$tenantA->makeCurrent();
$page = app(EditProviderConnection::class);
$method = new ReflectionMethod($page, 'currentTenant');
$method->setAccessible(true);
$resolvedTenant = $method->invoke($page);
expect($resolvedTenant)->toBeInstanceOf(Tenant::class);
expect($resolvedTenant->is($tenantA))->toBeTrue();
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
test('ProviderConnectionResource::getUrl infers tenant from referer during Livewire requests', function (): void {
$tenant = Tenant::factory()->create([
'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
]);
$request = Request::create('/livewire/update', 'POST');
$request->headers->set('x-livewire', '1');
$request->headers->set('referer', "http://localhost/admin/tenants/{$tenant->external_id}/provider-connections/1/edit");
app()->instance('request', $request);
expect(Tenant::query()->where('external_id', $tenant->external_id)->exists())->toBeTrue();
$method = new ReflectionMethod(ProviderConnectionResource::class, 'resolveScopedTenant');
$method->setAccessible(true);
$resolvedTenant = $method->invoke(null);
expect($resolvedTenant)->toBeInstanceOf(Tenant::class);
expect($resolvedTenant->is($tenant))->toBeTrue();
$url = ProviderConnectionResource::getUrl('index');
expect($url)->toContain((string) $tenant->external_id);
expect($url)->toContain('/admin/tenants/');
expect($url)->toContain('/provider-connections');
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Policies\ProviderConnectionPolicy;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('provider connection policy resolves tenant from record when route tenant is missing', function (): void {
[$user, $tenantA] = createUserWithTenant(role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => $tenantA->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
// Simulate a different "current" tenant (e.g. Livewire update without {tenant} route param).
$tenantB->makeCurrent();
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'tenant_id' => (int) $tenantA->getKey(),
'provider' => 'microsoft',
]);
$policy = app(ProviderConnectionPolicy::class);
$result = $policy->update($user, $connection);
expect($result)->toBeTrue();
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('includes an admin consent next step when provider consent is missing', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Contoso',
]);
$registry = app(ProviderNextStepsRegistry::class);
$steps = $registry->forReason($tenant, ProviderReasonCodes::ProviderConsentMissing);
expect($steps)->toBeArray()
->and($steps)->not->toBeEmpty()
->and($steps[0]['label'])->toBe('Grant admin consent')
->and($steps[0]['url'])->toContain('learn.microsoft.com');
});
it('links to the real admin consent endpoint when provider credentials exist', function () {
$tenant = Tenant::factory()->create([
'app_client_id' => null,
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->graphTenantId(),
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'derived-client-id',
'client_secret' => 'derived-client-secret',
],
]);
$registry = app(ProviderNextStepsRegistry::class);
$steps = $registry->forReason($tenant, ProviderReasonCodes::ProviderConsentMissing, $connection);
expect($steps)->toBeArray()
->and($steps)->not->toBeEmpty()
->and($steps[0]['label'])->toBe('Grant admin consent')
->and($steps[0]['url'])->toContain('login.microsoftonline.com')
->and($steps[0]['url'])->toContain('adminconsent');
});

View File

@ -1,6 +1,8 @@
<?php
use App\Filament\Resources\TenantResource;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -27,3 +29,33 @@
expect($url)->toContain(urlencode('https://graph.microsoft.com/.default'));
}
});
it('can derive admin consent url from provider connection credentials when tenant app_client_id is missing', function () {
$tenant = Tenant::factory()->create([
'app_client_id' => null,
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->graphTenantId(),
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'derived-client-id',
'client_secret' => 'derived-client-secret',
],
]);
$url = TenantResource::adminConsentUrl($tenant);
expect($url)
->not->toBeNull()
->and($url)->toContain('login.microsoftonline.com')
->and($url)->toContain('adminconsent')
->and($url)->toContain(urlencode('derived-client-id'));
});