Merge branch '086-retire-legacy-runs-into-operation-runs-session-1770683729' into 085-tenant-operate-hub
# Conflicts: # .github/agents/copilot-instructions.md
This commit is contained in:
commit
c8e5996a1a
6
.github/agents/copilot-instructions.md
vendored
6
.github/agents/copilot-instructions.md
vendored
@ -46,10 +46,10 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4)
|
||||
- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
|
||||
- 085-tenant-operate-hub: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail
|
||||
- 085-tenant-operate-hub: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
|
||||
|
||||
- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface`
|
||||
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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([]);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
));
|
||||
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
]],
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
123
app/Jobs/SyncRoleDefinitionsJob.php
Normal file
123
app/Jobs/SyncRoleDefinitionsJob.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
24
app/Models/EntraRoleDefinition.php
Normal file
24
app/Models/EntraRoleDefinition.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
257
app/Services/Directory/RoleDefinitionsSyncService.php
Normal file
257
app/Services/Directory/RoleDefinitionsSyncService.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
30
app/Support/Operations/OperationRunCapabilityResolver.php
Normal file
30
app/Support/Operations/OperationRunCapabilityResolver.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
|
||||
31
database/factories/EntraRoleDefinitionFactory.php
Normal file
31
database/factories/EntraRoleDefinitionFactory.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Retire Legacy Runs Into Operation Runs
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-09
|
||||
**Feature**: [../spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- The spec uses product-specific terms (e.g., 404 vs 403 semantics) to make authorization behavior testable, but avoids naming specific frameworks or code-level implementation choices.
|
||||
@ -0,0 +1,41 @@
|
||||
# Contracts (Spec 086)
|
||||
|
||||
This spec does not introduce a new public HTTP API surface.
|
||||
|
||||
## Canonical OperationRun contract (internal)
|
||||
|
||||
Spec 086 tightens and standardizes the internal contract for how operations are created, identified, and displayed.
|
||||
|
||||
### Run creation contract
|
||||
|
||||
- Start surfaces must create the `operation_runs` row **before** dispatching asynchronous work.
|
||||
- Jobs must receive the `OperationRun` (or its id) and must **not** attempt a fallback-create.
|
||||
|
||||
### Identity / idempotency contract
|
||||
|
||||
Operation run identity is enforced by a partial unique index for active states.
|
||||
|
||||
Planned identity rules by type:
|
||||
- `inventory.sync` and `directory_groups.sync`: deterministic identity (while-active dedupe)
|
||||
- `backup_schedule.run_now` and `backup_schedule.retry`: unique-per-click identity (nonce)
|
||||
- `backup_schedule.scheduled`: deterministic identity by `(backup_schedule_id, scheduled_for)` (strict)
|
||||
|
||||
### Context contract (selected keys)
|
||||
|
||||
The `operation_runs.context` JSON is used for:
|
||||
- “Target” display (via `target_scope`)
|
||||
- “Related” deep links (via `OperationRunLinks::related`)
|
||||
- provenance (trigger source, schedule id, initiating user)
|
||||
|
||||
Keys referenced in existing UI code:
|
||||
- `provider_connection_id`
|
||||
- `backup_schedule_id`
|
||||
- `backup_schedule_run_id`
|
||||
- `restore_run_id`
|
||||
- `target_scope`
|
||||
|
||||
## Graph Contract Registry
|
||||
|
||||
All Microsoft Graph calls remain required to go through `GraphClientInterface` and be modeled in `config/graph_contracts.php`.
|
||||
|
||||
Spec 086 removes Graph calls from Filament render/search/label callbacks (DB-only rendering), and moves those lookups behind cached tables + asynchronous sync operations.
|
||||
131
specs/086-retire-legacy-runs-into-operation-runs/data-model.md
Normal file
131
specs/086-retire-legacy-runs-into-operation-runs/data-model.md
Normal file
@ -0,0 +1,131 @@
|
||||
# Data Model (Spec 086)
|
||||
|
||||
This feature consolidates execution tracking into `operation_runs` while keeping legacy run tables as read-only history.
|
||||
|
||||
## Entities
|
||||
|
||||
### 1) OperationRun (canonical)
|
||||
|
||||
**Table:** `operation_runs`
|
||||
|
||||
**Purpose:** Single source of truth for execution tracking: status/progress, results (counts), failures, provenance/context.
|
||||
|
||||
**Fields (current):**
|
||||
- `id`
|
||||
- `workspace_id` (FK, required)
|
||||
- `tenant_id` (FK, nullable)
|
||||
- `user_id` (FK, nullable)
|
||||
- `initiator_name` (string)
|
||||
- `type` (string; see OperationRunType registry)
|
||||
- `status` (string; queued|running|completed)
|
||||
- `outcome` (string; pending|succeeded|partially_succeeded|failed|blocked…)
|
||||
- `run_identity_hash` (string; deterministic hash for idempotency)
|
||||
- `summary_counts` (json/array; normalized counts + key metadata)
|
||||
- `failure_summary` (json/array; structured failures, sanitized)
|
||||
- `context` (json/array; provenance + inputs + target scope)
|
||||
- `started_at`, `completed_at`, `created_at`, `updated_at`
|
||||
|
||||
**Indexes / constraints (current):**
|
||||
- `(workspace_id, type, created_at)` and `(workspace_id, created_at)`
|
||||
- `(tenant_id, type, created_at)` and `(tenant_id, created_at)`
|
||||
- Partial unique indexes for active runs:
|
||||
- tenant-scoped: unique `(tenant_id, run_identity_hash)` where `tenant_id IS NOT NULL` and `status IN ('queued','running')`
|
||||
- workspace-scoped: unique `(workspace_id, run_identity_hash)` where `tenant_id IS NULL` and `status IN ('queued','running')`
|
||||
|
||||
**Context contract (current patterns):**
|
||||
The `context` JSON is used for “related links” and display. Existing keys include (non-exhaustive):
|
||||
- `provider_connection_id`
|
||||
- `backup_schedule_id`
|
||||
- `backup_schedule_run_id`
|
||||
- `backup_set_id`
|
||||
- `policy_id`
|
||||
- `restore_run_id`
|
||||
- `target_scope` (nested object)
|
||||
- `selection` and `idempotency` objects for bulk operations
|
||||
|
||||
**Required additions for Spec 086 (planned):**
|
||||
- New `type` values:
|
||||
- `backup_schedule.scheduled`
|
||||
- `directory_role_definitions.sync`
|
||||
- Scheduled backup context keys:
|
||||
- `backup_schedule_id`
|
||||
- `scheduled_for` (UTC timestamp/minute)
|
||||
- Optional `backup_schedule_run_id` if the legacy table remains for history during transition
|
||||
|
||||
### 2) InventorySyncRun (legacy)
|
||||
|
||||
**Table:** `inventory_sync_runs`
|
||||
|
||||
**Purpose:** Historical record (read-only) for pre-cutover tracking.
|
||||
|
||||
**Key fields:**
|
||||
- `tenant_id`
|
||||
- `selection_hash`
|
||||
- `selection_payload` (nullable)
|
||||
- status + timestamps + counters
|
||||
|
||||
**Planned optional mapping:**
|
||||
- Add nullable `operation_run_id` FK to enable deterministic redirect to canonical viewer when present. No backfill required.
|
||||
|
||||
### 3) EntraGroupSyncRun (legacy)
|
||||
|
||||
**Table:** `entra_group_sync_runs`
|
||||
|
||||
**Purpose:** Historical record (read-only) for pre-cutover group sync tracking.
|
||||
|
||||
**Key fields:**
|
||||
- `tenant_id`
|
||||
- `selection_key`, `slot_key`
|
||||
- status + error fields + counters
|
||||
|
||||
**Planned optional mapping:**
|
||||
- Add nullable `operation_run_id` FK to enable deterministic redirect when present.
|
||||
|
||||
### 4) BackupScheduleRun (legacy)
|
||||
|
||||
**Table:** `backup_schedule_runs`
|
||||
|
||||
**Purpose:** Historical record of backup schedule executions.
|
||||
|
||||
**Planned behavior change:**
|
||||
- Distinguish scheduled fires vs manual/retry at the OperationRun level by introducing `backup_schedule.scheduled` type.
|
||||
|
||||
**Planned optional mapping:**
|
||||
- Add nullable `operation_run_id` FK to enable deterministic redirect when present.
|
||||
|
||||
### 5) RestoreRun (domain)
|
||||
|
||||
**Table:** `restore_runs`
|
||||
|
||||
**Purpose:** Domain workflow record (requested items, dry-run, preview/results). Execution tracking and “View run” uses `operation_runs`.
|
||||
|
||||
**Current linkage approach:**
|
||||
- Canonical runs store `restore_run_id` in `operation_runs.context` (used by `OperationRunLinks::related`).
|
||||
|
||||
## Enumerations / Registries
|
||||
|
||||
### OperationRunType
|
||||
|
||||
**Location:** `app/Support/OperationRunType.php`
|
||||
|
||||
**Planned additions:**
|
||||
- `BackupScheduleScheduled = 'backup_schedule.scheduled'`
|
||||
- `DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync'`
|
||||
|
||||
### OperationCatalog
|
||||
|
||||
**Location:** `app/Support/OperationCatalog.php`
|
||||
|
||||
**Planned additions:**
|
||||
- Human label for `backup_schedule.scheduled`
|
||||
- Human label for `directory_role_definitions.sync`
|
||||
- Optional expected durations (if known)
|
||||
|
||||
## State transitions
|
||||
|
||||
### OperationRun
|
||||
|
||||
- `queued` → `running` → `completed`
|
||||
- `outcome` starts as `pending`, transitions to one of: `succeeded`, `partially_succeeded`, `failed`, `blocked`.
|
||||
|
||||
The canonical update surface is `OperationRunService` (`dispatchOrFail`, `updateRun`, `appendFailures`, `incrementSummaryCounts`, etc.).
|
||||
109
specs/086-retire-legacy-runs-into-operation-runs/plan.md
Normal file
109
specs/086-retire-legacy-runs-into-operation-runs/plan.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Implementation Plan: Retire Legacy Runs Into Operation Runs
|
||||
|
||||
**Branch**: `086-retire-legacy-runs-into-operation-runs` | **Date**: 2026-02-10 | **Spec**: `specs/086-retire-legacy-runs-into-operation-runs/spec.md`
|
||||
**Input**: Feature specification from `specs/086-retire-legacy-runs-into-operation-runs/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Retire legacy “run tracking” tables as the primary execution tracker for in-scope operations (inventory sync, directory groups sync, backup schedule runs, restore execution, and directory role definitions sync) and make `operation_runs` the canonical source of truth.
|
||||
|
||||
Key implementation approach:
|
||||
- Use the existing tenantless canonical viewer `/admin/operations/{run}` (Filament page `TenantlessOperationRunViewer`) and ensure it remains DB-only at render time.
|
||||
- Enforce the clarified 404/403 semantics for run viewing: non-members 404, members missing capability 403, where the view capability equals the start capability.
|
||||
- Enforce dispatch-time OperationRun creation for every start surface; jobs never fallback-create.
|
||||
- Apply explicit run identity rules per operation type (dedupe vs unique-per-click vs strict schedule dedupe), including strict scheduled backup idempotency: at most one canonical run ever per (schedule_id, intended fire-time).
|
||||
- Remove Graph calls from UI render/search/label callbacks by using cached directory data (groups + role definitions) and “Sync now” operations.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (via Sail)
|
||||
**Testing**: Pest v4 (PHPUnit v12 runner)
|
||||
**Target Platform**: Web application (Laravel + Filament admin panel)
|
||||
**Project Type**: web
|
||||
**Performance Goals**: Operations viewer + Monitoring pages render from DB state only; canonical viewer loads in ~2s under normal conditions
|
||||
**Constraints**: No outbound HTTP in Monitoring/Operations rendering/search/label callbacks (OPS-EX-AUTH-001); dispatch-time OperationRun creation; jobs must never fallback-create; strict 404/403 isolation semantics
|
||||
**Scale/Scope**: TenantPilot admin workflows; multiple operation families; staged cutover with legacy history preserved
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: No change to the meaning of inventory vs snapshots/backups; this spec only changes execution tracking.
|
||||
- Read/write separation: Start surfaces remain enqueue-only; destructive-like actions are not added here; audit logging remains required for mutations.
|
||||
- Graph contract path: UI render/search/label callbacks must become DB-only; any remaining Graph calls stay behind `GraphClientInterface` + `config/graph_contracts.php`.
|
||||
- Deterministic capabilities: Run viewing must be capability-gated using the existing capability registry (no raw strings).
|
||||
- RBAC-UX: Enforce clarified semantics for run viewing: non-members 404, members missing capability 403; authorization enforced server-side via Policy/Gate.
|
||||
- Workspace isolation: Canonical tenantless `/admin/operations/{run}` continues to enforce workspace membership (deny-as-not-found).
|
||||
- Global search: `OperationRunResource` stays non-globally-searchable; no new global-search surfaces introduced.
|
||||
- Run observability: All in-scope long-running/scheduled/remote operations are tracked via `OperationRun`; Monitoring pages remain DB-only.
|
||||
- Automation: Scheduled backup run creation uses strict idempotency per schedule + intended fire-time.
|
||||
- Badge semantics (BADGE-001): Run status/outcome badges already use `BadgeRenderer`; do not introduce ad-hoc mappings.
|
||||
- Filament UI Action Surface Contract: Legacy resources remain read-only; canonical operations pages already define inspection affordances.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/086-retire-legacy-runs-into-operation-runs/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── README.md
|
||||
└── tasks.md # To be created by /speckit.tasks
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── Monitoring/
|
||||
│ │ └── Operations/
|
||||
│ └── Resources/
|
||||
├── Http/
|
||||
│ └── Middleware/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
config/
|
||||
├── graph.php
|
||||
└── graph_contracts.php
|
||||
|
||||
database/
|
||||
└── migrations/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel web application (monolith) with Filament admin panel.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this feature.
|
||||
|
||||
## Phase Plan
|
||||
|
||||
Phase 0/1 deliverables are already captured in:
|
||||
- `specs/086-retire-legacy-runs-into-operation-runs/research.md`
|
||||
- `specs/086-retire-legacy-runs-into-operation-runs/data-model.md`
|
||||
- `specs/086-retire-legacy-runs-into-operation-runs/contracts/README.md`
|
||||
- `specs/086-retire-legacy-runs-into-operation-runs/quickstart.md`
|
||||
|
||||
Phase 2 (tasks) will be produced via `/speckit.tasks` and should slice work by operation family:
|
||||
1) Authorization: capability-gate canonical run viewing (404 vs 403 semantics).
|
||||
2) Backup schedules: add `backup_schedule.scheduled` + strict idempotency; make manual runs unique-per-click.
|
||||
3) Directory groups: stop writing legacy rows; keep legacy pages read-only; ensure dispatch-time OperationRun creation.
|
||||
4) Inventory sync: stop writing legacy rows; ensure dispatch-time OperationRun creation and no UI Graph calls.
|
||||
5) Tenant configuration: remove Graph calls from render/search/labels; add role definitions cache + “Sync now” operation.
|
||||
6) Restore: ensure execution tracking uses OperationRun only; legacy restore domain records remain as domain entities.
|
||||
@ -0,0 +1,42 @@
|
||||
# Quickstart (Spec 086)
|
||||
|
||||
This quickstart is for validating Spec 086 changes locally using Sail.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- `vendor/bin/sail up -d`
|
||||
|
||||
## Run formatting
|
||||
|
||||
- `vendor/bin/sail bin pint --dirty`
|
||||
|
||||
## Run targeted tests
|
||||
|
||||
Use the minimal test subset relevant to the PR slice you are working on:
|
||||
|
||||
- `vendor/bin/sail artisan test --compact --filter=OperationRun`
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature` (narrow further to the new/changed files)
|
||||
|
||||
## Manual verification checklist
|
||||
|
||||
### Canonical viewer
|
||||
|
||||
- Trigger an operation that creates an `OperationRun`.
|
||||
- Open the canonical URL (from notification action): `/admin/operations/{runId}`.
|
||||
- Confirm the viewer renders from persisted DB state only.
|
||||
|
||||
### Authorization semantics
|
||||
|
||||
- As a non-workspace-member user, opening `/admin/operations/{runId}` returns 404.
|
||||
- As a workspace member without the required capability for that run type, opening the viewer returns 403.
|
||||
|
||||
### Dedupe semantics
|
||||
|
||||
- Inventory sync / directory group sync: attempting to start while active reuses the existing active run and links to it.
|
||||
- Manual backup schedule run now/retry: each click produces a distinct `OperationRun`.
|
||||
- Scheduled backup: double-fire for the same schedule + intended minute produces at most one `OperationRun`.
|
||||
|
||||
### DB-only forms
|
||||
|
||||
- Tenant configuration selectors (directory groups, role definitions) render and search without outbound HTTP calls.
|
||||
- “Sync now” actions enqueue operations and provide “View run” link.
|
||||
87
specs/086-retire-legacy-runs-into-operation-runs/research.md
Normal file
87
specs/086-retire-legacy-runs-into-operation-runs/research.md
Normal file
@ -0,0 +1,87 @@
|
||||
# Research (Spec 086)
|
||||
|
||||
This document resolves the unknowns needed to write an implementation plan for “Retire Legacy Runs Into Operation Runs”. It is based on repository inspection (no new external dependencies).
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Canonical run viewer is already implemented; keep the route shape
|
||||
|
||||
- **Decision:** Use the existing tenantless canonical viewer route `admin.operations.view` (path pattern `/admin/operations/{run}`) implemented by the Filament page `TenantlessOperationRunViewer`.
|
||||
- **Rationale:** This already enforces “tenantless deep link” while still doing workspace / tenant entitlement checks server-side through `Gate::authorize('view', $run)`.
|
||||
- **Alternatives considered:** Create a second viewer page or route. Rejected because it would introduce duplicate UX and increase the chance of policy drift.
|
||||
|
||||
Repository anchors:
|
||||
- Canonical viewer page: `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- Link helper: `app/Support/OperationRunLinks.php`
|
||||
- Workspace selection middleware explicitly treats `/admin/operations/{id}` as workspace-optional: `app/Http/Middleware/EnsureWorkspaceSelected.php`
|
||||
|
||||
### 2) OperationRun is persisted and DB-rendered; schema supports workspace-tenant and workspace-only runs
|
||||
|
||||
- **Decision:** Treat `operation_runs` as the canonical persistence format for status/progress/results.
|
||||
- **Rationale:** The schema already includes `workspace_id` (required) and `tenant_id` (nullable), enabling both tenant-plane and workspace-plane operations.
|
||||
- **Alternatives considered:** Separate tables per operation family. Rejected because it breaks the Monitoring → Operations single source of truth principle.
|
||||
|
||||
Repository anchors:
|
||||
- Migrations: `database/migrations/2026_01_16_180642_create_operation_runs_table.php`, `database/migrations/2026_02_04_090030_add_workspace_id_to_operation_runs_table.php`
|
||||
- Model: `app/Models/OperationRun.php`
|
||||
|
||||
### 3) View authorization must be capability-gated per operation type (in addition to membership)
|
||||
|
||||
- **Decision:** Extend run viewing authorization to require the same capability used to start the operation type.
|
||||
- **Rationale:** Spec 086 clarifications require: non-members get 404; members without capability get 403; and the “view” capability equals the “start” capability.
|
||||
- **Implementation approach (planned):** Update `OperationRunPolicy::view()` to:
|
||||
1) Keep existing workspace membership and tenant entitlement checks (deny-as-not-found).
|
||||
2) Resolve required capability from `OperationRun->type` using a centralized mapping helper.
|
||||
3) If capability is known and tenant-scoped, enforce `403` when the member lacks it.
|
||||
|
||||
Repository anchors:
|
||||
- Current policy (membership + tenant entitlement only): `app/Policies/OperationRunPolicy.php`
|
||||
- Existing capability enforcement in start surfaces (examples):
|
||||
- Inventory sync start: `Capabilities::TENANT_INVENTORY_SYNC_RUN` in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
|
||||
- Directory groups sync start: `Capabilities::TENANT_SYNC` in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
|
||||
- Backup schedule run/retry: `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` in `app/Filament/Resources/BackupScheduleResource.php`
|
||||
|
||||
### 4) Run identity / dedupe strategy varies by operation type
|
||||
|
||||
- **Decision:** Use existing `OperationRunService` helpers but apply type-specific identity rules:
|
||||
- `inventory.sync` and `directory_groups.sync`: **while-active dedupe** based on deterministic inputs (continue using `ensureRun(...)`-style identity).
|
||||
- `backup_schedule.run_now` and `backup_schedule.retry`: **unique per click** (no dedupe). Create a new run each time by including a nonce in identity inputs (e.g., UUID).
|
||||
- `backup_schedule.scheduled`: **strict dedupe** per `(backup_schedule_id, scheduled_for)`; create a new operation type `backup_schedule.scheduled` and use `ensureRunWithIdentity(...)` keyed by schedule + intended fire-time.
|
||||
- **Rationale:** Matches explicit spec clarifications and protects against scheduler double-fire.
|
||||
- **Alternatives considered:**
|
||||
- Keep using `ensureRun(...)` for manual runs → rejected (dedupes while active).
|
||||
- Use legacy table unique constraints as idempotency → rejected (spec requires OperationRun is canonical).
|
||||
|
||||
Repository anchors:
|
||||
- `ensureRun(...)` and `ensureRunWithIdentity(...)`: `app/Services/OperationRunService.php`
|
||||
- Existing partial unique index for active runs: `operation_runs_active_unique_*` in the migrations above.
|
||||
|
||||
### 5) Legacy run tables are real and currently written to; deterministic redirect requires an explicit mapping field
|
||||
|
||||
- **Decision:** Legacy tables remain viewable and read-only, but should not be relied on for current execution tracking.
|
||||
- **Rationale:** Spec requires “no new legacy rows” for in-scope operations. Today, some start surfaces still create legacy rows (e.g., inventory/group sync, backup schedule runs).
|
||||
- **Planned design:**
|
||||
- Stop creating new legacy rows as part of the cutover PRs.
|
||||
- Implement legacy “view” redirect behavior only when a record has a canonical mapping.
|
||||
- To make redirects deterministic without a backfill, add an optional `operation_run_id` FK column to legacy tables that we intend to redirect (only populated for rows created after the migration; older rows remain legacy-only view).
|
||||
- **Alternatives considered:** Derive mapping by recomputing hashes and searching by time window. Rejected as non-deterministic and likely to pick the wrong run when identities collide historically.
|
||||
|
||||
Repository anchors (legacy tables):
|
||||
- Inventory sync runs: `database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php`
|
||||
- Directory group sync runs: `database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php`
|
||||
- Backup schedule runs: `database/migrations/**create_backup_schedule_runs**` (used in `BackupScheduleResource`)
|
||||
- Restore runs (domain): `database/migrations/2025_12_10_000150_create_restore_runs_table.php`
|
||||
|
||||
### 6) DB-only rendering constraint is already enforced in Monitoring pages, but Tenant configuration forms still call Graph
|
||||
|
||||
- **Decision:** Remove outbound Graph calls from configuration-form search/labels by introducing cached directory role definitions and using cached directory groups.
|
||||
- **Rationale:** Constitution OPS-EX-AUTH-001 + Spec 086 FR-006/FR-015 require render/search/label resolution to be DB-only.
|
||||
- **Repository finding:** `TenantResource` currently queries Graph for role definitions in selector callbacks.
|
||||
|
||||
Repository anchors:
|
||||
- Graph call sites inside UI callbacks: `app/Filament/Resources/TenantResource.php` (roleDefinitions search/label methods)
|
||||
|
||||
## Open items (resolved enough for planning)
|
||||
|
||||
- Exact schema for the new role definition cache tables and the sync job contract will be specified in `data-model.md` and implemented in Phase PR(s).
|
||||
- The capability mapping for run viewing will be implemented via a centralized helper; the plan will enumerate required capabilities per in-scope operation type.
|
||||
160
specs/086-retire-legacy-runs-into-operation-runs/spec.md
Normal file
160
specs/086-retire-legacy-runs-into-operation-runs/spec.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Feature Specification: Retire Legacy Runs Into Operation Runs
|
||||
|
||||
**Feature Branch**: `086-retire-legacy-runs-into-operation-runs`
|
||||
**Created**: 2026-02-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Retire legacy run tracking into canonical operation runs, with DB-only rendering and dispatch-time run creation. Legacy run tables remain read-only history."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-10
|
||||
|
||||
- Q: For manual backup schedule runs (`backup_schedule.run_now`) and retries (`backup_schedule.retry`), should the system dedupe while a run is active, or always create a new run per click? → A: Always create a new run per click (no dedupe).
|
||||
- Q: Who may view the canonical run detail page (“View run”)? → A: Workspace members may view runs only if they also have the required capability for that operation type; non-members get 404, members without capability get 403.
|
||||
- Q: Which capability should be required to view a run (“View run”)? → A: Use the same capability as starting that operation type.
|
||||
- Q: For `backup_schedule.scheduled`, how should dedupe work? → A: Strict dedupe per schedule and intended fire-time (at most one run).
|
||||
- Q: For the role definitions cache “Sync now” operation, should it use a new dedicated operation type or reuse an existing one? → A: Use a new dedicated operation type.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Start an operation with an immediate canonical run link (Priority: P1)
|
||||
|
||||
As a workspace member, I can start long-running operations (inventory sync, directory groups sync, scheduled backups, restore execution, directory role definitions sync) and immediately receive a stable “View run” link that I can open and share.
|
||||
|
||||
**Why this priority**: This removes the “run link appears later / changes” ambiguity, improves auditability, and prevents duplicate tracking paths.
|
||||
|
||||
**Independent Test**: Trigger each supported operation start surface and verify a canonical run record exists before work begins, and that the canonical viewer loads from persisted state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace member with the required capability, **When** they start an inventory sync, **Then** a canonical run exists immediately and the UI shows a stable “View run” link.
|
||||
2. **Given** a scheduled backup fire event, **When** the scheduler dispatches work, **Then** a canonical run exists immediately and the same fire event cannot create duplicates.
|
||||
3. **Given** a workspace member without the required capability, **When** they attempt to start the operation, **Then** the request is rejected with a capability error (403) and no run is created.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Monitor executions from a single canonical viewer (Priority: P2)
|
||||
|
||||
As a workspace member, I can open an operations viewer link for any run and see status, progress, results, and errors without the page triggering outbound calls.
|
||||
|
||||
Legacy “run history” pages remain available for older historical rows but cannot start or retry anything.
|
||||
|
||||
**Why this priority**: A single viewer reduces support load, enables consistent deep linking, and avoids UI latency and rate-limiting from outbound calls.
|
||||
|
||||
**Independent Test**: Load the canonical viewer and legacy history pages using outbound client fakes/mocks and assert no outbound calls occur during rendering/search.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a run exists, **When** a user opens its canonical operations link, **Then** the page renders only from persisted state and performs no outbound calls.
|
||||
2. **Given** a legacy run history record that has a known canonical mapping, **When** a user opens the legacy “view” page, **Then** they are redirected to the canonical operations viewer.
|
||||
3. **Given** a legacy run history record without a canonical mapping, **When** a user opens the legacy “view” page, **Then** they see a read-only historical record and no new canonical run is created.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Use cached directory data in forms without blocking calls (Priority: P3)
|
||||
|
||||
As a workspace member configuring tenant-related settings, I can search/select directory groups and role definitions using cached data. If cached data is missing or stale, I can trigger an asynchronous sync (“Sync now”) without the form making outbound calls.
|
||||
|
||||
**Why this priority**: Prevents slow, flaky UI and rate-limits from inline lookups, while keeping the configuration flow usable.
|
||||
|
||||
**Independent Test**: Render the configuration form and exercise search/label rendering while asserting outbound clients are not called.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** cached directory groups exist, **When** the user searches for groups, **Then** results and labels come from cached data.
|
||||
2. **Given** cached role definitions are missing, **When** the user opens the role definition selector, **Then** the UI indicates “data not available yet” and offers a non-destructive “Sync now” action.
|
||||
3. **Given** the user triggers “Sync now”, **When** the sync starts, **Then** a canonical run is created immediately and the user can open its canonical “View run” link.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A scheduler fires the same scheduled backup more than once for the same intended time.
|
||||
- A user triggers the same sync while an identical sync is still active (dedupe/while-active semantics).
|
||||
- A job fails before writing progress; the canonical run still exists and shows a clear failure state.
|
||||
- A legacy history row exists but has no canonical mapping; it must remain viewable without creating new canonical runs.
|
||||
- A non-member attempts to access a canonical operations link; response must be deny-as-not-found (404).
|
||||
- A member lacks capability: start surfaces must reject (403) and the UI must reflect disabled affordances.
|
||||
- Cached directory data is empty or stale; UI must not block on outbound calls and must provide a safe way to sync.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature includes long-running/queued/scheduled work. The spec MUST describe tenant isolation, run observability (type/identity/visibility), and tests.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature changes authorization behavior and navigation paths. It MUST define 404 vs 403 semantics and ensure server-side enforcement for operation-start flows.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Outbound HTTP without a canonical run is not allowed on Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Any new/changed status presentation for runs MUST remain centralized and covered by tests.
|
||||
|
||||
**Constitution alignment (Admin UI Action Surfaces):** This feature changes multiple admin UI surfaces and MUST satisfy the UI Action Surface Contract (see matrix below).
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Canonical tracking)**: The system MUST treat the canonical run record as the single source of truth for execution tracking (status, progress, results, errors) for the in-scope operations.
|
||||
- **FR-002 (Dispatch-time creation)**: Every start surface (UI action, console command, scheduler, internal service) MUST create the canonical run record before dispatching any asynchronous work.
|
||||
- **FR-003 (No job fallback-create)**: Background workers MUST NOT create canonical run records as a fallback; missing run identifiers are treated as a fatal contract violation.
|
||||
- **FR-004 (Canonical deep-link)**: The system MUST support exactly one canonical deep-link format for viewing runs which is tenantless and stable.
|
||||
|
||||
- **FR-005 (Membership + capability rules)**: Access to operation runs MUST follow these rules:
|
||||
- Non-members of the workspace scope MUST receive deny-as-not-found (404).
|
||||
- Workspace members who lack the required capability for the operation type MUST receive 403.
|
||||
- **FR-005a (View capability mapping)**: “View run” MUST require the same capability as “Start” for the corresponding operation type.
|
||||
- **FR-006 (DB-only rendering)**: Operations/monitoring and run viewer pages MUST render solely from persisted data and MUST NOT perform outbound calls during rendering/search/label resolution.
|
||||
|
||||
- **FR-007 (Legacy history read-only)**: Legacy run history records MUST remain viewable as historical data, but MUST be strictly read-only (no start/retry/execute actions).
|
||||
- **FR-008 (Legacy redirects)**: If a legacy history record includes a canonical mapping, the legacy “view” page MUST redirect deterministically to the canonical viewer; otherwise it MUST display legacy-only history.
|
||||
- **FR-009 (No new legacy rows)**: For the in-scope operations, the system MUST stop writing new legacy run history rows. Existing legacy history remains unchanged.
|
||||
|
||||
- **FR-010 (Scheduled backup classification)**: Scheduled backup executions MUST be represented with a distinct operation type (not conflated with manual runs).
|
||||
- **FR-011 (Run identity & dedupe)**: The system MUST compute deterministic run identities for dedupe and scheduler double-fire protection, and MUST define whether each type dedupes “while active” or is strictly unique.
|
||||
- **FR-011b (Scheduled backups are strict)**: Scheduled backup executions MUST use strict dedupe per schedule and intended fire-time (at most one canonical run ever per schedule per intended fire-time).
|
||||
- **FR-011a (Backup manual runs are unique)**: Manual backup schedule runs (“run now”) and retries MUST be unique per user action (no while-active dedupe).
|
||||
- **FR-012 (Inputs & provenance)**: The system MUST store operation inputs and provenance (target tenant/schedule, trigger source, optional initiating user) on the canonical run record.
|
||||
|
||||
- **FR-013 (Structured results)**: The system MUST store a standard, structured summary of results (counts) and failures (structured error entries) on the canonical run record.
|
||||
- **FR-014 (Restore domain vs execution)**: Restore workflow domain records may remain as domain entities, but execution tracking and “View run” affordances MUST use the canonical run record exclusively.
|
||||
|
||||
- **FR-015 (Cached directory data)**: The system MUST provide cached directory group data and cached role definition data to support search and label rendering in configuration forms without outbound calls.
|
||||
- **FR-015a (Role definitions sync type)**: The role definitions cache sync MUST use a dedicated operation type (e.g., `directory_role_definitions.sync`) to keep identities, results, and auditability distinct from other sync operations.
|
||||
- **FR-016 (Safe “Sync now”)**: When cached directory data is missing, the UI MUST provide a non-destructive “Sync now” action that starts an asynchronous sync and immediately exposes the canonical run link.
|
||||
|
||||
#### Assumptions
|
||||
|
||||
- A canonical run model/viewer already exists and is suitable for monitoring long-running operations.
|
||||
- Outbound calls to external services are permitted only in asynchronous execution paths and are observable via the canonical run record.
|
||||
|
||||
#### Out of Scope
|
||||
|
||||
- Backfilling legacy history into canonical runs.
|
||||
- Dropping/removing legacy run history tables.
|
||||
- Introducing new cross-workspace analytics.
|
||||
|
||||
## UI Action Matrix *(mandatory when admin UI is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations viewer | Canonical run viewer route | None | Open by canonical link | None | None | None | None | N/A | Yes (canonical run record metadata) | Must be DB-only rendering; non-member is 404 |
|
||||
| Inventory sync start | Inventory admin UI | Start sync | View run link appears after start | View run | None | None | N/A | N/A | Yes | Capability-gated; creates canonical run before dispatch |
|
||||
| Directory groups sync start | Directory groups admin UI & console | Sync now | View run link appears after start | View run | None | Sync now (when cache empty) | N/A | N/A | Yes | Single dispatcher entry; legacy start actions removed |
|
||||
| Backup schedule runs list | Backup schedule detail | None | List links open canonical viewer | View run | None | None | N/A | N/A | Yes | Includes scheduled/manual/retry runs; scheduled has distinct type |
|
||||
| Tenant configuration selectors | Tenant settings forms | Sync now (when cache empty) | Search from cached data | None | None | Sync now | N/A | Save/Cancel | Yes | No outbound calls in search/label resolution |
|
||||
| Legacy run history pages | Archive/history areas | None | View (read-only) | View only | None | None | None | N/A | Yes (historical) | No Start/Retry; redirect only if canonical mapping exists |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Canonical Run**: A single, shareable execution record containing type, identity, provenance, status, progress, results, and errors.
|
||||
- **Legacy Run History Record**: A historical record for prior run-tracking paths; viewable but not mutable.
|
||||
- **Managed Tenant**: The tenant context targeted by operations.
|
||||
- **Backup Schedule**: A schedule configuration that can trigger executions automatically.
|
||||
- **Restore Run (Domain Record)**: The domain workflow record for restore; links to canonical execution runs.
|
||||
- **Directory Group Cache**: Cached group metadata used for searching/label rendering in forms.
|
||||
- **Role Definition Cache**: Cached role definition metadata used for searching/label rendering in forms.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of newly started in-scope operations create a canonical run record before any asynchronous work is dispatched.
|
||||
- **SC-002**: Over a 30-day staging observation window, 0 new legacy run history rows are created for in-scope operations.
|
||||
- **SC-003**: Operations viewer and monitoring pages perform 0 outbound calls during rendering/search/label resolution (verified by automated tests).
|
||||
- **SC-004**: For scheduled backups, duplicate scheduler fires for the same schedule and intended fire-time result in at most 1 canonical run.
|
||||
- **SC-005**: Users can open a canonical “View run” link and see status/progress within 2 seconds in typical conditions.
|
||||
143
specs/086-retire-legacy-runs-into-operation-runs/tasks.md
Normal file
143
specs/086-retire-legacy-runs-into-operation-runs/tasks.md
Normal file
@ -0,0 +1,143 @@
|
||||
---
|
||||
|
||||
description: "Task list for Spec 086 implementation"
|
||||
---
|
||||
|
||||
# Tasks: Retire Legacy Runs Into Operation Runs (086)
|
||||
|
||||
**Input**: Design documents from `specs/086-retire-legacy-runs-into-operation-runs/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
**Tests**: REQUIRED (Pest) — runtime behavior changes must be covered.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
- [x] T001 Confirm baseline green test subset via `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Monitoring/OperationsDbOnlyTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
|
||||
- [x] T002 Confirm Filament v5 + Livewire v4 constraints are respected for any touched pages/resources in `app/Filament/**`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives required by all stories.
|
||||
|
||||
- [x] T003 Add centralized “run type → required capability” resolver in `app/Support/Operations/OperationRunCapabilityResolver.php`
|
||||
- [x] T004 Update `app/Policies/OperationRunPolicy.php` to enforce clarified 404/403 semantics (non-member 404; member missing capability 403) using T003
|
||||
- [x] T005 [P] Add/extend operation type registry for new types in `app/Support/OperationRunType.php`
|
||||
- [x] T006 [P] Add/extend operation labels/catalog entries in `app/Support/OperationCatalog.php`
|
||||
- [x] T007 Add tests covering view authorization semantics in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` (404 vs 403 + capability-gated view)
|
||||
|
||||
**Checkpoint**: Canonical viewer authorization matches spec; new run types exist in registries.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Start an operation with an immediate canonical run link (Priority: P1)
|
||||
|
||||
**Goal**: All start surfaces create an `operation_runs` record at dispatch time; no job fallback-create; “View run” link is stable.
|
||||
|
||||
**Independent Test**: Start each in-scope operation and assert the `operation_runs` row exists before work begins, with correct type/identity/context and a stable tenantless view URL.
|
||||
|
||||
### Tests (US1)
|
||||
|
||||
- [x] T008 [P] [US1] Add/extend tests for OperationRun dispatch-time creation in `tests/Feature/OperationRunServiceTest.php`
|
||||
- [X] T009 [P] [US1] Add/extend tests for start-surface authorization (403 prevents run creation) in `tests/Feature/RunStartAuthorizationTest.php`
|
||||
|
||||
### Implementation (US1)
|
||||
|
||||
- [X] T010 [US1] Ensure inventory sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
|
||||
- [X] T011 [US1] Ensure directory groups sync start surface creates OperationRun before dispatch and uses canonical link in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
|
||||
- [X] T012 [US1] Ensure backup schedule manual run-now creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- [X] T013 [US1] Ensure backup schedule retry creates OperationRun before dispatch with unique-per-click identity (nonce) in `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- [x] T014 [US1] Ensure scheduled backup dispatcher creates OperationRun before dispatch with strict identity by (schedule_id, scheduled_for) and type `backup_schedule.scheduled` in `app/Services/BackupScheduling/BackupScheduleDispatcher.php`
|
||||
- [x] T014a [US1] Enforce strict scheduled backup idempotency (at most one canonical run ever per schedule_id + intended fire-time), using an explicit DB constraint and/or lock strategy aligned with `OperationRunService` identities
|
||||
- [x] T015 [US1] Enforce “no job fallback-create” by validating required OperationRun context is present; fail fast if missing in `app/Jobs/RunInventorySyncJob.php`, `app/Jobs/EntraGroupSyncJob.php`, and `app/Jobs/RunBackupScheduleJob.php`
|
||||
|
||||
### Restore (US1)
|
||||
|
||||
- [X] T015a [P] [US1] Add/extend tests that starting a restore execution creates an OperationRun at dispatch time (target existing restore execution tests under `tests/Feature/RestoreRunWizardExecuteTest.php` and/or `tests/Feature/ExecuteRestoreRunJobTest.php`)
|
||||
- [X] T015b [US1] Ensure the restore execution start surface creates OperationRun before dispatch and surfaces the stable canonical “View run” link (adjust the Filament restore execution action/page used in the wizard flow)
|
||||
- [x] T015c [US2] Ensure restore domain records link to canonical OperationRuns for observability (align with FR-014; no legacy fallback-create)
|
||||
|
||||
**Checkpoint**: Starting operations always yields a stable `/admin/operations/{run}` link immediately.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Monitor executions from a single canonical viewer (Priority: P2)
|
||||
|
||||
**Goal**: Canonical viewer and Monitoring pages remain DB-only; legacy run history pages are read-only and redirect only when a deterministic mapping exists.
|
||||
|
||||
**Independent Test**: Load canonical viewer and legacy view pages while asserting no outbound Graph calls occur during render/search/label callbacks.
|
||||
|
||||
### Tests (US2)
|
||||
|
||||
- [X] T016 [P] [US2] Add tests asserting Monitoring pages render DB-only (no Graph calls) in `tests/Feature/Monitoring/MonitoringOperationsTest.php`
|
||||
- [X] T017 [P] [US2] Add tests for legacy-to-canonical redirect when mapping exists and no redirect when mapping absent in `tests/Feature/Operations/` (new file: `tests/Feature/Operations/LegacyRunRedirectTest.php`)
|
||||
|
||||
### Implementation (US2)
|
||||
|
||||
- [X] T018 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `inventory_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_inventory_sync_runs_table.php`)
|
||||
- [X] T019 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `entra_group_sync_runs` (new migration in `database/migrations/**_add_operation_run_id_to_entra_group_sync_runs_table.php`)
|
||||
- [X] T020 [US2] Add nullable `operation_run_id` mapping column + FK/index to legacy table `backup_schedule_runs` (new migration in `database/migrations/**_add_operation_run_id_to_backup_schedule_runs_table.php`)
|
||||
- [x] T021 [US2] Stop writing NEW legacy run rows for inventory sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Inventory/InventorySyncService.php` and any start surfaces)
|
||||
- [x] T022 [US2] Stop writing NEW legacy run rows for directory group sync and use `operation_runs` only for execution tracking (adjust service + callers in `app/Services/Directory/EntraGroupSyncService.php` and any start surfaces)
|
||||
- [x] T023 [US2] Stop writing NEW legacy run rows for backup schedule executions and use `operation_runs` only for execution tracking; keep legacy table strictly read-only history for existing rows (adjust dispatcher and UI surfaces in `app/Services/BackupScheduling/BackupScheduleDispatcher.php` and `app/Filament/Resources/BackupScheduleResource.php`)
|
||||
- [x] T023a [US2] Update Backup Schedule UI to show new executions from `operation_runs` (query by type + context like schedule_id) and link to canonical viewer; legacy runs list remains history-only
|
||||
- [X] T024 [US2] Implement deterministic redirect on legacy “view” pages when `operation_run_id` exists in `app/Filament/Resources/InventorySyncRunResource/Pages/ViewInventorySyncRun.php` and `app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php`
|
||||
- [x] T025 [US2] Ensure legacy run history pages remain strictly read-only (remove/disable start/retry actions) in `app/Filament/Resources/InventorySyncRunResource.php`, `app/Filament/Resources/EntraGroupSyncRunResource.php`, and `app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php`
|
||||
|
||||
**Checkpoint**: Canonical viewer is the only execution-tracker UI; legacy is view-only and redirects only when mapped.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Use cached directory data in forms without blocking calls (Priority: P3)
|
||||
|
||||
**Goal**: Tenant configuration selectors use cached directory groups + cached role definitions; “Sync now” triggers async sync with an immediate canonical run link; no outbound calls during render/search/label callbacks.
|
||||
|
||||
**Independent Test**: Render Tenant configuration forms and exercise search/label callbacks while asserting Graph client is not called.
|
||||
|
||||
### Tests (US3)
|
||||
|
||||
- [x] T026 [P] [US3] Add tests that TenantResource role definition selectors render/search DB-only (no Graph calls) in `tests/Feature/Filament/` (new file: `tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php`)
|
||||
- [x] T027 [P] [US3] Add tests that “Sync now” creates an OperationRun and returns a canonical view link in `tests/Feature/DirectoryGroups/` or `tests/Feature/TenantRBAC/` (choose closest existing folder)
|
||||
- [x] T027a [P] [US3] Add tests that directory group selectors render/search DB-only (no Graph calls) and use cached DB tables (new file under `tests/Feature/DirectoryGroups/` or `tests/Feature/Filament/`)
|
||||
|
||||
### Implementation (US3)
|
||||
|
||||
- [x] T028 [US3] Create cached role definitions table + model + factory (new migration in `database/migrations/**_create_entra_role_definitions_table.php`, model in `app/Models/EntraRoleDefinition.php`, factory in `database/factories/EntraRoleDefinitionFactory.php`)
|
||||
- [x] T029 [US3] Add “role definitions sync” operation type `directory_role_definitions.sync` to `app/Support/OperationRunType.php` and label in `app/Support/OperationCatalog.php` (if not already completed in T005/T006)
|
||||
- [x] T030 [US3] Implement role definitions sync service + job that updates the cache and records progress/failures on the OperationRun (service in `app/Services/Directory/RoleDefinitionsSyncService.php`, job in `app/Jobs/SyncRoleDefinitionsJob.php`)
|
||||
- [x] T030a [US3] Register/verify Graph contract entries required for role definitions sync in `config/graph_contracts.php` and ensure the sync uses `GraphClientInterface` only (no ad-hoc endpoints)
|
||||
- [x] T031 [US3] Update `app/Filament/Resources/TenantResource.php` roleDefinitions search/label callbacks to query cached DB tables only (remove Graph calls from callbacks)
|
||||
- [x] T032 [US3] Add a non-destructive “Sync now” Filament action that dispatches `directory_role_definitions.sync` and provides a canonical “View run” link (in `app/Filament/Resources/TenantResource.php`)
|
||||
|
||||
**Checkpoint**: Tenant configuration selectors are DB-only; cache sync is async and observable via canonical run.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T033 Ensure new/modified destructive-like actions (if any) use `Action::make(...)->action(...)->requiresConfirmation()` and are authorized server-side (audit existing touched Filament actions under `app/Filament/**`)
|
||||
- [x] T034 Run Pint on changed files via `vendor/bin/sail bin pint --dirty`
|
||||
- [x] T035 Run targeted test subset per quickstart: `vendor/bin/sail artisan test --compact --filter=OperationRun` and the new/changed test files
|
||||
- [x] T036 Allow re-running onboarding verification while status is `in_progress` (prevents dead-end when a prior run is stuck and the current connection would immediately block with next steps) in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||
- [x] T037 Auto-fail stale queued provider operation runs to allow rerun (prevents permanent dedupe when a worker isn’t running) in `app/Services/Providers/ProviderOperationStartGate.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → Polish (Phase 6)
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 is the MVP: it enables stable canonical run creation + links.
|
||||
- US2 depends on Foundational + US1 (viewer/auth semantics), but can be implemented in parallel once viewer auth is stable.
|
||||
- US3 depends on Foundational + cache primitives, but can proceed after Foundational even if US2 is in progress.
|
||||
|
||||
### Parallel Execution Examples
|
||||
|
||||
- US1 parallelizable: T008 + T009 (tests) can be written in parallel; start-surface patches T010–T014 can be split across different files.
|
||||
- US2 parallelizable: migrations T018–T020 can be done in parallel; legacy resource updates T024–T025 can be split by resource.
|
||||
- US3 parallelizable: schema/model/factory T028 can be done while tests T026–T027 are being drafted.
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -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],
|
||||
)));
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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)');
|
||||
});
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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)');
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
50
tests/Feature/Monitoring/MonitoringOperationsTest.php
Normal file
50
tests/Feature/Monitoring/MonitoringOperationsTest.php
Normal 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();
|
||||
});
|
||||
@ -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();
|
||||
|
||||
|
||||
88
tests/Feature/Operations/LegacyRunRedirectTest.php
Normal file
88
tests/Feature/Operations/LegacyRunRedirectTest.php
Normal 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();
|
||||
});
|
||||
@ -3,11 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\TenantRole;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@ -61,6 +63,42 @@
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for members missing the required capability for the operation type', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$tenant->users()->attach((int) $user->getKey(), [
|
||||
'role' => TenantRole::Readonly->value,
|
||||
'source' => 'manual',
|
||||
'source_ref' => null,
|
||||
'created_by_user_id' => null,
|
||||
]);
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/operations/{$run->getKey()}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('renders stored target scope and failure details for a completed run', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
38
tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php
Normal file
38
tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php
Normal 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)
|
||||
);
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
60
tests/Unit/Providers/ProviderNextStepsRegistryTest.php
Normal file
60
tests/Unit/Providers/ProviderNextStepsRegistryTest.php
Normal 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');
|
||||
});
|
||||
@ -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'));
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user