085-tenant-operate-hub #103
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -24,6 +24,9 @@ ## Active Technologies
|
||||
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface` (084-verification-surfaces-unification)
|
||||
- PostgreSQL (JSONB-backed `OperationRun.context`) (084-verification-surfaces-unification)
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
|
||||
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -43,9 +46,10 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4)
|
||||
- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
|
||||
- 085-tenant-operate-hub: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail
|
||||
- 084-verification-surfaces-unification: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Queue/Jobs (Laravel), Microsoft Graph via `GraphClientInterface`
|
||||
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
@ -25,4 +27,15 @@ class Alerts extends Page
|
||||
protected static ?string $title = 'Alerts';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.alerts';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
@ -25,4 +27,15 @@ class AuditLog extends Page
|
||||
protected static ?string $title = 'Audit Log';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.audit-log';
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_audit_log',
|
||||
returnActionName: 'operate_hub_return_audit_log',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,10 +7,14 @@
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Widgets\Operations\OperationsKpiHeader;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
@ -50,6 +54,46 @@ protected function getHeaderWidgets(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
|
||||
$actions = [
|
||||
Action::make('operate_hub_scope_operations')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_operations')
|
||||
->label('Back to '.$activeTenant->name)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
|
||||
$actions[] = Action::make('operate_hub_show_all_tenants')
|
||||
->label('Show all tenants')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
|
||||
$this->removeTableFilter('tenant_id');
|
||||
|
||||
$this->redirect('/admin/operations');
|
||||
});
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function updatedActiveTab(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
@ -61,6 +105,8 @@ public function table(Table $table): Table
|
||||
->query(function (): Builder {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
->latest('id')
|
||||
@ -71,6 +117,10 @@ public function table(Table $table): Table
|
||||
->when(
|
||||
! $workspaceId,
|
||||
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when(
|
||||
$activeTenant instanceof Tenant,
|
||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
|
||||
);
|
||||
|
||||
return $this->applyActiveTab($query);
|
||||
|
||||
@ -9,17 +9,20 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantlessOperationRunViewer extends Page
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
@ -37,16 +40,42 @@ class TenantlessOperationRunViewer extends Page
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
|
||||
$actions = [
|
||||
Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
Action::make('operate_hub_scope_run_detail')
|
||||
->label($operateHubShell->scopeLabel(request()))
|
||||
->color('gray')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||
: route('admin.operations.index')),
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
|
||||
->label('← Back to '.$activeTenant->name)
|
||||
->color('gray')
|
||||
->url(\App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant));
|
||||
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
} else {
|
||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||
->label('Back to Operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
$actions[] = Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? route('admin.operations.view', ['run' => (int) $this->run->getKey()])
|
||||
: route('admin.operations.index'));
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
}
|
||||
@ -87,7 +116,7 @@ public function mount(OperationRun $run): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Gate::forUser($user)->authorize('view', $run);
|
||||
$this->authorize('view', $run);
|
||||
|
||||
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
|
||||
}
|
||||
|
||||
@ -347,7 +347,7 @@ public function content(Schema $schema): Schema
|
||||
SchemaActions::make([
|
||||
Action::make('wizardStartVerification')
|
||||
->label('Start verification')
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress')
|
||||
->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive())
|
||||
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
||||
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
||||
? null
|
||||
@ -629,6 +629,10 @@ private function verificationStatus(): string
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
if ($run->outcome === OperationRunOutcome::Blocked->value) {
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
if ($run->outcome === OperationRunOutcome::Succeeded->value) {
|
||||
return 'ready';
|
||||
}
|
||||
@ -658,7 +662,7 @@ private function verificationStatus(): string
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied'], true)) {
|
||||
if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) {
|
||||
return 'blocked';
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,10 +4,10 @@
|
||||
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Rules\SupportedPolicyTypesRule;
|
||||
@ -26,7 +26,6 @@
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeZone;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -50,7 +49,6 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -58,6 +56,10 @@
|
||||
|
||||
class BackupScheduleResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BackupSchedule::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
@ -384,104 +386,38 @@ public static function table(Table $table): Table
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$operationRun = $operationRunService->ensureRun(
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.run_now',
|
||||
inputs: [
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
],
|
||||
initiator: $userModel
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('This schedule already has a queued or running backup.')
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
Notification::make()
|
||||
->title('Run already queued')
|
||||
->body('Please wait a moment and try again.')
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
summaryCounts: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
],
|
||||
failures: [
|
||||
[
|
||||
'code' => 'SCHEDULE_CONFLICT',
|
||||
'message' => 'Unable to queue a unique backup schedule run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRun->update([
|
||||
'context' => array_merge($operationRun->context ?? [], [
|
||||
context: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
]);
|
||||
'trigger' => 'run_now',
|
||||
],
|
||||
initiator: $userModel,
|
||||
);
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'operation_run_id' => $operationRun->getKey(),
|
||||
'trigger' => 'run_now',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
@ -519,104 +455,38 @@ public static function table(Table $table): Table
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$operationRun = $operationRunService->ensureRun(
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.retry',
|
||||
inputs: [
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
],
|
||||
initiator: $userModel
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Retry already queued')
|
||||
->body('This schedule already has a queued or running retry.')
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
Notification::make()
|
||||
->title('Retry already queued')
|
||||
->body('Please wait a moment and try again.')
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
summaryCounts: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
],
|
||||
failures: [
|
||||
[
|
||||
'code' => 'SCHEDULE_CONFLICT',
|
||||
'message' => 'Unable to queue a unique backup schedule retry run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRun->update([
|
||||
'context' => array_merge($operationRun->context ?? [], [
|
||||
context: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
]);
|
||||
'trigger' => 'retry',
|
||||
],
|
||||
initiator: $userModel,
|
||||
);
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'operation_run_id' => $operationRun->getKey(),
|
||||
'trigger' => 'retry',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
@ -640,6 +510,7 @@ public static function table(Table $table): Table
|
||||
DeleteAction::make()
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
@ -674,96 +545,52 @@ public static function table(Table $table): Table
|
||||
|
||||
$bulkRun = null;
|
||||
|
||||
$createdRunIds = [];
|
||||
$createdOperationRunIds = [];
|
||||
|
||||
/** @var BackupSchedule $record */
|
||||
foreach ($records as $record) {
|
||||
$operationRun = $operationRunService->ensureRun(
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.run_now',
|
||||
inputs: [
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
],
|
||||
initiator: $user
|
||||
context: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'trigger' => 'bulk_run_now',
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
summaryCounts: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
],
|
||||
failures: [
|
||||
[
|
||||
'code' => 'SCHEDULE_CONFLICT',
|
||||
'message' => 'Unable to queue a unique backup schedule run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$createdRunIds[] = (int) $run->id;
|
||||
|
||||
$operationRun->update([
|
||||
'context' => array_merge($operationRun->context ?? [], [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
]);
|
||||
$createdOperationRunIds[] = (int) $operationRun->getKey();
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'operation_run_id' => $operationRun->getKey(),
|
||||
'trigger' => 'bulk_run_now',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
|
||||
}, emitQueuedNotification: false);
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Runs dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
||||
->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
|
||||
|
||||
if (count($createdRunIds) === 0) {
|
||||
if (count($createdOperationRunIds) === 0) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
@ -779,7 +606,7 @@ public static function table(Table $table): Table
|
||||
|
||||
$notification->send();
|
||||
|
||||
if (count($createdRunIds) > 0) {
|
||||
if (count($createdOperationRunIds) > 0) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
}
|
||||
})
|
||||
@ -815,96 +642,52 @@ public static function table(Table $table): Table
|
||||
|
||||
$bulkRun = null;
|
||||
|
||||
$createdRunIds = [];
|
||||
$createdOperationRunIds = [];
|
||||
|
||||
/** @var BackupSchedule $record */
|
||||
foreach ($records as $record) {
|
||||
$operationRun = $operationRunService->ensureRun(
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.retry',
|
||||
inputs: [
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
],
|
||||
initiator: $user
|
||||
context: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'trigger' => 'bulk_retry',
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
$run = null;
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $userId,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
break;
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $run instanceof BackupScheduleRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
summaryCounts: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
],
|
||||
failures: [
|
||||
[
|
||||
'code' => 'SCHEDULE_CONFLICT',
|
||||
'message' => 'Unable to queue a unique backup schedule retry run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$createdRunIds[] = (int) $run->id;
|
||||
|
||||
$operationRun->update([
|
||||
'context' => array_merge($operationRun->context ?? [], [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
]);
|
||||
$createdOperationRunIds[] = (int) $operationRun->getKey();
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'operation_run_id' => $operationRun->getKey(),
|
||||
'trigger' => 'bulk_retry',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($record, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob(0, $operationRun, (int) $record->getKey()));
|
||||
}, emitQueuedNotification: false);
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Retries dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)));
|
||||
->body(sprintf('Queued %d run(s).', count($createdOperationRunIds)));
|
||||
|
||||
if (count($createdRunIds) === 0) {
|
||||
if (count($createdOperationRunIds) === 0) {
|
||||
$notification->warning();
|
||||
} else {
|
||||
$notification->success();
|
||||
@ -920,7 +703,7 @@ public static function table(Table $table): Table
|
||||
|
||||
$notification->send();
|
||||
|
||||
if (count($createdRunIds) > 0) {
|
||||
if (count($createdOperationRunIds) > 0) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
}
|
||||
})
|
||||
@ -931,6 +714,7 @@ public static function table(Table $table): Table
|
||||
DeleteBulkAction::make('bulk_delete')
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
]),
|
||||
]);
|
||||
@ -949,6 +733,7 @@ public static function getEloquentQuery(): Builder
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
BackupScheduleOperationRunsRelationManager::class,
|
||||
BackupScheduleRunsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BackupScheduleOperationRunsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'operationRuns';
|
||||
|
||||
protected static ?string $title = 'Executions';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Enqueued')
|
||||
->dateTime(),
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->formatStateUsing([OperationCatalog::class, 'label']),
|
||||
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
|
||||
Tables\Columns\TextColumn::make('counts')
|
||||
->label('Counts')
|
||||
->getStateUsing(function (OperationRun $record): string {
|
||||
$counts = is_array($record->summary_counts) ? $record->summary_counts : [];
|
||||
|
||||
$total = (int) ($counts['total'] ?? 0);
|
||||
$succeeded = (int) ($counts['succeeded'] ?? 0);
|
||||
$failed = (int) ($counts['failed'] ?? 0);
|
||||
|
||||
if ($total === 0 && $succeeded === 0 && $failed === 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed);
|
||||
}),
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(function (OperationRun $record): string {
|
||||
$tenant = Tenant::currentOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
->openUrlInNewTab(true),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,8 @@ class BackupSetResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BackupSet::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ class FindingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Finding::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||
|
||||
@ -37,6 +37,8 @@ class InventoryItemResource extends Resource
|
||||
{
|
||||
protected static ?string $model = InventoryItem::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
@ -125,8 +127,20 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
TextEntry::make('external_id')->label('External ID'),
|
||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
|
||||
TextEntry::make('last_seen_operation_run_id')
|
||||
->label('Last inventory sync')
|
||||
->visible(fn (InventoryItem $record): bool => filled($record->last_seen_operation_run_id))
|
||||
->url(function (InventoryItem $record): ?string {
|
||||
if (! $record->last_seen_operation_run_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('last_seen_run_id')
|
||||
->label('Last sync run')
|
||||
->label('Last inventory sync (legacy)')
|
||||
->visible(fn (InventoryItem $record): bool => blank($record->last_seen_operation_run_id) && filled($record->last_seen_run_id))
|
||||
->url(function (InventoryItem $record): ?string {
|
||||
if (! $record->last_seen_run_id) {
|
||||
return null;
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -32,6 +32,8 @@ class InventorySyncRunResource extends Resource
|
||||
{
|
||||
protected static ?string $model = InventorySyncRun::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -317,6 +318,14 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(function (): array {
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
|
||||
];
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -330,19 +339,19 @@ public static function table(Table $table): Table
|
||||
->all();
|
||||
})
|
||||
->default(function (): ?string {
|
||||
$tenant = Filament::getTenant();
|
||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
if (! $activeTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
if ($workspaceId === null || (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
if ($workspaceId === null || (int) $activeTenant->workspace_id !== (int) $workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $tenant->getKey();
|
||||
return (string) $activeTenant->getKey();
|
||||
})
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
|
||||
@ -52,6 +52,8 @@ class PolicyResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Policy::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
@ -51,6 +51,8 @@ class PolicyVersionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PolicyVersion::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
@ -84,7 +85,61 @@ protected static function resolveScopedTenant(): ?Tenant
|
||||
->first();
|
||||
}
|
||||
|
||||
return Tenant::current();
|
||||
$externalId = static::resolveTenantExternalIdFromLivewireRequest();
|
||||
|
||||
if (is_string($externalId) && $externalId !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $externalId)
|
||||
->first();
|
||||
}
|
||||
|
||||
$filamentTenant = \Filament\Facades\Filament::getTenant();
|
||||
|
||||
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
|
||||
}
|
||||
|
||||
private static function resolveTenantExternalIdFromLivewireRequest(): ?string
|
||||
{
|
||||
if (! request()->headers->has('x-livewire') && ! request()->headers->has('x-livewire-navigate')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$url = \Livewire\Livewire::originalUrl();
|
||||
|
||||
if (is_string($url) && $url !== '') {
|
||||
$externalId = static::extractTenantExternalIdFromUrl($url);
|
||||
|
||||
if (is_string($externalId) && $externalId !== '') {
|
||||
return $externalId;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Ignore and fall back to referer.
|
||||
}
|
||||
|
||||
$referer = request()->headers->get('referer');
|
||||
|
||||
if (! is_string($referer) || $referer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::extractTenantExternalIdFromUrl($referer);
|
||||
}
|
||||
|
||||
private static function extractTenantExternalIdFromUrl(string $url): ?string
|
||||
{
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
$path = $url;
|
||||
}
|
||||
|
||||
if (preg_match('~/(?:admin)/(?:tenants|t)/([0-9a-fA-F-]{36})(?:/|$)~', $path, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $matches[1];
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -589,15 +644,18 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
$hadCredentials = $record->credential()->exists();
|
||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||
$previousStatus = (string) $record->status;
|
||||
|
||||
$status = $hadCredentials ? 'connected' : 'error';
|
||||
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
|
||||
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
|
||||
|
||||
$record->update([
|
||||
'status' => $status,
|
||||
'health_status' => 'unknown',
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => null,
|
||||
'last_error_message' => null,
|
||||
'last_error_reason_code' => $errorReasonCode,
|
||||
'last_error_message' => $errorMessage,
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
@ -716,9 +774,13 @@ public static function getEloquentQuery(): Builder
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
if ($tenantId === null) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query
|
||||
->where('workspace_id', (int) $workspaceId)
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
->where('tenant_id', $tenantId)
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
@ -736,6 +798,10 @@ public static function getPages(): array
|
||||
*/
|
||||
public static function getUrl(?string $name = null, array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
|
||||
{
|
||||
if (array_key_exists('tenant', $parameters) && blank($parameters['tenant'])) {
|
||||
unset($parameters['tenant']);
|
||||
}
|
||||
|
||||
if (! array_key_exists('tenant', $parameters)) {
|
||||
if ($tenant instanceof Tenant) {
|
||||
$parameters['tenant'] = $tenant->external_id;
|
||||
@ -746,6 +812,20 @@ public static function getUrl(?string $name = null, array $parameters = [], bool
|
||||
if (! array_key_exists('tenant', $parameters) && $resolvedTenant instanceof Tenant) {
|
||||
$parameters['tenant'] = $resolvedTenant->external_id;
|
||||
}
|
||||
|
||||
$record = $parameters['record'] ?? null;
|
||||
|
||||
if (! array_key_exists('tenant', $parameters) && $record instanceof ProviderConnection) {
|
||||
$recordTenant = $record->tenant;
|
||||
|
||||
if (! $recordTenant instanceof Tenant) {
|
||||
$recordTenant = Tenant::query()->whereKey($record->tenant_id)->first();
|
||||
}
|
||||
|
||||
if ($recordTenant instanceof Tenant) {
|
||||
$parameters['tenant'] = $recordTenant->external_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$panel ??= 'admin';
|
||||
|
||||
@ -28,10 +28,29 @@ class EditProviderConnection extends EditRecord
|
||||
{
|
||||
protected static string $resource = ProviderConnectionResource::class;
|
||||
|
||||
public ?string $scopedTenantExternalId = null;
|
||||
|
||||
protected bool $shouldMakeDefault = false;
|
||||
|
||||
protected bool $defaultWasChanged = false;
|
||||
|
||||
public function mount($record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$tenant = request()->route('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->scopedTenantExternalId = (string) $tenant->external_id;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_string($tenant) && $tenant !== '') {
|
||||
$this->scopedTenantExternalId = $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
|
||||
@ -42,9 +61,16 @@ protected function mutateFormDataBeforeSave(array $data): array
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
$record = $this->getRecord();
|
||||
|
||||
$tenant = $record instanceof ProviderConnection
|
||||
? ($record->tenant ?? $this->currentTenant())
|
||||
: $this->currentTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changedFields = array_values(array_diff(array_keys($record->getChanges()), ['updated_at']));
|
||||
|
||||
if ($this->shouldMakeDefault && ! $record->is_default) {
|
||||
@ -602,15 +628,18 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
$hadCredentials = $record->credential()->exists();
|
||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||
$previousStatus = (string) $record->status;
|
||||
|
||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||
$errorReasonCode = null;
|
||||
$errorMessage = null;
|
||||
|
||||
$record->update([
|
||||
'status' => $status,
|
||||
'health_status' => 'unknown',
|
||||
'last_health_check_at' => null,
|
||||
'last_error_reason_code' => null,
|
||||
'last_error_message' => null,
|
||||
'last_error_reason_code' => $errorReasonCode,
|
||||
'last_error_message' => $errorMessage,
|
||||
]);
|
||||
|
||||
$user = auth()->user();
|
||||
@ -640,8 +669,8 @@ protected function getHeaderActions(): array
|
||||
|
||||
if (! $hadCredentials) {
|
||||
Notification::make()
|
||||
->title('Connection enabled (credentials missing)')
|
||||
->body('Add credentials before running checks or operations.')
|
||||
->title('Connection enabled (needs consent)')
|
||||
->body('Grant admin consent before running checks or operations.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -744,7 +773,9 @@ protected function getFormActions(): array
|
||||
|
||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
$tenant = $this->currentTenant();
|
||||
$tenant = $record instanceof ProviderConnection
|
||||
? ($record->tenant ?? $this->currentTenant())
|
||||
: $this->currentTenant();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -767,6 +798,12 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
|
||||
private function currentTenant(): ?Tenant
|
||||
{
|
||||
if (is_string($this->scopedTenantExternalId) && $this->scopedTenantExternalId !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $this->scopedTenantExternalId)
|
||||
->first();
|
||||
}
|
||||
|
||||
$tenant = request()->route('tenant');
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -60,6 +61,17 @@ class RestoreRunResource extends Resource
|
||||
{
|
||||
protected static ?string $model = RestoreRun::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
@ -876,7 +888,27 @@ public static function table(Table $table): Table
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName);
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
$initiator = auth()->user();
|
||||
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
|
||||
|
||||
$opRun = $runs->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'restore.execute',
|
||||
inputs: [
|
||||
'restore_run_id' => (int) $newRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
||||
],
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
|
||||
$newRun->update(['operation_run_id' => $opRun->getKey()]);
|
||||
}
|
||||
|
||||
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -896,6 +928,11 @@ public static function table(Table $table): Table
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast('restore.execute')
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
@ -1727,7 +1764,35 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
status: 'success',
|
||||
);
|
||||
|
||||
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName);
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
$initiator = auth()->user();
|
||||
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
|
||||
|
||||
$opRun = $runs->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'restore.execute',
|
||||
inputs: [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||
],
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
|
||||
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
|
||||
}
|
||||
|
||||
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
|
||||
|
||||
OperationUxPresenter::queuedToast('restore.execute')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return $restoreRun->refresh();
|
||||
}
|
||||
|
||||
@ -7,12 +7,16 @@
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Jobs\BulkTenantSyncJob;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraRoleDefinition;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\RoleCapabilityMap;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Directory\RoleDefinitionsSyncService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RbacOnboardingService;
|
||||
@ -46,9 +50,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
use UnitEnum;
|
||||
|
||||
class TenantResource extends Resource
|
||||
@ -949,18 +951,14 @@ public static function rbacAction(): Actions\Action
|
||||
->optionsLimit(20)
|
||||
->searchDebounce(400)
|
||||
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::roleSearchOptions($record, $search))
|
||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::formatRoleLabel(
|
||||
static::resolveRoleName($record, $value),
|
||||
$value ?? ''
|
||||
))
|
||||
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
|
||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::roleLabelFromCache($record, $value))
|
||||
->helperText(fn (?Tenant $record) => static::roleSearchHelper($record))
|
||||
->hintAction(fn (?Tenant $record) => static::loginToSearchRolesAction($record))
|
||||
->hintAction(fn (?Tenant $record) => static::syncRoleDefinitionsAction())
|
||||
->hint('Wizard grants Intune RBAC roles only. "Intune Administrator" is an Entra directory role and is not assigned here.')
|
||||
->noSearchResultsMessage('No Intune RBAC roleDefinitions found (tenant may restrict RBAC or missing permission).')
|
||||
->loadingMessage('Loading roles...')
|
||||
->afterStateUpdated(function (Set $set, ?string $state, ?Tenant $record) {
|
||||
$set('role_display_name', static::resolveRoleName($record, $state));
|
||||
$set('role_display_name', static::roleNameFromCache($record, $state));
|
||||
}),
|
||||
Forms\Components\Hidden::make('role_display_name')
|
||||
->dehydrated(),
|
||||
@ -982,11 +980,9 @@ public static function rbacAction(): Actions\Action
|
||||
->placeholder('Search security groups')
|
||||
->visible(fn (Get $get) => $get('scope') === 'scope_group')
|
||||
->required(fn (Get $get) => $get('scope') === 'scope_group')
|
||||
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
|
||||
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
|
||||
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
|
||||
->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null)
|
||||
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
|
||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
|
||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value))
|
||||
->noSearchResultsMessage('No security groups found')
|
||||
->loadingMessage('Searching groups...'),
|
||||
Forms\Components\Select::make('group_mode')
|
||||
@ -1011,11 +1007,9 @@ public static function rbacAction(): Actions\Action
|
||||
->placeholder('Search security groups')
|
||||
->visible(fn (Get $get) => $get('group_mode') === 'existing')
|
||||
->required(fn (Get $get) => $get('group_mode') === 'existing')
|
||||
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
|
||||
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
|
||||
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
|
||||
->disabled(fn (?Tenant $record): bool => static::delegatedToken($record) === null)
|
||||
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
|
||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
|
||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::groupLabelFromCache($record, $value))
|
||||
->noSearchResultsMessage('No security groups found')
|
||||
->loadingMessage('Searching groups...'),
|
||||
])
|
||||
@ -1052,8 +1046,10 @@ public static function rbacAction(): Actions\Action
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
||||
$token = Cache::get($cacheKey);
|
||||
$userCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), null);
|
||||
$sessionCacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
||||
|
||||
$token = Cache::get($sessionCacheKey) ?? Cache::get($userCacheKey);
|
||||
|
||||
if (! $token) {
|
||||
Notification::make()
|
||||
@ -1078,7 +1074,8 @@ public static function rbacAction(): Actions\Action
|
||||
|
||||
$result = $service->run($record, $data, $user, $token);
|
||||
|
||||
Cache::forget($cacheKey);
|
||||
Cache::forget($sessionCacheKey);
|
||||
Cache::forget($userCacheKey);
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
Notification::make()
|
||||
@ -1133,6 +1130,10 @@ public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$tenantId = $tenant->graphTenantId();
|
||||
$clientId = $tenant->app_client_id;
|
||||
|
||||
if (! is_string($clientId) || trim($clientId) === '') {
|
||||
$clientId = static::resolveProviderClientIdForConsent($tenant);
|
||||
}
|
||||
$redirectUri = route('admin.consent.callback');
|
||||
$state = sprintf('tenantpilot|%s', $tenant->id);
|
||||
|
||||
@ -1162,6 +1163,36 @@ public static function adminConsentUrl(Tenant $tenant): ?string
|
||||
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
|
||||
}
|
||||
|
||||
private static function resolveProviderClientIdForConsent(Tenant $tenant): ?string
|
||||
{
|
||||
$connection = ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('provider', 'microsoft')
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $connection->credential?->payload;
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = $payload['client_id'] ?? null;
|
||||
|
||||
if (! is_string($clientId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientId = trim($clientId);
|
||||
|
||||
return $clientId !== '' ? $clientId : null;
|
||||
}
|
||||
|
||||
public static function entraUrl(Tenant $tenant): ?string
|
||||
{
|
||||
if ($tenant->app_client_id) {
|
||||
@ -1195,23 +1226,16 @@ private static function delegatedToken(?Tenant $tenant): ?string
|
||||
|
||||
private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Action
|
||||
{
|
||||
if (! $tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Actions\Action::make('login_to_load_roles')
|
||||
->label('Login to load roles')
|
||||
->url(route('admin.rbac.start', [
|
||||
'tenant' => $tenant->graphTenantId(),
|
||||
'return' => route('filament.admin.resources.tenants.view', [
|
||||
'record' => $tenant,
|
||||
]),
|
||||
]));
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function roleSearchHelper(?Tenant $tenant): ?string
|
||||
{
|
||||
return static::delegatedToken($tenant) ? null : 'Login to load roles';
|
||||
if (! $tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::delegatedToken($tenant) === null ? 'Login to search roles' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1219,122 +1243,112 @@ public static function roleSearchHelper(?Tenant $tenant): ?string
|
||||
*/
|
||||
public static function roleSearchOptions(?Tenant $tenant, string $search): array
|
||||
{
|
||||
$token = static::delegatedToken($tenant);
|
||||
|
||||
if ($token !== null) {
|
||||
return static::searchRoleDefinitionsDelegated($tenant, $search, $token);
|
||||
}
|
||||
|
||||
return static::searchRoleDefinitions($tenant, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function searchRoleDefinitionsDelegated(?Tenant $tenant, string $search, string $token): array
|
||||
{
|
||||
if (! $tenant || mb_strlen($search) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$needle = mb_strtolower($search);
|
||||
|
||||
/** @var GraphClientInterface $graph */
|
||||
$graph = app(GraphClientInterface::class);
|
||||
|
||||
$response = $graph->request('GET', 'deviceManagement/roleDefinitions', [
|
||||
'access_token' => $token,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$roles = is_array($response->data['value'] ?? null) ? $response->data['value'] : [];
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($roles as $role) {
|
||||
$id = is_string($role['id'] ?? null) ? (string) $role['id'] : null;
|
||||
$displayName = is_string($role['displayName'] ?? null) ? (string) $role['displayName'] : null;
|
||||
|
||||
if (! $id || ! $displayName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_contains(mb_strtolower($displayName), $needle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[$id] = static::formatRoleLabel($displayName, $id);
|
||||
}
|
||||
|
||||
ksort($results);
|
||||
|
||||
return array_slice($results, 0, 20, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function searchRoleDefinitions(?Tenant $tenant, string $search): array
|
||||
{
|
||||
if (! $tenant) {
|
||||
if (! $tenant || mb_strlen($search) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$token = static::delegatedToken($tenant);
|
||||
$needle = mb_strtolower($search);
|
||||
|
||||
if (! $token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Str::contains(Str::lower($search), 'intune administrator')) {
|
||||
Notification::make()
|
||||
->title('Intune Administrator is a directory role')
|
||||
->body('Das ist eine Entra Directory Role, nicht Intune RBAC; wird vom Wizard nicht vergeben.')
|
||||
->warning()
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
|
||||
$filter = mb_strlen($search) >= 2
|
||||
? sprintf("startswith(displayName,'%s')", static::escapeOdataValue($search))
|
||||
: null;
|
||||
|
||||
$query = [
|
||||
'$select' => 'id,displayName,isBuiltIn',
|
||||
'$top' => 20,
|
||||
];
|
||||
|
||||
if ($filter) {
|
||||
$query['$filter'] = $filter;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = app(GraphClientInterface::class)->request(
|
||||
'GET',
|
||||
'deviceManagement/roleDefinitions',
|
||||
[
|
||||
'query' => $query,
|
||||
] + $tenant->graphOptions() + [
|
||||
'access_token' => $token,
|
||||
]
|
||||
);
|
||||
} catch (Throwable) {
|
||||
static::notifyRoleLookupFailure();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
static::notifyRoleLookupFailure();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$roles = collect($response->data['value'] ?? [])
|
||||
->filter(fn (array $role) => filled($role['id'] ?? null))
|
||||
->mapWithKeys(fn (array $role) => [
|
||||
$role['id'] => static::formatRoleLabel($role['displayName'] ?? null, $role['id']),
|
||||
return EntraRoleDefinition::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereRaw('lower(display_name) like ?', [$needle.'%'])
|
||||
->orderBy('display_name')
|
||||
->limit(20)
|
||||
->get(['entra_id', 'display_name'])
|
||||
->mapWithKeys(fn (EntraRoleDefinition $role): array => [
|
||||
(string) $role->entra_id => static::formatRoleLabel((string) $role->display_name, (string) $role->entra_id),
|
||||
])
|
||||
->all();
|
||||
|
||||
if (empty($roles)) {
|
||||
static::logEmptyRoleDefinitions($tenant, $response->data['value'] ?? []);
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
private static function resolveRoleName(?Tenant $tenant, ?string $roleId): ?string
|
||||
public static function roleLabelFromCache(?Tenant $tenant, ?string $roleId): ?string
|
||||
{
|
||||
if (! $tenant || blank($roleId)) {
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
$token = static::delegatedToken($tenant);
|
||||
$displayName = EntraRoleDefinition::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('entra_id', $roleId)
|
||||
->value('display_name');
|
||||
|
||||
if (! $token) {
|
||||
$displayName = is_string($displayName) && $displayName !== '' ? $displayName : $roleId;
|
||||
|
||||
return static::formatRoleLabel($displayName, $roleId);
|
||||
}
|
||||
|
||||
private static function roleNameFromCache(?Tenant $tenant, ?string $roleId): ?string
|
||||
{
|
||||
if (! $tenant || blank($roleId)) {
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = app(GraphClientInterface::class)->request(
|
||||
'GET',
|
||||
"deviceManagement/roleDefinitions/{$roleId}",
|
||||
[
|
||||
'query' => [
|
||||
'$select' => 'id,displayName',
|
||||
],
|
||||
] + $tenant->graphOptions() + [
|
||||
'access_token' => $token,
|
||||
]
|
||||
);
|
||||
} catch (Throwable) {
|
||||
static::notifyRoleLookupFailure();
|
||||
$displayName = EntraRoleDefinition::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('entra_id', $roleId)
|
||||
->value('display_name');
|
||||
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
static::notifyRoleLookupFailure();
|
||||
|
||||
return $roleId;
|
||||
}
|
||||
|
||||
$displayName = $response->data['displayName'] ?? null;
|
||||
$id = $response->data['id'] ?? $roleId;
|
||||
|
||||
return $displayName ?: $id;
|
||||
return is_string($displayName) && $displayName !== '' ? $displayName : $roleId;
|
||||
}
|
||||
|
||||
private static function formatRoleLabel(?string $displayName, string $id): string
|
||||
@ -1344,58 +1358,9 @@ private static function formatRoleLabel(?string $displayName, string $id): strin
|
||||
return trim(($displayName ?: 'RBAC role').$suffix);
|
||||
}
|
||||
|
||||
private static function escapeOdataValue(string $value): string
|
||||
{
|
||||
return str_replace("'", "''", $value);
|
||||
}
|
||||
|
||||
private static function notifyRoleLookupFailure(): void
|
||||
{
|
||||
Notification::make()
|
||||
->title('Role lookup failed')
|
||||
->body('Delegated session may have expired. Login again to load Intune RBAC roles.')
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
|
||||
private static function logEmptyRoleDefinitions(Tenant $tenant, array $roles): void
|
||||
{
|
||||
$names = collect($roles)->pluck('displayName')->filter()->take(5)->values()->all();
|
||||
|
||||
Log::warning('rbac.role_definitions.empty', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'count' => count($roles),
|
||||
'sample' => $names,
|
||||
]);
|
||||
|
||||
try {
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'rbac.roles.empty',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'warning',
|
||||
context: ['metadata' => ['count' => count($roles), 'sample' => $names]],
|
||||
);
|
||||
} catch (Throwable) {
|
||||
Log::notice('rbac.role_definitions.audit_failed', ['tenant_id' => $tenant->id]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Action
|
||||
{
|
||||
if (! $tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Actions\Action::make('login_to_search_groups')
|
||||
->label('Login to search groups')
|
||||
->url(route('admin.rbac.start', [
|
||||
'tenant' => $tenant->graphTenantId(),
|
||||
'return' => route('filament.admin.resources.tenants.view', [
|
||||
'record' => $tenant,
|
||||
]),
|
||||
]));
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function groupSearchHelper(?Tenant $tenant): ?string
|
||||
@ -1404,7 +1369,7 @@ public static function groupSearchHelper(?Tenant $tenant): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::delegatedToken($tenant) ? null : 'Login to search groups';
|
||||
return static::delegatedToken($tenant) === null ? 'Login to search groups' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1418,76 +1383,116 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra
|
||||
|
||||
$token = static::delegatedToken($tenant);
|
||||
|
||||
if (! $token) {
|
||||
return [];
|
||||
if ($token !== null) {
|
||||
/** @var GraphClientInterface $graph */
|
||||
$graph = app(GraphClientInterface::class);
|
||||
|
||||
$response = $graph->request('GET', 'groups', [
|
||||
'access_token' => $token,
|
||||
'query' => [
|
||||
'$filter' => sprintf("startswith(displayName,'%s') and securityEnabled eq true", addslashes($search)),
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = is_array($response->data['value'] ?? null) ? $response->data['value'] : [];
|
||||
$results = [];
|
||||
|
||||
foreach ($groups as $group) {
|
||||
$id = is_string($group['id'] ?? null) ? (string) $group['id'] : null;
|
||||
$displayName = is_string($group['displayName'] ?? null) ? (string) $group['displayName'] : null;
|
||||
|
||||
if (! $id || ! $displayName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[$id] = EntraGroupLabelResolver::formatLabel($displayName, $id);
|
||||
}
|
||||
|
||||
ksort($results);
|
||||
|
||||
return array_slice($results, 0, 20, true);
|
||||
}
|
||||
|
||||
$filter = sprintf(
|
||||
"securityEnabled eq true and startswith(displayName,'%s')",
|
||||
static::escapeOdataValue($search)
|
||||
);
|
||||
$needle = mb_strtolower($search);
|
||||
|
||||
try {
|
||||
$response = app(GraphClientInterface::class)->request(
|
||||
'GET',
|
||||
'groups',
|
||||
[
|
||||
'query' => [
|
||||
'$select' => 'id,displayName',
|
||||
'$top' => 20,
|
||||
'$filter' => $filter,
|
||||
],
|
||||
] + $tenant->graphOptions() + [
|
||||
'access_token' => $token,
|
||||
]
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($response->data['value'] ?? [])
|
||||
->filter(fn (array $group) => filled($group['id'] ?? null))
|
||||
->mapWithKeys(fn (array $group) => [
|
||||
(string) $group['id'] => EntraGroupLabelResolver::formatLabel($group['displayName'] ?? null, (string) $group['id']),
|
||||
return EntraGroup::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereRaw('lower(display_name) like ?', [$needle.'%'])
|
||||
->orderBy('display_name')
|
||||
->limit(20)
|
||||
->get(['entra_id', 'display_name'])
|
||||
->mapWithKeys(fn (EntraGroup $group): array => [
|
||||
(string) $group->entra_id => EntraGroupLabelResolver::formatLabel((string) $group->display_name, (string) $group->entra_id),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?string
|
||||
public static function groupLabelFromCache(?Tenant $tenant, ?string $groupId): ?string
|
||||
{
|
||||
if (! $tenant || blank($groupId)) {
|
||||
return $groupId;
|
||||
}
|
||||
|
||||
$token = static::delegatedToken($tenant);
|
||||
$resolver = app(EntraGroupLabelResolver::class);
|
||||
|
||||
if (! $token) {
|
||||
return $groupId;
|
||||
}
|
||||
return $resolver->resolveOne($tenant, $groupId);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = app(GraphClientInterface::class)->request(
|
||||
'GET',
|
||||
'groups/'.$groupId,
|
||||
[] + $tenant->graphOptions() + [
|
||||
'access_token' => $token,
|
||||
]
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return $groupId;
|
||||
}
|
||||
public static function syncRoleDefinitionsAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('sync_role_definitions')
|
||||
->label('Sync now')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (?Tenant $record): bool => $record instanceof Tenant && $record->isActive())
|
||||
->action(function (Tenant $record, RoleDefinitionsSyncService $syncService): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if ($response->failed()) {
|
||||
return $groupId;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return EntraGroupLabelResolver::formatLabel(
|
||||
$response->data['displayName'] ?? null,
|
||||
$response->data['id'] ?? $groupId
|
||||
);
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$opRun = $syncService->startManualSync($record, $user);
|
||||
|
||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Role definitions sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast('directory_role_definitions.sync')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,14 @@ public function __invoke(Request $request): RedirectResponse
|
||||
|
||||
app(WorkspaceContext::class)->clearLastTenantId($request);
|
||||
|
||||
return redirect()->to('/admin/operations');
|
||||
$previousUrl = url()->previous();
|
||||
|
||||
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
|
||||
|
||||
if ($previousHost !== null && $previousHost !== $request->getHost()) {
|
||||
return redirect()->to('/admin/operations');
|
||||
}
|
||||
|
||||
return redirect()->to((string) $previousUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,10 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
||||
|
||||
if (! $hasAnyActiveMembership && $this->isOperateHubPath($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $hasAnyActiveMembership && str_starts_with($path, '/admin/tenants')) {
|
||||
abort(404);
|
||||
}
|
||||
@ -101,4 +105,13 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
|
||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isOperateHubPath(string $path): bool
|
||||
{
|
||||
return in_array($path, [
|
||||
'/admin/operations',
|
||||
'/admin/alerts',
|
||||
'/admin/audit-log',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,7 +46,7 @@ public function handle(PolicySyncService $service, OperationRunService $operatio
|
||||
{
|
||||
$graph = app(GraphClientInterface::class);
|
||||
|
||||
if (! config('graph.enabled') || $graph instanceof NullGraphClient) {
|
||||
if ($graph instanceof NullGraphClient) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,11 @@
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
@ -37,6 +40,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
$panel = $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login(Login::class)
|
||||
@ -44,8 +48,6 @@ public function panel(Panel $panel): Panel
|
||||
ChooseWorkspace::registerRoutes($panel);
|
||||
ChooseTenant::registerRoutes($panel);
|
||||
NoAccess::registerRoutes($panel);
|
||||
|
||||
WorkspaceResource::registerRoutes($panel);
|
||||
})
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
@ -104,9 +106,15 @@ public function panel(Panel $panel): Panel
|
||||
)
|
||||
->resources([
|
||||
TenantResource::class,
|
||||
PolicyResource::class,
|
||||
ProviderConnectionResource::class,
|
||||
InventoryItemResource::class,
|
||||
WorkspaceResource::class,
|
||||
])
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
->pages([
|
||||
InventoryCoverage::class,
|
||||
TenantRequiredPermissions::class,
|
||||
])
|
||||
->widgets([
|
||||
@ -124,6 +132,7 @@ public function panel(Panel $panel): Panel
|
||||
SubstituteBindings::class,
|
||||
'ensure-correct-guard:web',
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
@ -29,7 +30,6 @@ class TenantPanelProvider extends PanelProvider
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
$panel = $panel
|
||||
->default()
|
||||
->id('tenant')
|
||||
->path('admin/t')
|
||||
->login(Login::class)
|
||||
@ -40,6 +40,23 @@ public function panel(Panel $panel): Panel
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Runs')
|
||||
->url(fn (): string => route('admin.operations.index'))
|
||||
->icon('heroicon-o-queue-list')
|
||||
->group('Monitoring')
|
||||
->sort(10),
|
||||
NavigationItem::make('Alerts')
|
||||
->url(fn (): string => route('admin.monitoring.alerts'))
|
||||
->icon('heroicon-o-bell-alert')
|
||||
->group('Monitoring')
|
||||
->sort(20),
|
||||
NavigationItem::make('Audit Log')
|
||||
->url(fn (): string => route('admin.monitoring.audit-log'))
|
||||
->icon('heroicon-o-clipboard-document-list')
|
||||
->group('Monitoring')
|
||||
->sort(30),
|
||||
])
|
||||
->renderHook(
|
||||
PanelsRenderHook::HEAD_END,
|
||||
fn () => view('filament.partials.livewire-intercept-shim')->render()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -4,18 +4,21 @@
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
@ -31,7 +34,9 @@ public function __construct(
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runs an inventory sync inline (no queue), enforcing locks/concurrency and creating an observable run record.
|
||||
* Runs an inventory sync immediately and persists a corresponding InventorySyncRun.
|
||||
*
|
||||
* This is primarily used in tests and for synchronous workflows.
|
||||
*
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
@ -41,30 +46,100 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
|
||||
$normalizedSelection = $computed['selection'];
|
||||
$selectionHash = $computed['selection_hash'];
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$operationRun = OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()),
|
||||
'context' => $normalizedSelection,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$run = InventorySyncRun::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $normalizedSelection,
|
||||
'status' => InventorySyncRun::STATUS_RUNNING,
|
||||
'had_errors' => false,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection);
|
||||
|
||||
$status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED);
|
||||
$hadErrors = (bool) ($result['had_errors'] ?? true);
|
||||
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null;
|
||||
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
|
||||
|
||||
$run->update([
|
||||
'status' => $status,
|
||||
'had_errors' => $hadErrors,
|
||||
'error_codes' => $errorCodes,
|
||||
'error_context' => $errorContext,
|
||||
'items_observed_count' => (int) ($result['items_observed_count'] ?? 0),
|
||||
'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0),
|
||||
'errors_count' => (int) ($result['errors_count'] ?? 0),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$policyTypes = $normalizedSelection['policy_types'] ?? [];
|
||||
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
|
||||
|
||||
$operationOutcome = match ($status) {
|
||||
'success' => OperationRunOutcome::Succeeded->value,
|
||||
'partial' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'skipped' => OperationRunOutcome::Blocked->value,
|
||||
default => OperationRunOutcome::Failed->value,
|
||||
};
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => $operationOutcome,
|
||||
'summary_counts' => [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => $status === 'success' ? count($policyTypes) : max(0, count($policyTypes) - (int) ($result['errors_count'] ?? 0)),
|
||||
'failed' => (int) ($result['errors_count'] ?? 0),
|
||||
'items' => (int) ($result['items_observed_count'] ?? 0),
|
||||
'updated' => (int) ($result['items_upserted_count'] ?? 0),
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
return $run->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an inventory sync (inline), enforcing locks/concurrency.
|
||||
*
|
||||
* This method MUST NOT create or update InventorySyncRun rows; OperationRun is canonical.
|
||||
*
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
||||
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
||||
*/
|
||||
public function executeSelection(OperationRun $operationRun, Tenant $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed = null): array
|
||||
{
|
||||
$computed = $this->normalizeAndHashSelection($selectionPayload);
|
||||
$normalizedSelection = $computed['selection'];
|
||||
$selectionHash = $computed['selection_hash'];
|
||||
|
||||
$globalSlot = $this->concurrencyLimiter->acquireGlobalSlot();
|
||||
if (! $globalSlot instanceof Lock) {
|
||||
return $this->createSkippedRun(
|
||||
tenant: $tenant,
|
||||
selectionHash: $selectionHash,
|
||||
selectionPayload: $normalizedSelection,
|
||||
now: $now,
|
||||
errorCode: 'concurrency_limit_global',
|
||||
);
|
||||
return $this->skippedResult('concurrency_limit_global');
|
||||
}
|
||||
|
||||
$tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id);
|
||||
if (! $tenantSlot instanceof Lock) {
|
||||
$globalSlot->release();
|
||||
|
||||
return $this->createSkippedRun(
|
||||
tenant: $tenant,
|
||||
selectionHash: $selectionHash,
|
||||
selectionPayload: $normalizedSelection,
|
||||
now: $now,
|
||||
errorCode: 'concurrency_limit_tenant',
|
||||
);
|
||||
return $this->skippedResult('concurrency_limit_tenant');
|
||||
}
|
||||
|
||||
$selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900);
|
||||
@ -72,33 +147,11 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
|
||||
$tenantSlot->release();
|
||||
$globalSlot->release();
|
||||
|
||||
return $this->createSkippedRun(
|
||||
tenant: $tenant,
|
||||
selectionHash: $selectionHash,
|
||||
selectionPayload: $normalizedSelection,
|
||||
now: $now,
|
||||
errorCode: 'lock_contended',
|
||||
);
|
||||
return $this->skippedResult('lock_contended');
|
||||
}
|
||||
|
||||
$run = InventorySyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $normalizedSelection,
|
||||
'status' => InventorySyncRun::STATUS_RUNNING,
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => null,
|
||||
'started_at' => $now,
|
||||
'finished_at' => null,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
]);
|
||||
|
||||
try {
|
||||
return $this->executeRun($run, $tenant, $normalizedSelection);
|
||||
return $this->executeSelectionUnderLock($operationRun, $tenant, $normalizedSelection, $onPolicyTypeProcessed);
|
||||
} finally {
|
||||
$selectionLock->release();
|
||||
$tenantSlot->release();
|
||||
@ -135,110 +188,12 @@ public function normalizeAndHashSelection(array $selectionPayload): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pending run record attributed to the initiating user so the run remains observable even if queue workers are down.
|
||||
*
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
public function createPendingRunForUser(Tenant $tenant, User $user, array $selectionPayload): InventorySyncRun
|
||||
{
|
||||
$computed = $this->normalizeAndHashSelection($selectionPayload);
|
||||
|
||||
return InventorySyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
'selection_payload' => $computed['selection'],
|
||||
'status' => InventorySyncRun::STATUS_PENDING,
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an existing pending run under locks/concurrency, updating that run to running/skipped/terminal.
|
||||
*/
|
||||
/**
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
||||
*/
|
||||
public function executePendingRun(InventorySyncRun $run, Tenant $tenant, ?callable $onPolicyTypeProcessed = null): InventorySyncRun
|
||||
{
|
||||
$computed = $this->normalizeAndHashSelection($run->selection_payload ?? []);
|
||||
$normalizedSelection = $computed['selection'];
|
||||
$selectionHash = $computed['selection_hash'];
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
|
||||
$run->update([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $normalizedSelection,
|
||||
'status' => InventorySyncRun::STATUS_RUNNING,
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => null,
|
||||
'started_at' => $now,
|
||||
'finished_at' => null,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
]);
|
||||
|
||||
$globalSlot = $this->concurrencyLimiter->acquireGlobalSlot();
|
||||
if (! $globalSlot instanceof Lock) {
|
||||
return $this->markExistingRunSkipped(
|
||||
run: $run,
|
||||
now: $now,
|
||||
errorCode: 'concurrency_limit_global',
|
||||
);
|
||||
}
|
||||
|
||||
$tenantSlot = $this->concurrencyLimiter->acquireTenantSlot((int) $tenant->id);
|
||||
if (! $tenantSlot instanceof Lock) {
|
||||
$globalSlot->release();
|
||||
|
||||
return $this->markExistingRunSkipped(
|
||||
run: $run,
|
||||
now: $now,
|
||||
errorCode: 'concurrency_limit_tenant',
|
||||
);
|
||||
}
|
||||
|
||||
$selectionLock = Cache::lock($this->selectionLockKey($tenant, $selectionHash), 900);
|
||||
if (! $selectionLock->get()) {
|
||||
$tenantSlot->release();
|
||||
$globalSlot->release();
|
||||
|
||||
return $this->markExistingRunSkipped(
|
||||
run: $run,
|
||||
now: $now,
|
||||
errorCode: 'lock_contended',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->executeRun($run, $tenant, $normalizedSelection, $onPolicyTypeProcessed);
|
||||
} finally {
|
||||
$selectionLock->release();
|
||||
$tenantSlot->release();
|
||||
$globalSlot->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
|
||||
*/
|
||||
/**
|
||||
* @param array{policy_types: list<string>, categories: list<string>, include_foundations: bool, include_dependencies: bool} $normalizedSelection
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
||||
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
||||
*/
|
||||
private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): InventorySyncRun
|
||||
private function executeSelectionUnderLock(OperationRun $operationRun, Tenant $tenant, array $normalizedSelection, ?callable $onPolicyTypeProcessed = null): array
|
||||
{
|
||||
$observed = 0;
|
||||
$upserted = 0;
|
||||
@ -349,7 +304,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
'platform' => $typeConfig['platform'] ?? null,
|
||||
'meta_jsonb' => $meta,
|
||||
'last_seen_at' => now(),
|
||||
'last_seen_run_id' => $run->getKey(),
|
||||
'last_seen_operation_run_id' => (int) $operationRun->getKey(),
|
||||
]
|
||||
);
|
||||
|
||||
@ -368,10 +323,8 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
|
||||
}
|
||||
|
||||
$status = $hadErrors ? InventorySyncRun::STATUS_PARTIAL : InventorySyncRun::STATUS_SUCCESS;
|
||||
|
||||
$run->update([
|
||||
'status' => $status,
|
||||
return [
|
||||
'status' => $hadErrors ? 'partial' : 'success',
|
||||
'had_errors' => $hadErrors,
|
||||
'error_codes' => array_values(array_unique($errorCodes)),
|
||||
'error_context' => [
|
||||
@ -380,29 +333,39 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
'items_observed_count' => $observed,
|
||||
'items_upserted_count' => $upserted,
|
||||
'errors_count' => $errors,
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
return $run->refresh();
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
$errorContext = $this->safeErrorContext($throwable);
|
||||
$errorContext['warnings'] = array_values($warnings);
|
||||
|
||||
$run->update([
|
||||
'status' => InventorySyncRun::STATUS_FAILED,
|
||||
return [
|
||||
'status' => 'failed',
|
||||
'had_errors' => true,
|
||||
'error_codes' => ['unexpected_exception'],
|
||||
'error_context' => $errorContext,
|
||||
'items_observed_count' => $observed,
|
||||
'items_upserted_count' => $upserted,
|
||||
'errors_count' => $errors + 1,
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
return $run->refresh();
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, had_errors: bool, error_codes: list<string>, error_context: array<string, mixed>|null, items_observed_count: int, items_upserted_count: int, errors_count: int}
|
||||
*/
|
||||
private function skippedResult(string $errorCode): array
|
||||
{
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'had_errors' => true,
|
||||
'error_codes' => [$errorCode],
|
||||
'error_context' => null,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, array $policyData): bool
|
||||
{
|
||||
$configurationPolicyTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
|
||||
@ -556,50 +519,6 @@ private function selectionLockKey(Tenant $tenant, string $selectionHash): string
|
||||
return sprintf('inventory_sync:tenant:%s:selection:%s', (string) $tenant->getKey(), $selectionHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
private function createSkippedRun(
|
||||
Tenant $tenant,
|
||||
string $selectionHash,
|
||||
array $selectionPayload,
|
||||
CarbonImmutable $now,
|
||||
string $errorCode,
|
||||
): InventorySyncRun {
|
||||
return InventorySyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $selectionPayload,
|
||||
'status' => InventorySyncRun::STATUS_SKIPPED,
|
||||
'had_errors' => true,
|
||||
'error_codes' => [$errorCode],
|
||||
'error_context' => null,
|
||||
'started_at' => $now,
|
||||
'finished_at' => $now,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function markExistingRunSkipped(InventorySyncRun $run, CarbonImmutable $now, string $errorCode): InventorySyncRun
|
||||
{
|
||||
$run->update([
|
||||
'status' => InventorySyncRun::STATUS_SKIPPED,
|
||||
'had_errors' => true,
|
||||
'error_codes' => [$errorCode],
|
||||
'error_context' => null,
|
||||
'started_at' => $run->started_at ?? $now,
|
||||
'finished_at' => $now,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
]);
|
||||
|
||||
return $run->refresh();
|
||||
}
|
||||
|
||||
private function mapGraphFailureToErrorCode(GraphResponse $response): string
|
||||
{
|
||||
$status = (int) ($response->status ?? 0);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -34,6 +34,12 @@ public function handle(Request $request, Closure $next): Response
|
||||
$existingTenant = Filament::getTenant();
|
||||
if ($existingTenant instanceof Tenant && $workspaceId !== null && (int) $existingTenant->workspace_id !== (int) $workspaceId) {
|
||||
Filament::setTenant(null, true);
|
||||
$existingTenant = null;
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
if ($existingTenant instanceof Tenant && $user instanceof User && ! $user->canAccessTenant($existingTenant)) {
|
||||
Filament::setTenant(null, true);
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
@ -59,7 +65,14 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenantParameter = null;
|
||||
if ($request->route()?->hasParameter('tenant')) {
|
||||
$tenantParameter = $request->route()->parameter('tenant');
|
||||
} elseif (filled($request->query('tenant'))) {
|
||||
$tenantParameter = $request->query('tenant');
|
||||
}
|
||||
|
||||
if ($tenantParameter !== null) {
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
@ -70,12 +83,9 @@ public function handle(Request $request, Closure $next): Response
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $panel->hasTenancy()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenantParameter = $request->route()->parameter('tenant');
|
||||
$tenant = $panel->getTenant($tenantParameter);
|
||||
$tenant = $tenantParameter instanceof Tenant
|
||||
? $tenantParameter
|
||||
: Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -129,8 +139,6 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
|
||||
131
app/Support/OperateHub/OperateHubShell.php
Normal file
131
app/Support/OperateHub/OperateHubShell.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OperateHub;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class OperateHubShell
|
||||
{
|
||||
public function __construct(
|
||||
private WorkspaceContext $workspaceContext,
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
) {}
|
||||
|
||||
public function scopeLabel(?Request $request = null): string
|
||||
{
|
||||
$activeTenant = $this->activeEntitledTenant($request);
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return 'Scope: Tenant — '.$activeTenant->name;
|
||||
}
|
||||
|
||||
return 'Scope: Workspace — all tenants';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, url: string}|null
|
||||
*/
|
||||
public function returnAffordance(?Request $request = null): ?array
|
||||
{
|
||||
$activeTenant = $this->activeEntitledTenant($request);
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return [
|
||||
'label' => 'Back to '.$activeTenant->name,
|
||||
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $activeTenant),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
public function headerActions(
|
||||
string $scopeActionName = 'operate_hub_scope',
|
||||
string $returnActionName = 'operate_hub_return',
|
||||
?Request $request = null,
|
||||
): array {
|
||||
$actions = [
|
||||
Action::make($scopeActionName)
|
||||
->label($this->scopeLabel($request))
|
||||
->color('gray')
|
||||
->disabled(),
|
||||
];
|
||||
|
||||
$returnAffordance = $this->returnAffordance($request);
|
||||
|
||||
if (is_array($returnAffordance)) {
|
||||
$actions[] = Action::make($returnActionName)
|
||||
->label($returnAffordance['label'])
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($returnAffordance['url']);
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function activeEntitledTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
return $this->resolveActiveTenant($request);
|
||||
}
|
||||
|
||||
private function resolveActiveTenant(?Request $request = null): ?Tenant
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof Tenant && $this->isEntitled($tenant, $request)) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$rememberedTenantId = $this->workspaceContext->lastTenantId($request);
|
||||
|
||||
if ($rememberedTenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rememberedTenant = Tenant::query()->whereKey($rememberedTenantId)->first();
|
||||
|
||||
if (! $rememberedTenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->isEntitled($rememberedTenant, $request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rememberedTenant;
|
||||
}
|
||||
|
||||
private function isEntitled(Tenant $tenant, ?Request $request = null): bool
|
||||
{
|
||||
if (! $tenant->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
|
||||
|
||||
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->capabilityResolver->isMember($user, $tenant);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
32
app/Support/Operations/OperationRunCapabilityResolver.php
Normal file
32
app/Support/Operations/OperationRunCapabilityResolver.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Support\Auth\Capabilities;
|
||||
|
||||
final class OperationRunCapabilityResolver
|
||||
{
|
||||
public function requiredCapabilityForType(string $operationType): ?string
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
|
||||
if ($operationType === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($operationType) {
|
||||
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'directory_groups.sync' => Capabilities::TENANT_SYNC,
|
||||
'backup_schedule.run_now', 'backup_schedule.retry', 'backup_schedule.scheduled' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
|
||||
// Viewing verification reports should be possible for readonly members.
|
||||
// Starting verification is separately guarded by the verification service.
|
||||
'provider.connection.check' => Capabilities::PROVIDER_VIEW,
|
||||
|
||||
// Keep legacy / unknown types viewable by membership+entitlement only.
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -18,7 +18,7 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
<ini name="memory_limit" value="2048M"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:z63PQuXp3rUOQ0L4o8xp76xeakrn5X3owja1qFX3ccY="/>
|
||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Alerts is reserved for future work.
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Alerts is reserved for future work.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Audit Log is reserved for future work.
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Audit Log is reserved for future work.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
@ -40,9 +41,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
$currentTenant = Filament::getTenant();
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$currentTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
||||
|
||||
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
|
||||
|
||||
$path = '/'.ltrim(request()->path(), '/');
|
||||
$route = request()->route();
|
||||
$routeName = (string) ($route?->getName() ?? '');
|
||||
@ -65,7 +69,7 @@
|
||||
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
|
||||
|
||||
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
|
||||
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@ -83,7 +87,7 @@
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
<a
|
||||
href="{{ ChooseWorkspace::getUrl(panel: 'admin') }}"
|
||||
href="{{ route('filament.admin.resources.workspaces.index') }}"
|
||||
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Switch workspace
|
||||
@ -174,7 +178,7 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
|
||||
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
|
||||
@csrf
|
||||
|
||||
<x-filament::button color="gray" size="sm" outlined>
|
||||
<x-filament::button type="submit" color="gray" size="sm" outlined>
|
||||
Clear tenant context
|
||||
</x-filament::button>
|
||||
</form>
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
Route::get('/admin/consent/start', TenantOnboardingController::class)
|
||||
->name('admin.consent.start');
|
||||
// Panel root override: keep the app's workspace-first flow.
|
||||
|
||||
// Avoid Filament's tenancy root redirect which otherwise sends users into legacy flows.
|
||||
// when no default tenant can be resolved.
|
||||
Route::middleware([
|
||||
@ -143,6 +143,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||
->name('admin.operations.index');
|
||||
@ -155,6 +156,20 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/t/{tenant:external_id}/operations', fn () => redirect()->route('admin.operations.index'))
|
||||
->name('admin.operations.legacy-tenant-index');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/alerts', \App\Filament\Pages\Monitoring\Alerts::class)
|
||||
->name('admin.monitoring.alerts');
|
||||
@ -167,6 +182,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/audit-log', \App\Filament\Pages\Monitoring\AuditLog::class)
|
||||
->name('admin.monitoring.audit-log');
|
||||
@ -179,6 +195,7 @@
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
'ensure-filament-tenant-selected',
|
||||
])
|
||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||
->name('admin.operations.view');
|
||||
|
||||
34
specs/085-tenant-operate-hub/checklists/requirements.md
Normal file
34
specs/085-tenant-operate-hub/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-09
|
||||
**Feature**: [specs/085-tenant-operate-hub/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Dependencies/assumptions used: canonical monitoring surfaces exist at `/admin/operations`, `/admin/operations/{run}`, `/admin/alerts`, `/admin/audit-log`; tenant plane exists at `/admin/t/{tenant}`; tenant context can be active or absent; authorization semantics remain consistent (deny-as-not-found vs forbidden); Monitoring views are view-only render surfaces that must not initiate outbound calls on render.
|
||||
76
specs/085-tenant-operate-hub/contracts/openapi.yaml
Normal file
76
specs/085-tenant-operate-hub/contracts/openapi.yaml
Normal file
@ -0,0 +1,76 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Tenant Operate Hub / Central Monitoring (UI Route Contracts)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Internal documentation of canonical central Monitoring surfaces.
|
||||
|
||||
These are Filament page routes (not a public API). The contract is used to
|
||||
pin down URL shapes and security semantics (404 vs 403) for acceptance.
|
||||
|
||||
servers:
|
||||
- url: /
|
||||
|
||||
paths:
|
||||
/admin/operations:
|
||||
get:
|
||||
summary: Central Monitoring - Operations index
|
||||
description: |
|
||||
Canonical operations list. Must render without outbound calls.
|
||||
|
||||
Scope semantics:
|
||||
- If tenant context is active AND entitled: page is tenant-filtered by default and shows tenant-scoped header/CTAs.
|
||||
- If tenant context is absent: page is workspace-wide.
|
||||
- If tenant context is active but not entitled: page behaves workspace-wide and must not reveal tenant identity.
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'302':
|
||||
description: Redirect to choose workspace if none selected
|
||||
'403':
|
||||
description: Authenticated but forbidden (capability denial after membership)
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
/admin/clear-tenant-context:
|
||||
post:
|
||||
summary: Exit tenant context (Monitoring)
|
||||
description: |
|
||||
Clears the active tenant context for the current session.
|
||||
Used by “Show all tenants” on central Monitoring pages.
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect back to a canonical Monitoring page
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Central Monitoring - Run detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: run
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'403':
|
||||
description: Authenticated but forbidden (policy denies view)
|
||||
'404':
|
||||
description: Deny-as-not-found when run is outside entitled scope
|
||||
/admin/alerts:
|
||||
get:
|
||||
summary: Central Monitoring - Alerts
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
/admin/audit-log:
|
||||
get:
|
||||
summary: Central Monitoring - Audit log
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'404':
|
||||
description: Deny-as-not-found when not entitled to workspace scope
|
||||
63
specs/085-tenant-operate-hub/data-model.md
Normal file
63
specs/085-tenant-operate-hub/data-model.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Data Model: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Date**: 2026-02-09
|
||||
**Branch**: 085-tenant-operate-hub
|
||||
|
||||
This feature is primarily UI/IA + navigation behavior. It introduces **no new database tables**.
|
||||
|
||||
## Entities (existing)
|
||||
|
||||
### Workspace
|
||||
- Purpose: primary isolation boundary and monitoring scope.
|
||||
- Source of truth: `workspaces` + membership.
|
||||
|
||||
### Tenant
|
||||
- Purpose: managed environment; tenant-plane routes live under `/admin/t/{tenant}`.
|
||||
- Access: entitlement-based.
|
||||
|
||||
### OperationRun
|
||||
- Purpose: canonical run tracking for all operational workflows.
|
||||
- Surface:
|
||||
- Index: `/admin/operations`
|
||||
- Detail: `/admin/operations/{run}`
|
||||
|
||||
### Alert (placeholder)
|
||||
- Purpose: future operator signals.
|
||||
- Surface: `/admin/alerts`.
|
||||
|
||||
### Audit Event / Audit Log (placeholder)
|
||||
- Purpose: immutable record of sensitive actions.
|
||||
- Surface: `/admin/audit-log`.
|
||||
|
||||
## Session / Context State (existing)
|
||||
|
||||
### Workspace context
|
||||
- Key: `WorkspaceContext::SESSION_KEY` (`current_workspace_id`)
|
||||
- Meaning: selected workspace id for the current session.
|
||||
|
||||
### Last tenant per workspace (session-based)
|
||||
- Key: `WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY` (`workspace_last_tenant_ids`)
|
||||
- Shape:
|
||||
- Map keyed by workspace id string → tenant id int
|
||||
- Example:
|
||||
- `{"12": 345}`
|
||||
- APIs:
|
||||
- `WorkspaceContext::rememberLastTenantId(int $workspaceId, int $tenantId, Request $request)`
|
||||
- `WorkspaceContext::lastTenantId(Request $request): ?int`
|
||||
- `WorkspaceContext::clearLastTenantId(Request $request)`
|
||||
|
||||
### Filament tenant context
|
||||
- Source: `Filament::getTenant()` (may persist across panels depending on Filament tenancy configuration).
|
||||
- Used to determine “active tenant context” for Monitoring UX.
|
||||
|
||||
**Spec 085 scope note**: Monitoring may use session-based last-tenant memory as a tenant-context signal when Filament tenant context is absent (e.g., when navigating from the tenant panel into central Monitoring). It must not be inferred from arbitrary deep links.
|
||||
|
||||
### Stale tenant context behavior (no entitlement)
|
||||
|
||||
- If tenant context is active but the user is not entitled, Monitoring pages behave as workspace-wide views and must not display tenant identity.
|
||||
|
||||
## Validation / Rules
|
||||
|
||||
- Tenant context MUST NOT be implicitly mutated by canonical monitoring pages.
|
||||
- Deny-as-not-found (404) applies when the actor is not entitled to tenant/workspace scope.
|
||||
- Forbidden (403) applies only after membership is established but capability is missing.
|
||||
123
specs/085-tenant-operate-hub/plan.md
Normal file
123
specs/085-tenant-operate-hub/plan.md
Normal file
@ -0,0 +1,123 @@
|
||||
# Implementation Plan: Spec 085 — Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Branch**: `085-tenant-operate-hub` | **Date**: 2026-02-09 | **Spec**: specs/085-tenant-operate-hub/spec.md
|
||||
**Input**: specs/085-tenant-operate-hub/spec.md
|
||||
|
||||
## Summary
|
||||
|
||||
Make central Monitoring pages feel context-aware when entered from the tenant panel, without introducing tenant-scoped monitoring routes and without implicit tenant switching.
|
||||
|
||||
Key outcomes:
|
||||
- Tenant panel sidebar replaces “Operations” with a “Monitoring” group of shortcuts (Runs/Alerts/Audit Log) that open central Monitoring surfaces.
|
||||
- `/admin/operations` becomes context-aware when tenant context is active: scope label shows tenant, table defaults to tenant filter, and header includes `Back to <tenant>` + `Show all tenants` (clears tenant context).
|
||||
- `/admin/operations/{run}` adds deterministic “back” affordances: tenant back link when tenant context is active + entitled, plus secondary `Show all operations`; otherwise `Back to Operations`.
|
||||
- Monitoring page render remains DB-only: no outbound calls and no background work triggered by view-only GET.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL (Sail)
|
||||
**Testing**: Pest v4 (`vendor/bin/sail artisan test`)
|
||||
**Target Platform**: Web (enterprise SaaS admin UI)
|
||||
**Project Type**: Laravel monolith (Filament panels + Livewire)
|
||||
**Performance Goals**: Monitoring page renders are DB-only, low-latency, and avoid N+1 regressions
|
||||
**Constraints**:
|
||||
- Canonical monitoring URLs must not change (`/admin/operations`, `/admin/operations/{run}`)
|
||||
- No new tenant-scoped monitoring routes
|
||||
- No implicit tenant switching (tenant selection remains explicit POST)
|
||||
- Deny-as-not-found (404) for non-members/non-entitled; 403 only after membership established
|
||||
- No outbound calls on render; no render-time side effects (jobs/notifications)
|
||||
**Scale/Scope**: Small-to-medium UX change touching tenant navigation + 2 monitoring pages + Pest tests
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first / snapshots: Not applicable (read-only monitoring UX).
|
||||
- Read/write separation: PASS (changes are navigation + view-only rendering; the only mutation is explicit “clear tenant context” action).
|
||||
- Graph contract path: PASS (no new Graph calls).
|
||||
- Deterministic capabilities: PASS (uses existing membership/entitlement checks; no new capability strings).
|
||||
- Workspace isolation: PASS (non-member workspace access remains 404).
|
||||
- Tenant isolation: PASS (no tenant identity leaks when not entitled; tenant pages remain 404).
|
||||
- Run observability: PASS (view-only pages do not start operations; Monitoring stays DB-only).
|
||||
- RBAC-UX destructive confirmation: PASS (no destructive actions added).
|
||||
- Filament UI Action Surface Contract: PASS (we’re modifying Pages; we will provide explicit header actions and table/default filter behavior; no new list resources are added).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/085-tenant-operate-hub/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ ├── Resources/
|
||||
│ └── ...
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ └── Middleware/
|
||||
├── Providers/
|
||||
└── Support/
|
||||
|
||||
resources/views/
|
||||
tests/Feature/
|
||||
routes/web.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith with Filament panels. Changes will be localized to existing panel providers, page classes, shared helpers (if present), and feature tests.
|
||||
|
||||
## Phase Plan
|
||||
|
||||
### Phase 0 — Research (complete)
|
||||
|
||||
Outputs:
|
||||
- specs/085-tenant-operate-hub/research.md (decisions + alternatives)
|
||||
|
||||
### Phase 1 — Design & Contracts (complete)
|
||||
|
||||
Outputs:
|
||||
- specs/085-tenant-operate-hub/data-model.md (no schema changes; context rules)
|
||||
- specs/085-tenant-operate-hub/contracts/openapi.yaml (canonical routes + clear-tenant-context POST)
|
||||
- specs/085-tenant-operate-hub/quickstart.md (manual verification)
|
||||
|
||||
### Phase 2 — Implementation Planning (next)
|
||||
|
||||
Implementation will be executed as small, test-driven slices:
|
||||
|
||||
1) Tenant panel navigation IA
|
||||
- Replace tenant-panel “Operations” entry with “Monitoring” group.
|
||||
- Add 3 shortcut items (Runs/Alerts/Audit Log).
|
||||
- Verify no new tenant-scoped monitoring routes are introduced.
|
||||
|
||||
2) Operations index context-aware header + default scope
|
||||
- If tenant context active + entitled: show scope `Tenant — <name>`, default table filter = tenant, CTAs `Back to <tenant>` and `Show all tenants`.
|
||||
- If no tenant context: show scope `Workspace — all tenants`.
|
||||
- If tenant context active but not entitled: behave workspace-wide (no tenant name, no back-to-tenant).
|
||||
|
||||
3) Run detail deterministic back affordances
|
||||
- If tenant context active + entitled: `← Back to <tenant>` plus secondary `Show all operations`.
|
||||
- Else: `Back to Operations`.
|
||||
|
||||
4) Pest tests (security + UX)
|
||||
- OperationsIndexScopeTest (tenant vs workspace scope labels + CTAs)
|
||||
- RunDetailBackToTenantTest (tenant-context vs no-context actions)
|
||||
- Deny-as-not-found coverage for non-entitled tenant pages
|
||||
- “No outbound calls on render” guard for `/admin/operations` and `/admin/operations/{run}`
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations expected.
|
||||
39
specs/085-tenant-operate-hub/quickstart.md
Normal file
39
specs/085-tenant-operate-hub/quickstart.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Quickstart: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Date**: 2026-02-09
|
||||
**Branch**: 085-tenant-operate-hub
|
||||
|
||||
## Local setup
|
||||
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Install deps (if needed): `vendor/bin/sail composer install`
|
||||
|
||||
## Manual verification steps (happy path)
|
||||
|
||||
1. Sign in.
|
||||
2. Select a workspace (via the context bar).
|
||||
3. Enter a tenant context (e.g., go to `/admin/t/{tenant}` via the tenant panel).
|
||||
4. In the tenant panel sidebar, open the **Monitoring** group and click:
|
||||
- Runs → lands on `/admin/operations`
|
||||
5. Verify `/admin/operations` shows:
|
||||
- Header scope: `Scope: Tenant — <tenant name>`
|
||||
- CTAs: `Back to <tenant name>` and `Show all tenants`
|
||||
- The table default scope is tenant-filtered to the active tenant.
|
||||
6. Click `Show all tenants`.
|
||||
7. Verify you stay on `/admin/operations` and scope becomes `Scope: Workspace — all tenants`.
|
||||
8. Open an operation run detail at `/admin/operations/{run}`.
|
||||
9. Verify the header shows:
|
||||
- `← Back to <tenant name>`
|
||||
- secondary `Show all operations` → `/admin/operations`
|
||||
10. Click `← Back to <tenant name>` and verify it lands on the tenant dashboard (`/admin/t/{tenant}`).
|
||||
|
||||
## Negative verification (security)
|
||||
|
||||
- With tenant context active, revoke tenant entitlement for the test user.
|
||||
- Reload `/admin/operations`.
|
||||
- Verify scope is workspace-wide and no tenant name / “Back to tenant” affordance appears.
|
||||
- Request the tenant dashboard URL directly (`/admin/t/{tenant}`) and verify deny-as-not-found.
|
||||
|
||||
## Test commands (to be added in Phase 2)
|
||||
|
||||
- Targeted suite: `vendor/bin/sail artisan test --compact --filter=OperationsIndexScopeTest`
|
||||
70
specs/085-tenant-operate-hub/research.md
Normal file
70
specs/085-tenant-operate-hub/research.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Research: Tenant Operate Hub / Tenant Overview IA (Spec 085)
|
||||
|
||||
**Date**: 2026-02-09
|
||||
**Branch**: 085-tenant-operate-hub
|
||||
**Spec**: specs/085-tenant-operate-hub/spec.md
|
||||
|
||||
This research consolidates repo evidence + the final clarification decisions, so the implementation plan and tests can be deterministic.
|
||||
|
||||
## Repository Evidence (high-signal)
|
||||
|
||||
- Canonical monitoring pages already exist in the Admin panel:
|
||||
- Operations index: app/Filament/Pages/Monitoring/Operations.php
|
||||
- Run detail (tenantless viewer): app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
- Alerts: app/Filament/Pages/Monitoring/Alerts.php
|
||||
- Audit log: app/Filament/Pages/Monitoring/AuditLog.php
|
||||
- Tenant selection + clear tenant context already exist (UI + route/controller):
|
||||
- Context bar partial: resources/views/filament/partials/context-bar.blade.php
|
||||
- Tenant select controller: app/Http/Controllers/SelectTenantController.php
|
||||
|
||||
## Decisions (resolved)
|
||||
|
||||
### Decision: Tenant context may use last-tenant memory for cross-panel flows
|
||||
- Decision: “Tenant context is active” on Monitoring pages is resolved from the active Filament tenant when present, otherwise from the remembered last-tenant id for the current workspace.
|
||||
- Rationale: Central Monitoring routes are canonical and tenantless, but users enter them from the tenant panel and expect consistent scoping.
|
||||
- Alternatives considered:
|
||||
- Query param `?tenant=`: rejected (would introduce an implicit switching vector).
|
||||
|
||||
### Decision: “Show all tenants” explicitly exits tenant context
|
||||
- Decision: The CTA “Show all tenants” clears tenant context (single meaning) and returns the user to workspace-wide Monitoring.
|
||||
- Rationale: Prevents the confusing state where tenant context is still active but filters are reset.
|
||||
- Alternatives considered:
|
||||
- Only reset table filter: rejected (context remains active and confuses scope semantics).
|
||||
|
||||
### Decision: Stale tenant context is handled without leaks
|
||||
- Decision: If tenant context is active but the user is no longer entitled to that tenant, Monitoring pages behave as workspace-wide:
|
||||
- Scope shows `Workspace — all tenants`
|
||||
- No tenant name is shown
|
||||
- No “Back to tenant” is rendered
|
||||
- Tenant pages remain deny-as-not-found
|
||||
- Rationale: Preserves deny-as-not-found and avoids tenant existence hints.
|
||||
- Alternatives considered:
|
||||
- 404 the Monitoring page: rejected (feels like being “thrown out”).
|
||||
- Auto-clear tenant context implicitly: rejected (implicit context mutation).
|
||||
|
||||
### Decision: Run detail shows an explicit tenant return + secondary escape hatch
|
||||
- Decision: When tenant context is active and entitled, run detail shows:
|
||||
- `← Back to <tenant name>` (tenant dashboard)
|
||||
- secondary `Show all operations` → `/admin/operations`
|
||||
- Rationale: “Back to tenant” is deterministic; the secondary link provides a canonical escape hatch.
|
||||
- Alternatives considered:
|
||||
- Only show `Back to tenant`: rejected by clarification (secondary escape hatch approved).
|
||||
|
||||
### Decision: Tenant panel navigation uses a “Monitoring” group with central shortcuts
|
||||
- Decision: Replace the tenant-panel “Operations” item with group “Monitoring” containing shortcuts:
|
||||
- Runs → `/admin/operations`
|
||||
- Alerts → `/admin/alerts`
|
||||
- Audit Log → `/admin/audit-log`
|
||||
- Rationale: Keep tenant sidebar labels minimal while still providing correct central Monitoring entry points, while preserving canonical URLs and avoiding tenant-scoped monitoring routes.
|
||||
- Alternatives considered:
|
||||
- New tenant-scoped monitoring routes: rejected (explicitly forbidden).
|
||||
|
||||
### Decision: “No outbound calls on render” is enforced by tests
|
||||
- Decision: Monitoring GET renders must not trigger outbound network calls or start background work as a side effect.
|
||||
- Rationale: Aligns with constitution (“Monitoring pages MUST be DB-only at render time”).
|
||||
- Alternatives considered:
|
||||
- Rely on convention: rejected; this needs regression protection.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None remaining for Phase 0. The spec clarifications cover all scope-affecting ambiguities.
|
||||
194
specs/085-tenant-operate-hub/spec.md
Normal file
194
specs/085-tenant-operate-hub/spec.md
Normal file
@ -0,0 +1,194 @@
|
||||
# Feature Specification: Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Feature Branch**: `085-tenant-operate-hub`
|
||||
**Created**: 2026-02-09
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Make central Monitoring surfaces feel context-aware when entered from a tenant, without changing canonical URLs, and without weakening deny-as-not-found security boundaries."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-09 (work order alignment)
|
||||
|
||||
- Q: What is the source of truth for “Back to tenant”? → A: The active entitled tenant context (Filament tenant if present, otherwise the remembered last-tenant id for the current workspace).
|
||||
- Q: Should “Back to last tenant” be implemented as a separate feature? → A: No; remembered tenant context is used only to preserve context when navigating from a tenant into central Monitoring.
|
||||
- Q: What does “Show all tenants” do? → A: It explicitly exits tenant context to return to workspace-wide monitoring (no mixed behavior with filter resets).
|
||||
- Q: How is Monitoring reached from tenant context? → A: Tenant navigation offers a “Monitoring” group with shortcuts that open central Monitoring surfaces.
|
||||
- Q: How should stale tenant context (tenant context active but user no longer entitled) behave on Monitoring pages? → A: Monitoring renders workspace-wide (no tenant name, no “Back to tenant”), preserving deny-as-not-found for tenant pages.
|
||||
- Q: Should run detail offer a secondary escape hatch when tenant context is active? → A: Yes — show a secondary “Show all operations” link to `/admin/operations`.
|
||||
- Q: How should tenant Monitoring shortcuts indicate “opens central monitoring”? → A: Keep labels minimal (no “↗ Central” suffix).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Monitoring feels context-aware (Priority: P1)
|
||||
|
||||
As an operator, when I open central Monitoring from within a tenant, I immediately understand:
|
||||
1) whether the Monitoring view is scoped to the current tenant or to all tenants, and
|
||||
2) how to get back to the tenant I came from.
|
||||
|
||||
**Why this priority**: This is the core usability and safety problem: monitoring and tenant work should not feel like different apps, but they must not blur security boundaries.
|
||||
|
||||
**Independent Test**: With a tenant context active, a test user can open the Operations index and a run detail, see an explicit scope indicator and a deterministic “Back to tenant”, and exit to workspace-wide monitoring intentionally.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is a member of a workspace and has access to at least one tenant, **When** they open central Monitoring (Operations), **Then** the page clearly shows whether tenant context is active.
|
||||
2. **Given** a tenant context is active, **When** the user navigates to a canonical monitoring detail page, **Then** the UI provides a single, clear “Back to tenant” affordance that returns to that tenant dashboard.
|
||||
3. **Given** a user is not entitled to the current tenant, **When** they try to access tenant-scoped pages via a direct link, **Then** they receive a not-found experience (deny-as-not-found), without any tenant existence hints.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Canonical URLs with explicit scope (Priority: P2)
|
||||
|
||||
As an operator, I can use canonical Monitoring URLs at all times. When tenant context is active, Monitoring views can be tenant-filtered by default, but they must not implicitly change tenant selection.
|
||||
|
||||
**Why this priority**: Avoids mistakes and misinterpretation of data by preventing silent scoping changes.
|
||||
|
||||
**Independent Test**: With a tenant selected, open monitoring index and detail views and verify the scope is consistent and clearly communicated.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant context is active, **When** the user opens the monitoring index, **Then** the default view is tenant-scoped (or clearly offers a one-click tenant scope), and the UI visibly indicates the scope.
|
||||
2. **Given** no tenant context is active, **When** the user opens monitoring, **Then** the view is workspace-wide and does not imply a tenant is selected.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Deep links are safe and recoverable (Priority: P3)
|
||||
|
||||
As an operator working inside a tenant, when I land on a canonical run detail via a deep link, I can safely return to the tenant if tenant context is still active and I am still entitled.
|
||||
|
||||
**Why this priority**: These workflows are frequent. Deep links are where users most often “lose” tenant context.
|
||||
|
||||
**Independent Test**: With a tenant context active, open a canonical run detail and verify the “Back to tenant” affordance is present and correct.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant context is active and the user is still entitled, **When** they open a canonical run detail, **Then** they see a “Back to tenant” affordance.
|
||||
2. **Given** tenant context is not active, **When** the user opens a canonical run detail, **Then** they see only a “Back to Operations” affordance.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- User has no workspace selected: Monitoring must not show cross-workspace data; user must select a workspace first.
|
||||
- User has workspace access but zero tenant access: Monitoring must still work in workspace-wide mode, without tenant selection.
|
||||
- User’s tenant access is revoked while they have a deep link open: subsequent tenant-scoped navigation must be deny-as-not-found.
|
||||
- User opens a bookmarked canonical run detail directly: the UI must provide a deterministic “Back” behavior without inventing tenant context.
|
||||
- Tenant context is active, but entitlement was revoked: Monitoring must not leak tenant identity; tenant return affordance must not appear (or must be safe).
|
||||
- Monitoring views must remain view-only render surfaces: rendering must not trigger outbound calls.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Target State (hard decision)
|
||||
|
||||
This spec adopts a single, deterministic interpretation:
|
||||
|
||||
- Monitoring URLs are canonical and do not change with tenant context.
|
||||
- Tenant context makes Monitoring *feel* scoped (scope indicators, default filters, and deterministic exits) without implicit tenant switching.
|
||||
|
||||
- Operations index: `/admin/operations`
|
||||
- Operations run detail: `/admin/operations/{run}`
|
||||
- Alerts: `/admin/alerts`
|
||||
- Audit log: `/admin/audit-log`
|
||||
|
||||
Tenant plane remains under `/admin/t/{tenant}` for tenant dashboards and workflows. Monitoring views are central, but when tenant context is active they become tenant-filtered by default and provide a deterministic “Back to tenant” affordance.
|
||||
|
||||
**Constitution alignment (required):** This feature is information architecture + navigation behavior. It MUST NOT introduce new outbound calls for monitoring pages. If it introduces or changes any write/change behavior (e.g., starting workflows), it MUST maintain existing safety gates (preview/confirmation/audit), tenant isolation, run observability, and tests.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature changes how users reach surfaces; it MUST preserve and test authorization semantics:
|
||||
- Non-member / not entitled to workspace scope OR tenant scope → deny-as-not-found (404 semantics)
|
||||
- Member but missing capability → forbidden (403 semantics)
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Authentication handshakes may perform synchronous outbound communication on auth endpoints. This MUST NOT be used for Monitoring pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If any status/severity/outcome badges are added or changed on hub pages, their meaning MUST be centralized and covered by tests.
|
||||
|
||||
**Constitution alignment (UI Action Surfaces):** If this feature adds/modifies any admin or tenant UI surfaces, the “UI Action Matrix” MUST be updated and action gating MUST remain consistent (confirmation for destructive-like actions; server-side authorization for mutations).
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-085-001**: Tenant navigation MUST offer a “Monitoring” group with shortcuts to central Monitoring surfaces:
|
||||
- Runs (Operations) → `/admin/operations`
|
||||
- Alerts → `/admin/alerts`
|
||||
- Audit Log → `/admin/audit-log`
|
||||
These shortcuts MUST NOT introduce new tenant-scoped monitoring URLs.
|
||||
|
||||
- **FR-085-002**: The Operations index (`/admin/operations`) MUST show a clear scope indicator in the page header:
|
||||
- `Scope: Workspace — all tenants` when no tenant context is active
|
||||
- `Scope: Tenant — <tenant name>` when tenant context is active
|
||||
- **FR-085-003**: Canonical Monitoring URLs MUST NOT implicitly change tenant context. Tenant context MAY influence default filters on Monitoring views.
|
||||
- **FR-085-004**: When tenant context is active on the Operations index, the default tenant filter MUST be set to the current tenant, and the UI MUST make this tenant scoping obvious.
|
||||
- **FR-085-005**: The Operations index MUST provide two explicit CTAs when tenant context is active:
|
||||
- `Show all tenants` (explicitly exits tenant context and returns to workspace-wide monitoring)
|
||||
- `Back to <tenant name>` (navigates to tenant dashboard)
|
||||
- **FR-085-006**: The run detail (`/admin/operations/{run}`) MUST provide a deterministic “Back” affordance:
|
||||
- If tenant context is active AND the user is still entitled: `← Back to <tenant name>` (tenant dashboard) AND a secondary `Show all operations` (to `/admin/operations`)
|
||||
- Else: `Back to Operations` (Operations index)
|
||||
- **FR-085-007**: “Back to tenant” MUST be based only on active entitled tenant context (Filament tenant, or remembered tenant for the current workspace). It MUST NOT be inferred from arbitrary deep-link parameters.
|
||||
- **FR-085-008**: Deny-as-not-found MUST remain: users not entitled to workspace or tenant scope MUST receive a not-found experience (404 semantics), with no tenant existence hints.
|
||||
- **FR-085-009**: Monitoring views (`/admin/operations` and `/admin/operations/{run}`) MUST remain view-only render surfaces and MUST NOT trigger outbound calls during render.
|
||||
- **FR-085-010**: If tenant context is active but the user is not entitled to that tenant, Monitoring pages MUST behave as workspace-wide views:
|
||||
- Scope indicator MUST show `Scope: Workspace — all tenants`
|
||||
- No tenant name MUST be displayed
|
||||
- No “Back to <tenant>” affordance MUST be rendered
|
||||
- Direct access to tenant pages MUST continue to be deny-as-not-found
|
||||
|
||||
## UI Action Matrix *(mandatory when UI surfaces are changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Central Operations (index) | `/admin/operations` | Scope indicator; `Show all tenants` (when tenant context active); deterministic back affordance | Linked run rows to open run detail | N/A | N/A | N/A | N/A | N/A | No | Must not implicitly change tenant context; default tenant filter when tenant context active |
|
||||
| Central Operations (run detail) | `/admin/operations/{run}` | `← Back to <tenant>` (when tenant context active + entitled) OR `Back to Operations`; secondary `Show all operations` allowed when tenant context active + entitled | N/A | N/A | N/A | N/A | N/A | N/A | No | Must not reveal tenant identity when user is not entitled |
|
||||
| Tenant navigation shortcuts | Tenant sidebar | N/A | N/A | N/A | N/A | N/A | N/A | N/A | No | “Monitoring” group with central shortcuts |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace**: A security and organizational boundary for operations and monitoring.
|
||||
- **Tenant**: A managed environment within a workspace; access is entitlement-based.
|
||||
- **Monitoring (Operations)**: Central monitoring views that can be workspace-wide or tenant-scoped when tenant context is active.
|
||||
- **Operation Run**: A tracked execution of an operational workflow; viewable via canonical run detail.
|
||||
- **Alert**: An operator-facing signal about an issue or state requiring attention.
|
||||
- **Audit Event**: An immutable record of important user-triggered actions and sensitive operations.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-085-001**: In a usability walkthrough, 90% of operators can correctly identify whether Operations is scoped to a tenant or to all tenants within 10 seconds of opening `/admin/operations`.
|
||||
- **SC-085-002**: With tenant context active, operators can return to the tenant dashboard from `/admin/operations` and `/admin/operations/{run}` in ≤ 1 click.
|
||||
- **SC-085-003**: Support tickets tagged “lost tenant context / where am I?” decrease by 30% within 30 days after rollout.
|
||||
- **SC-085-004**: Authorization regression checks show zero cases where a non-entitled user can infer existence of a tenant or view tenant-scoped monitoring data.
|
||||
|
||||
### Engineering Acceptance Outcomes
|
||||
|
||||
- **SC-085-005**: When tenant context is active, `/admin/operations` and `/admin/operations/{run}` clearly show tenant scope and a “Back to <tenant>” affordance.
|
||||
- **SC-085-006**: When tenant context is not active, `/admin/operations/{run}` shows “Back to Operations” and no “Back to tenant”.
|
||||
- **SC-085-007**: Viewing Monitoring pages does not initiate outbound network requests or start background work as a side effect of rendering.
|
||||
|
||||
## Test Plan *(mandatory)*
|
||||
|
||||
1. **Operations index scope label + CTAs (tenant context)**
|
||||
- With tenant context active and user entitled, request `/admin/operations`.
|
||||
- Assert the page indicates `Scope: Tenant — <tenant name>`.
|
||||
- Assert `Show all tenants` and `Back to <tenant name>` are available.
|
||||
|
||||
2. **Operations index scope label (no tenant context)**
|
||||
- With no tenant context active, request `/admin/operations`.
|
||||
- Assert the page indicates `Scope: Workspace — all tenants`.
|
||||
|
||||
3. **Run detail back affordance (tenant context)**
|
||||
- With tenant context active and user entitled, request `/admin/operations/{run}`.
|
||||
- Assert `← Back to <tenant name>` is available.
|
||||
- Assert secondary `Show all operations` is available and links to `/admin/operations`.
|
||||
|
||||
4. **Run detail back affordance (no tenant context)**
|
||||
- With no tenant context active, request `/admin/operations/{run}`.
|
||||
- Assert only `Back to Operations` is available.
|
||||
|
||||
5. **Deny-as-not-found regression**
|
||||
- As a user without tenant entitlement, request `/admin/t/{tenant}`.
|
||||
- Assert deny-as-not-found behavior (404 semantics) and that no tenant identity hints are revealed via Monitoring CTAs.
|
||||
|
||||
6. **Stale tenant context behaves workspace-wide**
|
||||
- With tenant context active but user not entitled, request `/admin/operations`.
|
||||
- Assert scope indicates workspace-wide and no tenant name or “Back to tenant” is present.
|
||||
|
||||
7. **No outbound calls on render**
|
||||
- Assert rendering `/admin/operations` and `/admin/operations/{run}` does not initiate outbound network calls and does not start background work from a view-only GET.
|
||||
192
specs/085-tenant-operate-hub/tasks.md
Normal file
192
specs/085-tenant-operate-hub/tasks.md
Normal file
@ -0,0 +1,192 @@
|
||||
|
||||
---
|
||||
description: "Task list for Spec 085 — Tenant Operate Hub / Tenant Overview IA"
|
||||
---
|
||||
|
||||
# Tasks: Spec 085 — Tenant Operate Hub / Tenant Overview IA
|
||||
|
||||
**Input**: Design documents from `/specs/085-tenant-operate-hub/`
|
||||
|
||||
**Required**:
|
||||
- `specs/085-tenant-operate-hub/plan.md`
|
||||
- `specs/085-tenant-operate-hub/spec.md`
|
||||
|
||||
**Additional docs present**:
|
||||
- `specs/085-tenant-operate-hub/research.md`
|
||||
- `specs/085-tenant-operate-hub/data-model.md`
|
||||
- `specs/085-tenant-operate-hub/contracts/openapi.yaml`
|
||||
- `specs/085-tenant-operate-hub/quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (runtime UX + security semantics; Pest)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Confirm the existing code touchpoints and test harness for Spec 085.
|
||||
|
||||
- [X] T001 Confirm canonical Monitoring routes + existing clear-context endpoint in routes/web.php and app/Http/Controllers/ClearTenantContextController.php
|
||||
- [X] T002 Confirm the Monitoring pages exist and are canonical: app/Filament/Pages/Monitoring/Operations.php, app/Filament/Pages/Operations/TenantlessOperationRunViewer.php, app/Filament/Pages/Monitoring/Alerts.php, app/Filament/Pages/Monitoring/AuditLog.php
|
||||
- [X] T003 Confirm Tenant panel provider is the entry point for tenant sidebar Monitoring shortcuts in app/Providers/Filament/TenantPanelProvider.php
|
||||
- [X] T004 Confirm Laravel 11+/12 panel provider registration is in bootstrap/providers.php (not bootstrap/app.php)
|
||||
- [X] T005 [P] Identify existing monitoring/tenant scoping tests to extend (tests/Feature/Monitoring/OperationsTenantScopeTest.php, tests/Feature/Operations/TenantlessOperationRunViewerTest.php)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared helper behavior must match Spec 085 semantics before story work.
|
||||
|
||||
- [X] T006 Update scope-label copy and semantics in app/Support/OperateHub/OperateHubShell.php (MUST match FR-085-002 exactly: "Scope: Workspace — all tenants" / "Scope: Tenant — <tenant name>")
|
||||
- [X] T007 Ensure OperateHubShell resolves active entitled tenant context safely (Filament tenant when present, otherwise remembered last-tenant id for the current workspace)
|
||||
- [X] T008 Update OperateHubShell return affordance label to include tenant name ("Back to <tenant name>") in app/Support/OperateHub/OperateHubShell.php
|
||||
- [X] T009 Add a helper method to resolve “active tenant AND still entitled” in app/Support/OperateHub/OperateHubShell.php (used by Operations index + run detail to implement stale-tenant-context behavior)
|
||||
- [X] T010 Ensure Monitoring renders remain DB-only (no outbound calls / no side effects) by standardizing test guards with Http::preventStrayRequests() in tests/Feature/Spec085/*.php and existing coverage tests/Feature/Monitoring/OperationsTenantScopeTest.php and tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
|
||||
**Checkpoint**: Shared semantics locked; user story work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Monitoring feels context-aware (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: When tenant context is active, Monitoring clearly shows tenant scope + deterministic “Back to tenant” and offers explicit “Show all tenants” to exit.
|
||||
|
||||
**Independent Test**: With tenant context active + entitled, GET `/admin/operations` shows `Scope: Tenant — <tenant>` and buttons `Back to <tenant>` and `Show all tenants`; clicking “Show all tenants” clears tenant context and returns to workspace-wide operations.
|
||||
|
||||
### Tests for User Story 1 (write first)
|
||||
|
||||
- [X] T011 [P] [US1] Add Spec 085 operations header tests in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant scope label + both CTAs)
|
||||
- [X] T012 [P] [US1] Add stale-tenant-context test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (tenant context set but user not entitled → workspace scope + no tenant name + no back-to-tenant)
|
||||
- [X] T013 [P] [US1] Add explicit-exit behavior test in tests/Feature/Spec085/OperationsIndexHeaderTest.php (POST /admin/clear-tenant-context clears Filament tenant + last tenant id)
|
||||
- [X] T014 [P] [US1] Add tenant navigation shortcuts test in tests/Feature/Spec085/TenantNavigationMonitoringShortcutsTest.php (tenant sidebar shows “Monitoring” group with Runs/Alerts/Audit Log)
|
||||
- [X] T015 [P] [US1] Add “deny-as-not-found” regression tests for canonical Monitoring access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-workspace-member → 404 for /admin/operations and /admin/operations/{run})
|
||||
- [X] T016 [P] [US1] Add “deny-as-not-found” regression test for tenant dashboard direct access in tests/Feature/Spec085/DenyAsNotFoundSemanticsTest.php (non-entitled to tenant → 404 for /admin/t/{tenant})
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T017 [US1] Replace Tenant sidebar "Operations" item with "Monitoring" group shortcuts in app/Providers/Filament/TenantPanelProvider.php (Runs→/admin/operations, Alerts→/admin/alerts, Audit Log→/admin/audit-log)
|
||||
- [X] T018 [US1] Implement Operations index scope indicator per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (workspace vs tenant; stale context treated as workspace)
|
||||
- [X] T019 [US1] Implement Operations index CTAs per Spec 085 in app/Filament/Pages/Monitoring/Operations.php (Back to <tenant> using App\Filament\Pages\TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant); Show all tenants exits tenant context)
|
||||
- [X] T020 [US1] Ensure “Show all tenants” uses an explicit server-side action (no implicit GET mutation) in app/Filament/Pages/Monitoring/Operations.php (perform the same mutations as app/Http/Controllers/ClearTenantContextController.php: Filament::setTenant(null, true) + WorkspaceContext::clearLastTenantId(); then redirect to /admin/operations)
|
||||
|
||||
**Checkpoint**: US1 fully testable and meets FR-085-001/002/005/007/010.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Canonical URLs with explicit scope (Priority: P2)
|
||||
|
||||
**Goal**: Canonical Monitoring URLs never implicitly change tenant context; tenant context may only affect default filtering and must be obvious.
|
||||
|
||||
**Independent Test**: With tenant context active, GET `/admin/operations` does not change tenant context and defaults the list to the active tenant (or otherwise clearly shows it’s tenant-scoped by default).
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T021 [P] [US2] Add non-mutation test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (GET /admin/operations does not set/clear tenant context)
|
||||
- [X] T022 [P] [US2] Add scope label test in tests/Feature/Spec085/CanonicalMonitoringDoesNotMutateTenantContextTest.php (no tenant context → "Scope: Workspace — all tenants")
|
||||
- [X] T023 [P] [US2] Add default-tenant-filter test in tests/Feature/Monitoring/OperationsTenantScopeTest.php (tenant context active → list defaults to active tenant)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T024 [US2] Ensure Operations index query applies workspace scoping and (when tenant context is active + entitled) tenant scoping without mutating tenant context in app/Filament/Pages/Monitoring/Operations.php
|
||||
- [X] T025 [US2] Ensure any default tenant filter is applied as a query/filter default only (no calls to Filament::setTenant() during GET) in app/Filament/Pages/Monitoring/Operations.php
|
||||
|
||||
**Checkpoint**: US2 meets FR-085-003/004/009.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Deep links are safe and recoverable (Priority: P3)
|
||||
|
||||
**Goal**: On `/admin/operations/{run}`, tenant-context users get a deterministic “Back to <tenant>” plus a secondary “Show all operations”; otherwise only “Back to Operations”.
|
||||
|
||||
**Independent Test**: With tenant context active + entitled, GET `/admin/operations/{run}` shows `← Back to <tenant>` and `Show all operations`; without tenant context it shows `Back to Operations` only.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T026 [P] [US3] Add run detail header-action tests in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context vs no context)
|
||||
- [X] T027 [P] [US3] Add stale-tenant-context run detail test in tests/Feature/Spec085/RunDetailBackAffordanceTest.php (tenant context set but not entitled → no tenant name, no back-to-tenant)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T028 [US3] Implement deterministic back affordances for run detail in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (tenant-context+entitled → “← Back to <tenant>” + “Show all operations”; else “Back to Operations”)
|
||||
- [X] T029 [US3] Ensure run detail never reveals tenant identity when the viewer is not entitled (stale tenant context treated as workspace-wide) in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
|
||||
**Checkpoint**: US3 meets FR-085-006/008/010.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T030 [P] Confirm Spec 085 UI Action Matrix matches implemented header actions in specs/085-tenant-operate-hub/spec.md
|
||||
- [X] T031 [P] Validate manual verification steps in specs/085-tenant-operate-hub/quickstart.md against actual behavior (update doc only if it drifted)
|
||||
- [X] T037 Ensure “Show all tenants” clears Operations table tenant filter state (prevents stale Livewire table filter state from keeping the list scoped)
|
||||
- [X] T032 Run formatting on changed files under app/ and tests/ using vendor/bin/sail bin pint --dirty
|
||||
- [X] T033 Run focused test suite: vendor/bin/sail artisan test --compact tests/Feature/Spec085 tests/Feature/Monitoring/OperationsTenantScopeTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
- [X] T034 Fix Filament auth-pattern guard compliance by removing Gate:: usage in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php (use $this->authorize(...))
|
||||
- [X] T035 Ensure canonical Operate Hub routes sanitize stale/non-entitled tenant context by applying ensure-filament-tenant-selected middleware to /admin/operations, /admin/alerts, /admin/audit-log, and /admin/operations/{run}
|
||||
- [X] T036 Harden Spec 085-related tests to match final copy/semantics and avoid brittle Livewire DOM assertions (tests/Feature/OpsUx/OperateHubShellTest.php, tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
|
||||
P2 --> US1[US1 (P1): Context-aware Monitoring entry]
|
||||
US1 --> US2[US2 (P2): Canonical URLs + explicit scope]
|
||||
US1 --> US3[US3 (P3): Deep-link back affordances]
|
||||
US2 --> P6[Phase 6: Polish]
|
||||
US3 --> P6
|
||||
```
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 is the MVP.
|
||||
- US2 and US3 depend on the shared foundational semantics (scope labels + entitled active tenant resolution).
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
```text
|
||||
In parallel:
|
||||
- T011 (tests) in tests/Feature/Spec085/OperationsIndexHeaderTest.php
|
||||
- T017 (tenant nav shortcuts) in app/Providers/Filament/TenantPanelProvider.php
|
||||
Then:
|
||||
- T018–T020 in app/Filament/Pages/Monitoring/Operations.php
|
||||
```
|
||||
|
||||
### US2
|
||||
|
||||
```text
|
||||
In parallel:
|
||||
- T021–T022 (non-mutation + scope label tests)
|
||||
- T023 (default tenant filter test) in tests/Feature/Monitoring/OperationsTenantScopeTest.php
|
||||
Then:
|
||||
- T024–T025 in app/Filament/Pages/Monitoring/Operations.php
|
||||
```
|
||||
|
||||
### US3
|
||||
|
||||
```text
|
||||
In parallel:
|
||||
- T026–T027 (run detail tests) in tests/Feature/Spec085/RunDetailBackAffordanceTest.php
|
||||
Then:
|
||||
- T028–T029 in app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP Scope
|
||||
|
||||
- Implement US1 only (T011–T020), run T033, then manually validate via specs/085-tenant-operate-hub/quickstart.md.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- US1 → US2 → US3, keeping each story independently testable.
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
]);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$policies = Policy::factory()
|
||||
->count(3)
|
||||
->create([
|
||||
|
||||
@ -53,6 +53,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
it('extracts edges during inventory sync and marks missing appropriately', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
$this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps);
|
||||
|
||||
$svc = app(InventorySyncService::class);
|
||||
@ -73,6 +74,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
it('respects 50-edge limit for outbound extraction', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
// Fake client returning 60 group assignments
|
||||
$this->app->bind(GraphClientInterface::class, function () {
|
||||
return new class implements GraphClientInterface
|
||||
@ -132,6 +134,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
it('persists unsupported reference warnings on the sync run record', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$this->app->bind(GraphClientInterface::class, function () {
|
||||
return new class implements GraphClientInterface
|
||||
@ -276,6 +279,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$this->app->bind(GraphClientInterface::class, function () {
|
||||
return new class implements GraphClientInterface
|
||||
|
||||
@ -97,6 +97,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'tenant_id' => 'tenant-1',
|
||||
]);
|
||||
|
||||
ensureDefaultProviderConnection($tenant);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'dcs-1',
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user