feat(spec-086): retire legacy runs into operation runs

This commit is contained in:
Ahmed Darrazi 2026-02-11 01:03:00 +01:00
parent 11f7209783
commit b870c0c8d4
85 changed files with 3943 additions and 1638 deletions

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

@ -12,8 +12,8 @@ # Tasks: Retire Legacy Runs Into Operation Runs (086)
## Phase 1: Setup (Shared Infrastructure)
- [ ] T001 Confirm baseline green test subset via `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
- [ ] T002 Confirm Filament v5 + Livewire v4 constraints are respected for any touched pages/resources in `app/Filament/**`
- [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/**`
---
@ -21,11 +21,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives required by all stories.
- [ ] T003 Add centralized “run type → required capability” resolver in `app/Support/Operations/OperationRunCapabilityResolver.php`
- [ ] T004 Update `app/Policies/OperationRunPolicy.php` to enforce clarified 404/403 semantics (non-member 404; member missing capability 403) using T003
- [ ] T005 [P] Add/extend operation type registry for new types in `app/Support/OperationRunType.php`
- [ ] T006 [P] Add/extend operation labels/catalog entries in `app/Support/OperationCatalog.php`
- [ ] T007 Add tests covering view authorization semantics in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` (404 vs 403 + capability-gated view)
- [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.
@ -39,24 +39,24 @@ ## Phase 3: User Story 1 — Start an operation with an immediate canonical run
### Tests (US1)
- [ ] T008 [P] [US1] Add/extend tests for OperationRun dispatch-time creation in `tests/Feature/OperationRunServiceTest.php`
- [ ] T009 [P] [US1] Add/extend tests for start-surface authorization (403 prevents run creation) in `tests/Feature/RunStartAuthorizationTest.php`
- [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)
- [ ] T010 [US1] Ensure inventory sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
- [ ] T011 [US1] Ensure directory groups sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
- [ ] T012 [US1] Ensure backup schedule manual run-now creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php`
- [ ] T013 [US1] Ensure backup schedule retry creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php`
- [ ] 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`
- [ ] 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
- [ ] 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`
- [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)
- [ ] 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`)
- [ ] 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)
- [ ] T015c [US2] Ensure restore domain records link to canonical OperationRuns for observability (align with FR-014; no legacy fallback-create)
- [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.
@ -70,20 +70,20 @@ ## Phase 4: User Story 2 — Monitor executions from a single canonical viewer (
### Tests (US2)
- [ ] T016 [P] [US2] Add tests asserting Monitoring pages render DB-only (no Graph calls) in `tests/Feature/Monitoring/MonitoringOperationsTest.php`
- [ ] 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`)
- [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)
- [ ] 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`)
- [ ] 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`)
- [ ] 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`)
- [ ] 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)
- [ ] 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)
- [ ] 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`)
- [ ] 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
- [ ] 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`
- [ ] 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`
- [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.
@ -97,18 +97,18 @@ ## Phase 5: User Story 3 — Use cached directory data in forms without blocking
### Tests (US3)
- [ ] 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`)
- [ ] 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)
- [ ] 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/`)
- [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)
- [ ] 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`)
- [ ] 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)
- [ ] 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`)
- [ ] 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)
- [ ] T031 [US3] Update `app/Filament/Resources/TenantResource.php` roleDefinitions search/label callbacks to query cached DB tables only (remove Graph calls from callbacks)
- [ ] 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`)
- [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.
@ -116,9 +116,11 @@ ### Implementation (US3)
## Phase 6: Polish & Cross-Cutting Concerns
- [ ] 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/**`)
- [ ] T034 Run Pint on changed files via `vendor/bin/sail bin pint --dirty`
- [ ] T035 Run targeted test subset per quickstart: `vendor/bin/sail artisan test --compact --filter=OperationRun` and the new/changed test files
- [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`
---

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;
it('allows viewing an operation run without a selected workspace when the user is a member of the run workspace', function (): void {
@ -56,6 +58,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'));
});