diff --git a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php index aca7057..a5ad6cf 100644 --- a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php +++ b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php @@ -4,7 +4,7 @@ use App\Filament\Resources\BackupSetResource; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -19,16 +19,25 @@ private function tableHasRecords(): bool protected function getHeaderActions(): array { + $create = Actions\CreateAction::make(); + UiEnforcement::forAction($create) + ->requireCapability(Capabilities::TENANT_SYNC) + ->apply(); + return [ - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()) - ->visible(fn (): bool => $this->tableHasRecords()), + $create->visible(fn (): bool => $this->tableHasRecords()), ]; } protected function getTableEmptyStateActions(): array { + $create = Actions\CreateAction::make(); + UiEnforcement::forAction($create) + ->requireCapability(Capabilities::TENANT_SYNC) + ->apply(); + return [ - UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()), + $create, ]; } } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index ee33227..dffa962 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -98,7 +98,14 @@ protected function getHeaderActions(): array return null; }) - ->authorize(fn (): bool => true), + ->authorize(function () use ($resolver): bool { + $tenant = $this->resolveTenantForCreateAction(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $resolver->isMember($user, $tenant); + }), ]; } @@ -175,7 +182,14 @@ private function makeEmptyStateCreateAction(): Actions\CreateAction return null; }) - ->authorize(fn (): bool => true); + ->authorize(function () use ($resolver): bool { + $tenant = $this->resolveTenantForCreateAction(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $resolver->isMember($user, $tenant); + }); } private function resolveTenantExternalIdForCreateAction(): ?string diff --git a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php index ec9a501..c957675 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php @@ -4,7 +4,7 @@ use App\Filament\Resources\RestoreRunResource; use App\Support\Auth\Capabilities; -use App\Support\Auth\UiEnforcement; +use App\Support\Rbac\UiEnforcement; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -19,16 +19,25 @@ private function tableHasRecords(): bool protected function getHeaderActions(): array { + $create = Actions\CreateAction::make(); + UiEnforcement::forAction($create) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); + return [ - UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()) - ->visible(fn (): bool => $this->tableHasRecords()), + $create->visible(fn (): bool => $this->tableHasRecords()), ]; } protected function getTableEmptyStateActions(): array { + $create = Actions\CreateAction::make(); + UiEnforcement::forAction($create) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(); + return [ - UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()), + $create, ]; } } diff --git a/app/Jobs/FetchAssignmentsJob.php b/app/Jobs/FetchAssignmentsJob.php index a492a4a..72350d6 100644 --- a/app/Jobs/FetchAssignmentsJob.php +++ b/app/Jobs/FetchAssignmentsJob.php @@ -2,7 +2,9 @@ namespace App\Jobs; +use App\Jobs\Middleware\TrackOperationRun; use App\Models\BackupItem; +use App\Models\OperationRun; use App\Services\AssignmentBackupService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -15,6 +17,8 @@ class FetchAssignmentsJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + /** * The number of times the job may be attempted. */ @@ -32,8 +36,19 @@ public function __construct( public int $backupItemId, public string $tenantExternalId, public string $policyExternalId, - public array $policyPayload - ) {} + public array $policyPayload, + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } /** * Execute the job. diff --git a/app/Jobs/RestoreAssignmentsJob.php b/app/Jobs/RestoreAssignmentsJob.php index ed20750..92b6f67 100644 --- a/app/Jobs/RestoreAssignmentsJob.php +++ b/app/Jobs/RestoreAssignmentsJob.php @@ -2,6 +2,8 @@ namespace App\Jobs; +use App\Jobs\Middleware\TrackOperationRun; +use App\Models\OperationRun; use App\Models\RestoreRun; use App\Models\Tenant; use App\Services\AssignmentRestoreService; @@ -16,6 +18,8 @@ class RestoreAssignmentsJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public ?OperationRun $operationRun = null; + public int $tries = 1; public int $backoff = 0; @@ -33,7 +37,18 @@ public function __construct( public array $foundationMapping = [], public ?string $actorEmail = null, public ?string $actorName = null, - ) {} + ?OperationRun $operationRun = null, + ) { + $this->operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } /** * Execute the job. diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index ba56088..38796c2 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -9,6 +9,8 @@ use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver; +use App\Support\OpsUx\RunFailureSanitizer; +use App\Support\Providers\ProviderReasonCodes; use Illuminate\Support\Facades\Log; class AssignmentBackupService @@ -19,6 +21,7 @@ public function __construct( private readonly AssignmentFilterResolver $assignmentFilterResolver, private readonly ScopeTagResolver $scopeTagResolver, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, + private readonly OperationRunService $operationRunService, ) {} /** @@ -82,6 +85,8 @@ public function enrichWithAssignments( 'metadata' => $metadata, ]); + $this->recordFetchOperationRun($backupItem, $tenant, $metadata); + Log::warning('No assignments fetched for policy', [ 'tenant_id' => $tenantId, 'policy_id' => $policyId, @@ -121,6 +126,8 @@ public function enrichWithAssignments( 'metadata' => $metadata, ]); + $this->recordFetchOperationRun($backupItem, $tenant, $metadata); + Log::info('Assignments enriched for backup item', [ 'tenant_id' => $tenantId, 'policy_id' => $policyId, @@ -132,6 +139,60 @@ public function enrichWithAssignments( return $backupItem->refresh(); } + /** + * @param array $captureMetadata + */ + public function recordFetchOperationRun(BackupItem $backupItem, Tenant $tenant, array $captureMetadata = []): void + { + $run = $this->operationRunService->ensureRunWithIdentity( + tenant: $tenant, + type: 'assignments.fetch', + identityInputs: [ + 'backup_item_id' => (int) $backupItem->getKey(), + ], + context: [ + 'backup_set_id' => (int) $backupItem->backup_set_id, + 'backup_item_id' => (int) $backupItem->getKey(), + 'policy_id' => is_numeric($backupItem->policy_id) ? (int) $backupItem->policy_id : null, + 'policy_identifier' => (string) $backupItem->policy_identifier, + ], + ); + + if ($run->status === 'completed') { + return; + } + + $this->operationRunService->updateRun($run, 'running'); + + $fetchFailed = (bool) ($captureMetadata['assignments_fetch_failed'] ?? false); + + $reasonCandidate = $captureMetadata['assignments_fetch_error_code'] + ?? $captureMetadata['assignments_fetch_error'] + ?? ProviderReasonCodes::UnknownError; + + $reasonCode = RunFailureSanitizer::normalizeReasonCode( + $this->normalizeReasonCandidate($reasonCandidate) + ); + + $this->operationRunService->updateRun( + $run, + status: 'completed', + outcome: $fetchFailed ? 'failed' : 'succeeded', + summaryCounts: [ + 'total' => 1, + 'processed' => $fetchFailed ? 0 : 1, + 'failed' => $fetchFailed ? 1 : 0, + ], + failures: $fetchFailed + ? [[ + 'code' => 'assignments.fetch_failed', + 'reason_code' => $reasonCode, + 'message' => (string) ($captureMetadata['assignments_fetch_error'] ?? 'Assignments fetch failed'), + ]] + : [], + ); + } + /** * Resolve scope tag IDs to display names. */ @@ -233,4 +294,24 @@ private function extractAssignmentFilterIds(array $assignments): array return array_values(array_unique($filterIds)); } + + private function normalizeReasonCandidate(mixed $candidate): string + { + if (! is_string($candidate) && ! is_numeric($candidate)) { + return ProviderReasonCodes::UnknownError; + } + + $raw = trim((string) $candidate); + + if ($raw === '') { + return ProviderReasonCodes::UnknownError; + } + + $raw = preg_replace('/(?skipOutcome($assignment, null, null, 'Assignment filter mapping is unavailable.'); $summary['skipped']++; - $this->logAssignmentOutcome( - status: 'skipped', - tenant: $tenant, - assignment: $assignment, - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'assignment_filter_id' => $filterId, - 'reason' => 'Assignment filter mapping is unavailable.', - ] - ); continue; } @@ -169,20 +153,6 @@ public function restore( 'Assignment filter mapping missing for filter ID.' ); $summary['skipped']++; - $this->logAssignmentOutcome( - status: 'skipped', - tenant: $tenant, - assignment: $assignment, - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'assignment_filter_id' => $filterId, - 'reason' => 'Assignment filter mapping missing for filter ID.', - ] - ); continue; } @@ -201,20 +171,6 @@ public function restore( if ($mappedGroupId === 'SKIP') { $outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId); $summary['skipped']++; - $this->logAssignmentOutcome( - status: 'skipped', - tenant: $tenant, - assignment: $assignment, - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'group_id' => $groupId, - 'mapped_group_id' => $mappedGroupId, - ] - ); continue; } @@ -262,20 +218,6 @@ public function restore( $meta['mapped_group_id'] ); $summary['success']++; - $this->logAssignmentOutcome( - status: 'created', - tenant: $tenant, - assignment: $meta['assignment'], - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'group_id' => $meta['group_id'], - 'mapped_group_id' => $meta['mapped_group_id'], - ] - ); } } else { $reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed'; @@ -294,22 +236,6 @@ public function restore( $assignResponse ); $summary['failed']++; - $this->logAssignmentOutcome( - status: 'failed', - tenant: $tenant, - assignment: $meta['assignment'], - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'group_id' => $meta['group_id'], - 'mapped_group_id' => $meta['mapped_group_id'], - 'graph_error_message' => $assignResponse->meta['error_message'] ?? null, - 'graph_error_code' => $assignResponse->meta['error_code'] ?? null, - ], - ); } } @@ -397,20 +323,6 @@ public function restore( if ($createResponse->successful()) { $outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']); $summary['success']++; - $this->logAssignmentOutcome( - status: 'created', - tenant: $tenant, - assignment: $meta['assignment'], - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'group_id' => $meta['group_id'], - 'mapped_group_id' => $meta['mapped_group_id'], - ] - ); } else { $outcomes[] = $this->failureOutcome( $meta['assignment'], @@ -420,22 +332,6 @@ public function restore( $createResponse ); $summary['failed']++; - $this->logAssignmentOutcome( - status: 'failed', - tenant: $tenant, - assignment: $meta['assignment'], - restoreRun: $restoreRun, - actorEmail: $actorEmail, - actorName: $actorName, - metadata: [ - 'policy_id' => $policyId, - 'policy_type' => $policyType, - 'group_id' => $meta['group_id'], - 'mapped_group_id' => $meta['mapped_group_id'], - 'graph_error_message' => $createResponse->meta['error_message'] ?? null, - 'graph_error_code' => $createResponse->meta['error_code'] ?? null, - ], - ); } usleep(100000); @@ -597,40 +493,4 @@ private function failureOutcome( 'graph_client_request_id' => $response?->meta['client_request_id'] ?? null, ], static fn ($value) => $value !== null); } - - private function logAssignmentOutcome( - string $status, - Tenant $tenant, - array $assignment, - ?RestoreRun $restoreRun, - ?string $actorEmail, - ?string $actorName, - array $metadata - ): void { - $action = match ($status) { - 'created' => 'restore.assignment.created', - 'failed' => 'restore.assignment.failed', - default => 'restore.assignment.skipped', - }; - - $statusLabel = match ($status) { - 'created' => 'success', - 'failed' => 'failed', - default => 'warning', - }; - - $this->auditLogger->log( - tenant: $tenant, - action: $action, - context: [ - 'metadata' => $metadata, - 'assignment' => $this->sanitizeAssignment($assignment), - ], - actorEmail: $actorEmail, - actorName: $actorName, - status: $statusLabel, - resourceType: 'restore_run', - resourceId: $restoreRun ? (string) $restoreRun->id : null - ); - } } diff --git a/app/Services/Auth/CapabilityResolver.php b/app/Services/Auth/CapabilityResolver.php index 152130b..a88ec83 100644 --- a/app/Services/Auth/CapabilityResolver.php +++ b/app/Services/Auth/CapabilityResolver.php @@ -93,7 +93,7 @@ private function getMembership(User $user, Tenant $tenant): ?array { $cacheKey = "membership_{$user->id}_{$tenant->id}"; - if (! isset($this->resolvedMemberships[$cacheKey])) { + if (! array_key_exists($cacheKey, $this->resolvedMemberships)) { $membership = TenantMembership::query() ->where('user_id', $user->id) ->where('tenant_id', $tenant->id) @@ -105,6 +105,47 @@ private function getMembership(User $user, Tenant $tenant): ?array return $this->resolvedMemberships[$cacheKey]; } + /** + * Prime membership cache for a set of tenants in one query. + * + * Used to avoid N+1 queries for bulk selection authorization. + * + * @param array $tenantIds + */ + public function primeMemberships(User $user, array $tenantIds): void + { + $tenantIds = array_values(array_unique(array_map(static fn ($id): int => (int) $id, $tenantIds))); + + if ($tenantIds === []) { + return; + } + + $missingTenantIds = []; + foreach ($tenantIds as $tenantId) { + $cacheKey = "membership_{$user->id}_{$tenantId}"; + if (! array_key_exists($cacheKey, $this->resolvedMemberships)) { + $missingTenantIds[] = $tenantId; + } + } + + if ($missingTenantIds === []) { + return; + } + + $memberships = TenantMembership::query() + ->where('user_id', $user->id) + ->whereIn('tenant_id', $missingTenantIds) + ->get(['tenant_id', 'role', 'source', 'source_ref']); + + $byTenantId = $memberships->keyBy('tenant_id'); + + foreach ($missingTenantIds as $tenantId) { + $cacheKey = "membership_{$user->id}_{$tenantId}"; + $membership = $byTenantId->get($tenantId); + $this->resolvedMemberships[$cacheKey] = $membership?->toArray(); + } + } + /** * Clear cached memberships (useful for testing or after membership changes) */ diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 29aa0b5..3200292 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -7,7 +7,7 @@ class AssignmentFetcher { public function __construct( - private readonly MicrosoftGraphClient $graphClient, + private readonly GraphClientInterface $graphClient, private readonly GraphContractRegistry $contracts, ) {} diff --git a/app/Services/Graph/AssignmentFilterResolver.php b/app/Services/Graph/AssignmentFilterResolver.php index 764c134..57f5375 100644 --- a/app/Services/Graph/AssignmentFilterResolver.php +++ b/app/Services/Graph/AssignmentFilterResolver.php @@ -9,7 +9,7 @@ class AssignmentFilterResolver { public function __construct( - private readonly MicrosoftGraphClient $graphClient, + private readonly GraphClientInterface $graphClient, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, ) {} diff --git a/app/Services/Graph/GroupResolver.php b/app/Services/Graph/GroupResolver.php index afac6a4..55e4f33 100644 --- a/app/Services/Graph/GroupResolver.php +++ b/app/Services/Graph/GroupResolver.php @@ -8,7 +8,7 @@ class GroupResolver { public function __construct( - private readonly MicrosoftGraphClient $graphClient, + private readonly GraphClientInterface $graphClient, ) {} /** diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index 255e2e1..adad3e6 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -290,21 +290,27 @@ private function snapshotPolicy( $captured = $captureResult['captured']; $payload = $captured['payload']; $metadata = $captured['metadata'] ?? []; + $backupItem = $this->createBackupItemFromVersion( + tenant: $tenant, + backupSet: $backupSet, + policy: $policy, + version: $version, + payload: is_array($payload) ? $payload : [], + assignments: $captured['assignments'] ?? null, + scopeTags: $captured['scope_tags'] ?? null, + metadata: is_array($metadata) ? $metadata : [], + warnings: $captured['warnings'] ?? [], + ); - return [ - $this->createBackupItemFromVersion( + if ($includeAssignments) { + $this->assignmentBackupService->recordFetchOperationRun( + backupItem: $backupItem, tenant: $tenant, - backupSet: $backupSet, - policy: $policy, - version: $version, - payload: is_array($payload) ? $payload : [], - assignments: $captured['assignments'] ?? null, - scopeTags: $captured['scope_tags'] ?? null, - metadata: is_array($metadata) ? $metadata : [], - warnings: $captured['warnings'] ?? [], - ), - null, - ]; + captureMetadata: is_array($metadata) ? $metadata : [], + ); + } + + return [$backupItem, null]; } /** diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index ab266c5..967d157 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -7,6 +7,7 @@ use App\Models\Tenant; use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\AssignmentFilterResolver; +use App\Services\Graph\GraphException; use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver; @@ -108,6 +109,9 @@ public function capture( } catch (\Throwable $e) { $captureMetadata['assignments_fetch_failed'] = true; $captureMetadata['assignments_fetch_error'] = $e->getMessage(); + $captureMetadata['assignments_fetch_error_code'] = $e instanceof GraphException + ? ($e->status ?? null) + : (is_numeric($e->getCode()) ? (int) $e->getCode() : null); Log::warning('Failed to fetch assignments during capture', [ 'tenant_id' => $tenant->id, @@ -295,6 +299,9 @@ public function ensureVersionHasAssignments( } catch (\Throwable $e) { $metadata['assignments_fetch_failed'] = true; $metadata['assignments_fetch_error'] = $e->getMessage(); + $metadata['assignments_fetch_error_code'] = $e instanceof GraphException + ? ($e->status ?? null) + : (is_numeric($e->getCode()) ? (int) $e->getCode() : null); Log::warning('Failed to backfill assignments for version', [ 'version_id' => $version->id, diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 319602e..fe76a9c 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -4,6 +4,7 @@ use App\Models\BackupItem; use App\Models\BackupSet; +use App\Models\OperationRun; use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\ProviderConnection; @@ -14,8 +15,10 @@ use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphLogger; +use App\Services\OperationRunService; use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderGateway; +use App\Support\OpsUx\RunFailureSanitizer; use App\Support\Providers\ProviderReasonCodes; use Carbon\CarbonImmutable; use Illuminate\Support\Arr; @@ -34,6 +37,7 @@ public function __construct( private readonly GraphContractRegistry $contracts, private readonly ConfigurationPolicyTemplateResolver $templateResolver, private readonly AssignmentRestoreService $assignmentRestoreService, + private readonly OperationRunService $operationRunService, private readonly FoundationMappingService $foundationMappingService, private readonly ?ProviderConnectionResolver $providerConnections = null, private readonly ?ProviderGateway $providerGateway = null, @@ -378,6 +382,36 @@ public function execute( $results = $foundationEntries; $hardFailures = $foundationFailures; + $assignmentRestoreRun = null; + $assignmentRestoreTotals = [ + 'success' => 0, + 'failed' => 0, + 'skipped' => 0, + ]; + $assignmentRestoreFailures = []; + + $assignmentRestoreItemCount = $policyItems + ->filter(fn (BackupItem $policyItem): bool => is_array($policyItem->assignments) && $policyItem->assignments !== []) + ->count(); + + if (! $dryRun && $assignmentRestoreItemCount > 0) { + $assignmentRestoreRun = $this->operationRunService->ensureRunWithIdentity( + tenant: $tenant, + type: 'assignments.restore', + identityInputs: [ + 'restore_run_id' => (int) $restoreRun->getKey(), + ], + context: [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'assignment_item_count' => (int) $assignmentRestoreItemCount, + ], + ); + + if ($assignmentRestoreRun->status !== 'completed') { + $this->operationRunService->updateRun($assignmentRestoreRun, 'running'); + } + } foreach ($policyItems as $item) { $context = [ @@ -761,6 +795,41 @@ public function execute( $assignmentSummary = $assignmentOutcomes['summary'] ?? null; + if (is_array($assignmentSummary)) { + $assignmentRestoreTotals['success'] += (int) ($assignmentSummary['success'] ?? 0); + $assignmentRestoreTotals['failed'] += (int) ($assignmentSummary['failed'] ?? 0); + $assignmentRestoreTotals['skipped'] += (int) ($assignmentSummary['skipped'] ?? 0); + } + + if (is_array($assignmentOutcomes)) { + foreach ($assignmentOutcomes['outcomes'] ?? [] as $assignmentOutcome) { + if (! is_array($assignmentOutcome)) { + continue; + } + + if (($assignmentOutcome['status'] ?? null) !== 'failed') { + continue; + } + + $message = (string) ($assignmentOutcome['reason'] + ?? $assignmentOutcome['graph_error_message'] + ?? 'Assignment restore failed'); + + $reasonCandidate = $assignmentOutcome['graph_error_code'] + ?? $assignmentOutcome['reason'] + ?? $assignmentOutcome['graph_error_message'] + ?? ProviderReasonCodes::UnknownError; + + $assignmentRestoreFailures[] = [ + 'code' => 'assignments.restore_failed', + 'reason_code' => RunFailureSanitizer::normalizeReasonCode( + $this->normalizeFailureReasonCandidate($reasonCandidate) + ), + 'message' => $message, + ]; + } + } + if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') { $itemStatus = 'partial'; $resultReason = 'Assignments restored with failures'; @@ -956,6 +1025,56 @@ public function execute( ]), ]); + if ($assignmentRestoreRun instanceof OperationRun) { + $assignmentAttempted = $assignmentRestoreTotals['success'] + $assignmentRestoreTotals['failed']; + + $assignmentRunOutcome = 'succeeded'; + + if ($assignmentRestoreTotals['failed'] > 0 && $assignmentRestoreTotals['success'] > 0) { + $assignmentRunOutcome = 'partially_succeeded'; + } elseif ($assignmentRestoreTotals['failed'] > 0) { + $assignmentRunOutcome = 'failed'; + } + + $this->operationRunService->updateRun( + $assignmentRestoreRun, + status: 'completed', + outcome: $assignmentRunOutcome, + summaryCounts: [ + 'total' => $assignmentAttempted, + 'processed' => $assignmentRestoreTotals['success'], + 'failed' => $assignmentRestoreTotals['failed'], + ], + failures: $assignmentRestoreFailures, + ); + + $assignmentAuditStatus = match (true) { + $assignmentRestoreTotals['failed'] > 0 && $assignmentRestoreTotals['success'] === 0 => 'failed', + $assignmentRestoreTotals['failed'] > 0 || $assignmentRestoreTotals['skipped'] > 0 => 'partial', + default => 'success', + }; + + $this->auditLogger->log( + tenant: $tenant, + action: 'restore.assignments.summary', + context: [ + 'metadata' => [ + 'restore_run_id' => (int) $restoreRun->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'total' => $assignmentAttempted, + 'succeeded' => (int) $assignmentRestoreTotals['success'], + 'failed' => (int) $assignmentRestoreTotals['failed'], + 'skipped' => (int) $assignmentRestoreTotals['skipped'], + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->getKey(), + status: $assignmentAuditStatus, + ); + } + $this->auditLogger->log( tenant: $tenant, action: $dryRun ? 'restore.previewed' : 'restore.executed', @@ -1025,6 +1144,26 @@ private function resolveRestoreMode(string $policyType): string return $restore; } + private function normalizeFailureReasonCandidate(mixed $candidate): string + { + if (! is_string($candidate) && ! is_numeric($candidate)) { + return ProviderReasonCodes::UnknownError; + } + + $raw = trim((string) $candidate); + + if ($raw === '') { + return ProviderReasonCodes::UnknownError; + } + + $raw = preg_replace('/(?contracts->get($policyType); diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index ea8c4f5..568618a 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -7,6 +7,7 @@ use App\Models\Tenant; use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\AssignmentFilterResolver; +use App\Services\Graph\GraphException; use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver; @@ -182,6 +183,9 @@ public function captureFromGraph( } catch (\Throwable $e) { $assignmentMetadata['assignments_fetch_failed'] = true; $assignmentMetadata['assignments_fetch_error'] = $e->getMessage(); + $assignmentMetadata['assignments_fetch_error_code'] = $e instanceof GraphException + ? ($e->status ?? null) + : (is_numeric($e->getCode()) ? (int) $e->getCode() : null); } } diff --git a/app/Support/Auth/UiEnforcement.php b/app/Support/Auth/UiEnforcement.php deleted file mode 100644 index 9686556..0000000 --- a/app/Support/Auth/UiEnforcement.php +++ /dev/null @@ -1,526 +0,0 @@ -): bool|null - */ - private ?\Closure $bulkPreflight = null; - - public function __construct(private string $capability) - { - } - - public static function for(string $capability): self - { - return new self($capability); - } - - public function preserveVisibility(): self - { - if ($this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) { - throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.'); - } - - $this->preserveVisibility = true; - - return $this; - } - - public function andVisibleWhen(callable $businessVisible): self - { - $this->businessVisible = \Closure::fromCallable($businessVisible); - - return $this; - } - - public function andHiddenWhen(callable $businessHidden): self - { - $this->businessHidden = \Closure::fromCallable($businessHidden); - - return $this; - } - - public function tenantFromFilament(): self - { - $this->tenantResolverMode = self::TENANT_RESOLVER_FILAMENT; - $this->customTenantResolver = null; - - return $this; - } - - public function tenantFromRecord(): self - { - if ($this->preserveVisibility) { - throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.'); - } - - $this->tenantResolverMode = self::TENANT_RESOLVER_RECORD; - $this->customTenantResolver = null; - - return $this; - } - - public function tenantFrom(callable $resolver): self - { - if ($this->preserveVisibility) { - throw new LogicException('preserveVisibility() is forbidden for record-scoped surfaces.'); - } - - $this->tenantResolverMode = self::TENANT_RESOLVER_CUSTOM; - $this->customTenantResolver = \Closure::fromCallable($resolver); - - return $this; - } - - /** - * Custom bulk authorization preflight for selection. - * - * Signature: fn (Collection $records): bool - */ - public function preflightSelection(callable $preflight): self - { - $this->bulkPreflightMode = self::BULK_PREFLIGHT_CUSTOM; - $this->bulkPreflight = \Closure::fromCallable($preflight); - - return $this; - } - - public function preflightByTenantMembership(): self - { - $this->bulkPreflightMode = self::BULK_PREFLIGHT_TENANT_MEMBERSHIP; - $this->bulkPreflight = null; - - return $this; - } - - public function preflightByCapability(): self - { - $this->bulkPreflightMode = self::BULK_PREFLIGHT_CAPABILITY; - $this->bulkPreflight = null; - - return $this; - } - - public function apply(Action $action): Action - { - $this->assertMixedVisibilityConfigIsValid(); - - if (! $this->preserveVisibility) { - $this->applyVisibility($action); - } - - if ($action->isBulk()) { - $action->disabled(function () use ($action): bool { - /** @var Collection $records */ - $records = collect($action->getSelectedRecords()); - - return $this->bulkIsDisabled($records); - }); - - $action->tooltip(function () use ($action): ?string { - /** @var Collection $records */ - $records = collect($action->getSelectedRecords()); - - return $this->bulkDisabledTooltip($records); - }); - } else { - $action->disabled(fn (?Model $record = null): bool => $this->isDisabled($record)); - $action->tooltip(fn (?Model $record = null): ?string => $this->disabledTooltip($record)); - } - - return $action; - } - - public function isAllowed(?Model $record = null): bool - { - return ! $this->isDisabled($record); - } - - public function authorizeOrAbort(?Model $record = null): void - { - $user = auth()->user(); - abort_unless($user instanceof User, 403); - - $tenant = $this->resolveTenant($record); - - if (! ($tenant instanceof Tenant)) { - abort(404); - } - - abort_unless($this->isMemberOfTenant($user, $tenant), 404); - abort_unless(Gate::forUser($user)->allows($this->capability, $tenant), 403); - } - - /** - * Server-side enforcement for bulk selections. - * - * - If any selected tenant is not a membership: 404 (deny-as-not-found). - * - If all are memberships but any lacks capability: 403. - * - * @param Collection $records - */ - public function authorizeBulkSelectionOrAbort(Collection $records): void - { - $user = auth()->user(); - abort_unless($user instanceof User, 403); - - $tenantIds = $this->resolveTenantIdsForRecords($records); - - if ($tenantIds === []) { - abort(403); - } - - $membershipTenantIds = $this->membershipTenantIds($user, $tenantIds); - - if (count($membershipTenantIds) !== count($tenantIds)) { - abort(404); - } - - $allowedTenantIds = $this->capabilityTenantIds($user, $tenantIds); - - if (count($allowedTenantIds) !== count($tenantIds)) { - abort(403); - } - } - - /** - * Public helper for evaluating bulk selection authorization decisions. - * - * @param Collection $records - */ - public function bulkSelectionIsAuthorized(User $user, Collection $records): bool - { - return $this->bulkSelectionIsAuthorizedInternal($user, $records); - } - - private function applyVisibility(Action $action): void - { - $canApplyMemberVisibility = ! ($action->isBulk() && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT); - - $businessVisible = $this->businessVisible; - $businessHidden = $this->businessHidden; - - if ($businessVisible instanceof \Closure) { - $action->visible(function () use ($action, $businessVisible, $canApplyMemberVisibility): bool { - if (! (bool) $action->evaluate($businessVisible)) { - return false; - } - - if (! $canApplyMemberVisibility) { - return true; - } - - $record = $action->getRecord(); - - return $this->isMember($record instanceof Model ? $record : null); - }); - } - - if ($businessHidden instanceof \Closure) { - $action->hidden(function () use ($action, $businessHidden, $canApplyMemberVisibility): bool { - if ($canApplyMemberVisibility) { - $record = $action->getRecord(); - - if (! $this->isMember($record instanceof Model ? $record : null)) { - return true; - } - } - - return (bool) $action->evaluate($businessHidden); - }); - - return; - } - - if (! $canApplyMemberVisibility) { - return; - } - - if (! ($businessVisible instanceof \Closure)) { - $action->hidden(function () use ($action): bool { - $record = $action->getRecord(); - - return ! $this->isMember($record instanceof Model ? $record : null); - }); - } - } - - private function assertMixedVisibilityConfigIsValid(): void - { - if ($this->preserveVisibility && ($this->businessVisible instanceof \Closure || $this->businessHidden instanceof \Closure)) { - throw new LogicException('preserveVisibility() cannot be combined with andVisibleWhen()/andHiddenWhen().'); - } - - if ($this->preserveVisibility && $this->tenantResolverMode !== self::TENANT_RESOLVER_FILAMENT) { - throw new LogicException('preserveVisibility() is allowed only for tenant-scoped (tenantFromFilament) surfaces.'); - } - } - - private function isDisabled(?Model $record = null): bool - { - $user = auth()->user(); - - if (! ($user instanceof User)) { - return true; - } - - $tenant = $this->resolveTenant($record); - - if (! ($tenant instanceof Tenant)) { - return true; - } - - if (! $this->isMemberOfTenant($user, $tenant)) { - return true; - } - - return ! Gate::forUser($user)->allows($this->capability, $tenant); - } - - private function disabledTooltip(?Model $record = null): ?string - { - $user = auth()->user(); - - if (! ($user instanceof User)) { - return null; - } - - $tenant = $this->resolveTenant($record); - - if (! ($tenant instanceof Tenant)) { - return null; - } - - if (! $this->isMemberOfTenant($user, $tenant)) { - return null; - } - - if (Gate::forUser($user)->allows($this->capability, $tenant)) { - return null; - } - - return UiTooltips::insufficientPermission(); - } - - private function bulkIsDisabled(Collection $records): bool - { - $user = auth()->user(); - - if (! ($user instanceof User)) { - return true; - } - - return ! $this->bulkSelectionIsAuthorizedInternal($user, $records); - } - - private function bulkDisabledTooltip(Collection $records): ?string - { - $user = auth()->user(); - - if (! ($user instanceof User)) { - return null; - } - - if ($this->bulkSelectionIsAuthorizedInternal($user, $records)) { - return null; - } - - return UiTooltips::insufficientPermission(); - } - - private function bulkSelectionIsAuthorizedInternal(User $user, Collection $records): bool - { - if ($this->bulkPreflightMode === self::BULK_PREFLIGHT_CUSTOM && $this->bulkPreflight instanceof \Closure) { - return (bool) ($this->bulkPreflight)($records); - } - - $tenantIds = $this->resolveTenantIdsForRecords($records); - - if ($tenantIds === []) { - return false; - } - - return match ($this->bulkPreflightMode) { - self::BULK_PREFLIGHT_TENANT_MEMBERSHIP => count($this->membershipTenantIds($user, $tenantIds)) === count($tenantIds), - self::BULK_PREFLIGHT_CAPABILITY => count($this->capabilityTenantIds($user, $tenantIds)) === count($tenantIds), - default => false, - }; - } - - /** - * @param Collection $records - * @return array - */ - private function resolveTenantIdsForRecords(Collection $records): array - { - if ($this->tenantResolverMode === self::TENANT_RESOLVER_FILAMENT) { - $tenant = Filament::getTenant(); - - return $tenant instanceof Tenant ? [(int) $tenant->getKey()] : []; - } - - if ($this->tenantResolverMode === self::TENANT_RESOLVER_RECORD) { - $ids = $records - ->filter(fn (Model $record): bool => $record instanceof Tenant) - ->map(fn (Tenant $tenant): int => (int) $tenant->getKey()) - ->all(); - - return array_values(array_unique($ids)); - } - - if ($this->tenantResolverMode === self::TENANT_RESOLVER_CUSTOM && $this->customTenantResolver instanceof \Closure) { - $ids = []; - - foreach ($records as $record) { - if (! ($record instanceof Model)) { - continue; - } - - $resolved = ($this->customTenantResolver)($record); - - if ($resolved instanceof Tenant) { - $ids[] = (int) $resolved->getKey(); - continue; - } - - if (is_int($resolved)) { - $ids[] = $resolved; - } - } - - return array_values(array_unique($ids)); - } - - return []; - } - - private function isMember(?Model $record = null): bool - { - $user = auth()->user(); - - if (! ($user instanceof User)) { - return false; - } - - $tenant = $this->resolveTenant($record); - - if (! ($tenant instanceof Tenant)) { - return false; - } - - return $this->isMemberOfTenant($user, $tenant); - } - - private function isMemberOfTenant(User $user, Tenant $tenant): bool - { - return Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant); - } - - private function resolveTenant(?Model $record = null): ?Tenant - { - return match ($this->tenantResolverMode) { - self::TENANT_RESOLVER_FILAMENT => Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null, - self::TENANT_RESOLVER_RECORD => $record instanceof Tenant ? $record : null, - self::TENANT_RESOLVER_CUSTOM => $this->resolveTenantViaCustomResolver($record), - default => null, - }; - } - - private function resolveTenantViaCustomResolver(?Model $record): ?Tenant - { - if (! ($this->customTenantResolver instanceof \Closure)) { - return null; - } - - if (! ($record instanceof Model)) { - return null; - } - - $resolved = ($this->customTenantResolver)($record); - - if ($resolved instanceof Tenant) { - return $resolved; - } - - return null; - } - - /** - * @param array $tenantIds - * @return array - */ - private function membershipTenantIds(User $user, array $tenantIds): array - { - /** @var array $ids */ - $ids = DB::table('tenant_memberships') - ->where('user_id', (int) $user->getKey()) - ->whereIn('tenant_id', $tenantIds) - ->pluck('tenant_id') - ->map(fn ($id): int => (int) $id) - ->all(); - - return array_values(array_unique($ids)); - } - - /** - * @param array $tenantIds - * @return array - */ - private function capabilityTenantIds(User $user, array $tenantIds): array - { - $roles = RoleCapabilityMap::rolesWithCapability($this->capability); - - if ($roles === []) { - return []; - } - - /** @var array $ids */ - $ids = DB::table('tenant_memberships') - ->where('user_id', (int) $user->getKey()) - ->whereIn('tenant_id', $tenantIds) - ->whereIn('role', $roles) - ->pluck('tenant_id') - ->map(fn ($id): int => (int) $id) - ->all(); - - return array_values(array_unique($ids)); - } -} diff --git a/app/Support/OperationCatalog.php b/app/Support/OperationCatalog.php index d0009ba..67ed751 100644 --- a/app/Support/OperationCatalog.php +++ b/app/Support/OperationCatalog.php @@ -32,6 +32,8 @@ public static function labels(): array 'backup_schedule_retention' => 'Backup schedule retention', 'backup_schedule_purge' => 'Backup schedule purge', 'restore.execute' => 'Restore execution', + 'assignments.fetch' => 'Assignment fetch', + 'assignments.restore' => 'Assignment restore', 'directory_role_definitions.sync' => 'Role definitions sync', 'restore_run.delete' => 'Delete restore runs', 'restore_run.restore' => 'Restore restore runs', @@ -64,6 +66,7 @@ public static function expectedDurationSeconds(string $operationType): ?int 'compliance.snapshot' => 180, 'entra_group_sync' => 120, 'drift_generate_findings' => 240, + 'assignments.fetch', 'assignments.restore' => 60, default => null, }; } diff --git a/app/Support/Rbac/UiEnforcement.php b/app/Support/Rbac/UiEnforcement.php index a1ef87d..769d456 100644 --- a/app/Support/Rbac/UiEnforcement.php +++ b/app/Support/Rbac/UiEnforcement.php @@ -188,6 +188,39 @@ public function apply(): Action|BulkAction return $this->action; } + /** + * Evaluate whether a bulk selection is authorized (all-or-nothing). + * + * - If any selected tenant is not a membership: false. + * - If all are memberships but any lacks capability: false. + */ + public function bulkSelectionIsAuthorized(User $user, Collection $records): bool + { + $tenantIds = $this->resolveTenantIdsFromRecords($records); + + if ($tenantIds === []) { + return false; + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + $resolver->primeMemberships($user, $tenantIds); + + foreach ($tenantIds as $tenantId) { + $tenant = $this->makeTenantStub($tenantId); + + if (! $resolver->isMember($user, $tenant)) { + return false; + } + + if ($this->capability !== null && ! $resolver->can($user, $tenant, $this->capability)) { + return false; + } + } + + return true; + } + /** * Hide action for non-members. * @@ -286,6 +319,19 @@ private function applyDisabledState(): void $tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission(); $this->action->disabled(function (?Model $record = null) { + if ($this->isBulk && $this->action instanceof BulkAction) { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + /** @var Collection $records */ + $records = collect($this->action->getSelectedRecords()); + + return ! $this->bulkSelectionIsAuthorized($user, $records); + } + $context = $this->resolveContextWithRecord($record); // Non-members are hidden, so this only affects members @@ -298,6 +344,23 @@ private function applyDisabledState(): void // Only show tooltip when actually disabled $this->action->tooltip(function (?Model $record = null) use ($tooltip) { + if ($this->isBulk && $this->action instanceof BulkAction) { + $user = auth()->user(); + + if (! $user instanceof User) { + return $tooltip; + } + + /** @var Collection $records */ + $records = collect($this->action->getSelectedRecords()); + + if (! $this->bulkSelectionIsAuthorized($user, $records)) { + return $tooltip; + } + + return null; + } + $context = $this->resolveContextWithRecord($record); if ($context->isMember && ! $context->hasCapability) { @@ -332,6 +395,21 @@ private function applyDestructiveConfirmation(): void private function applyServerSideGuard(): void { $this->action->before(function (?Model $record = null): void { + if ($this->isBulk && $this->action instanceof BulkAction) { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + /** @var Collection $records */ + $records = collect($this->action->getSelectedRecords()); + + $this->authorizeBulkSelectionOrAbort($user, $records); + + return; + } + $context = $this->resolveContextWithRecord($record); // Non-member → 404 (deny-as-not-found) @@ -346,6 +424,99 @@ private function applyServerSideGuard(): void }); } + /** + * Server-side enforcement for bulk selections. + * + * - If any selected tenant is not a membership: 404 (deny-as-not-found). + * - If all are memberships but any lacks capability: 403. + */ + private function authorizeBulkSelectionOrAbort(User $user, Collection $records): void + { + $tenantIds = $this->resolveTenantIdsFromRecords($records); + + if ($tenantIds === []) { + abort(403); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + $resolver->primeMemberships($user, $tenantIds); + + foreach ($tenantIds as $tenantId) { + $tenant = $this->makeTenantStub($tenantId); + + if (! $resolver->isMember($user, $tenant)) { + abort(404); + } + } + + if ($this->capability === null) { + return; + } + + foreach ($tenantIds as $tenantId) { + $tenant = $this->makeTenantStub($tenantId); + + if (! $resolver->can($user, $tenant, $this->capability)) { + abort(403); + } + } + } + + /** + * @param Collection $records + * @return array + */ + private function resolveTenantIdsFromRecords(Collection $records): array + { + $tenantIds = []; + + foreach ($records as $record) { + if ($record instanceof Tenant) { + $tenantIds[] = (int) $record->getKey(); + + continue; + } + + if ($record instanceof Model) { + $tenantId = $record->getAttribute('tenant_id'); + if ($tenantId !== null) { + $tenantIds[] = (int) $tenantId; + + continue; + } + + if (method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) { + $relatedTenant = $record->getRelation('tenant'); + + if ($relatedTenant instanceof Tenant) { + $tenantIds[] = (int) $relatedTenant->getKey(); + + continue; + } + } + } + } + + if ($tenantIds === []) { + $tenant = Filament::getTenant(); + if ($tenant instanceof Tenant) { + $tenantIds[] = (int) $tenant->getKey(); + } + } + + return array_values(array_unique($tenantIds)); + } + + private function makeTenantStub(int $tenantId): Tenant + { + $tenant = new Tenant; + $tenant->forceFill(['id' => $tenantId]); + $tenant->exists = true; + + return $tenant; + } + /** * Resolve the current access context with an optional record. */ diff --git a/routes/web.php b/routes/web.php index e84aa68..c92d6c9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -117,7 +117,7 @@ return $workspace; }); -Route::middleware(['web', 'auth', 'ensure-workspace-member']) +Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-member']) ->prefix('/admin/w/{workspace}') ->group(function (): void { Route::get('/', fn () => redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => request()->route('workspace')]))