$policyIds */ public function createBackupSet( Tenant $tenant, array $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, ): BackupSet { $this->assertActiveTenant($tenant); $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) ->get(); $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name) { $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup', 'created_by' => $actorEmail, 'status' => 'running', 'metadata' => [], ]); $failures = []; $itemsCreated = 0; foreach ($policies as $policy) { [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); if ($failure !== null) { $failures[] = $failure; continue; } if ($item !== null) { $itemsCreated++; } } $status = $this->resolveStatus($itemsCreated, $failures); $backupSet->update([ 'status' => $status, 'item_count' => $itemsCreated, 'completed_at' => CarbonImmutable::now(), 'metadata' => ['failures' => $failures], ]); return $backupSet->refresh(); }); $this->auditLogger->log( tenant: $tenant, action: 'backup.created', context: [ 'metadata' => [ 'backup_set_id' => $backupSet->id, 'item_count' => $backupSet->item_count, 'status' => $backupSet->status, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'backup_set', resourceId: (string) $backupSet->id, status: $backupSet->status === 'completed' ? 'success' : 'partial' ); return $backupSet; } /** * Add snapshots for additional policies to an existing backup set. * * @param array $policyIds */ public function addPoliciesToSet( Tenant $tenant, BackupSet $backupSet, array $policyIds, ?string $actorEmail = null, ?string $actorName = null, ): BackupSet { $this->assertActiveTenant($tenant); if ($backupSet->trashed() || $backupSet->tenant_id !== $tenant->id) { throw new \RuntimeException('Backup set is archived or does not belong to the current tenant.'); } $existingPolicyIds = $backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all(); $policyIds = array_values(array_diff($policyIds, $existingPolicyIds)); if (empty($policyIds)) { return $backupSet->refresh(); } $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) ->get(); $metadata = $backupSet->metadata ?? []; $failures = $metadata['failures'] ?? []; $itemsCreated = 0; foreach ($policies as $policy) { [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); if ($failure !== null) { $failures[] = $failure; continue; } if ($item !== null) { $itemsCreated++; } } $status = $this->resolveStatus($itemsCreated, $failures); $backupSet->update([ 'status' => $status, 'item_count' => $backupSet->items()->count(), 'completed_at' => CarbonImmutable::now(), 'metadata' => ['failures' => $failures], ]); $this->auditLogger->log( tenant: $tenant, action: 'backup.items_added', context: [ 'metadata' => [ 'backup_set_id' => $backupSet->id, 'added_count' => $itemsCreated, 'status' => $status, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'backup_set', resourceId: (string) $backupSet->id, status: $status === 'completed' ? 'success' : 'partial' ); return $backupSet->refresh(); } private function resolveStatus(int $itemsCreated, array $failures): string { return match (true) { $itemsCreated === 0 && count($failures) > 0 => 'failed', count($failures) > 0 => 'partial', default => 'completed', }; } /** * @return array{0:?BackupItem,1:?array{policy_id:int,reason:string,status:int|string|null}} */ private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $policy, ?string $actorEmail = null): array { $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $context = [ 'tenant' => $tenantIdentifier, 'policy_type' => $policy->policy_type, 'policy_id' => $policy->external_id, ]; $this->graphLogger->logRequest('get_policy', $context); try { $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $policy->platform, ]); } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); return [ null, [ 'policy_id' => $policy->id, 'reason' => $mapped->getMessage(), 'status' => $mapped->status, ], ]; } $this->graphLogger->logResponse('get_policy', $response, $context); $payload = $response->data['payload'] ?? $response->data; $metadata = Arr::except($response->data, ['payload']); if ($response->failed()) { $reason = $response->warnings[0] ?? 'Graph request failed'; $failure = [ 'policy_id' => $policy->id, 'reason' => $reason, 'status' => $response->status, ]; if (! config('graph.stub_on_failure')) { return [null, $failure]; } // Fallback to a stub payload for local/dev when Graph fails. $payload = [ 'id' => $policy->external_id, 'type' => $policy->policy_type, 'source' => 'stub', 'warning' => $reason, ]; $metadata['warnings'] = $response->warnings ?? [$reason]; } $backupItem = BackupItem::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'payload' => $payload, 'metadata' => $metadata, ]); $this->versionService->captureVersion( policy: $policy, payload: $payload, createdBy: $actorEmail, metadata: [ 'source' => 'backup', 'backup_set_id' => $backupSet->id, 'backup_item_id' => $backupItem->id, ] ); return [$backupItem, null]; } private function assertActiveTenant(Tenant $tenant): void { if (! $tenant->isActive()) { throw new \RuntimeException('Tenant is archived or inactive.'); } } }