fix(backups): enforce authorization and safe retention

- Gate volume backup retention and S3 controls by update permission
- Preserve backup records when S3 deletion fails
- Share SFTP download streaming with consistent missing-file handling
- Handle schedule creation errors and link service database backups
This commit is contained in:
Andras Bacsai committed 2026-07-19 23:15:55 +02:00
1 parent 99a8a96e7f
commit 72a0a57f0e
13 files changed
+433 -112

No files matched your search

@@ -13,6 +13,7 @@ use App\Models\Service;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\MessageBag;
use OpenApi\Attributes as OA;
use RuntimeException;
@@ -161,6 +162,26 @@ class VolumeBackupsController extends Controller
return response()->json(['message' => 'Storage not found.'], 404);
}
['errors' => $errors, 's3Storage' => $s3Storage, 'saveToS3' => $saveToS3] = $this->validateUpsertRequest($request, $storage, $teamId);
if ($errors->isNotEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
return $this->persistSchedule($request, $storage, $teamId, $s3Storage, $saveToS3, $resourceType, $resource);
}
/**
* @return array{errors: MessageBag, s3Storage: S3Storage|null, saveToS3: bool}
*/
private function validateUpsertRequest(
Request $request,
LocalPersistentVolume|LocalFileVolume $storage,
int|string $teamId,
): array {
$validator = customApiValidator($request->all(), [
'frequency' => 'required|string|max:255',
'enabled' => 'boolean',
@@ -223,13 +244,22 @@ class VolumeBackupsController extends Controller
$errors->add('storage_uuid', 'Only directory file storages can be backed up.');
}
if ($errors->isNotEmpty()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
return [
'errors' => $errors,
's3Storage' => $s3Storage,
'saveToS3' => $saveToS3,
];
}
private function persistSchedule(
Request $request,
LocalPersistentVolume|LocalFileVolume $storage,
int|string $teamId,
?S3Storage $s3Storage,
bool $saveToS3,
string $resourceType,
Model $resource,
): JsonResponse {
$backup = $storage->scheduledBackups()->updateOrCreate([], [
'team_id' => $teamId,
'frequency' => $request->string('frequency')->toString(),
@@ -85,22 +85,26 @@ class Create extends Component
return;
}
$backup = $target->scheduledBackups()->updateOrCreate(
[],
[
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
'enabled' => true,
],
);
try {
$backup = $target->scheduledBackups()->updateOrCreate(
[],
[
'team_id' => currentTeam()->id,
'frequency' => $this->frequency,
'enabled' => true,
],
);
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
$this->redirectRoute('project.application.backup.show', [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
'application_uuid' => $this->application->uuid,
'backup_uuid' => $backup->uuid,
], navigate: true);
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
redirectRoute($this, 'project.application.backup.show', [
'project_uuid' => $this->application->project()->uuid,
'environment_uuid' => $this->application->environment->uuid,
'application_uuid' => $this->application->uuid,
'backup_uuid' => $backup->uuid,
]);
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
+18 -1
View File
@@ -84,7 +84,24 @@ class FileStorage extends Component
$this->hasEnabledBackup = $backup?->enabled ?? false;
$this->backupUrl = null;
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
if (! $this->hasEnabledBackup) {
return;
}
if ($this->resource instanceof ServiceDatabase) {
$this->backupUrl = route('project.service.database.backups', [
'project_uuid' => $this->resource->service->project()->uuid,
'environment_uuid' => $this->resource->service->environment->uuid,
'service_uuid' => $this->resource->service->uuid,
'stack_service_uuid' => $this->resource->uuid,
]);
return;
}
if (! $this->resource instanceof Application) {
$this->hasEnabledBackup = false;
return;
}
@@ -9,6 +9,8 @@ use App\Models\LocalPersistentVolume;
use App\Models\S3Storage;
use App\Models\ScheduledVolumeBackup;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\WithPagination;
@@ -178,13 +180,13 @@ class VolumeBackups extends Component
$this->dispatch('success', $this->enabled ? 'Storage backups enabled.' : 'Storage backups disabled.');
}
public function backupNow()
public function backupNow(): Redirector|RedirectResponse|null
{
$this->authorize('update', $this->resource);
if (! $this->backup) {
if (! $this->validateSettings()) {
return;
return null;
}
$this->enabled = false;
@@ -202,7 +204,7 @@ class VolumeBackups extends Component
]);
}
public function delete(?string $password = null, array $selectedActions = [])
public function delete(?string $password = null, array $selectedActions = []): bool|string
{
$this->authorize('update', $this->resource);