TenantAtlas/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php
ahmido 6a86c5901a 066-rbac-ui-enforcement-helper (#81)
Kontext / Ziel
Diese PR standardisiert Tenant‑RBAC Enforcement in der Filament‑UI: statt ad-hoc Gate::*, abort_if/abort_unless und kopierten ->visible()/->disabled()‑Closures gibt es jetzt eine zentrale, wiederverwendbare Implementierung für Actions (Header/Table/Bulk).

Links zur Spec:

spec.md
plan.md
quickstart.md
Was ist drin
Neue zentrale Helper-API: UiEnforcement (Tenant-plane RBAC‑UX “source of truth” für Filament Actions)
Standardisierte Tooltip-Texte und Context-DTO (UiTooltips, TenantAccessContext)
Migration vieler tenant‑scoped Filament Action-Surfaces auf das Standardpattern (ohne ad-hoc Auth-Patterns)
CI‑Guard (Test) gegen neue ad-hoc Patterns in app/Filament/**:
verbietet Gate::allows/denies/check/authorize, use Illuminate\Support\Facades\Gate, abort_if/abort_unless
Legacy-Allowlist ist aktuell leer (neue Verstöße failen sofort)
RBAC-UX Semantik (konsequent & testbar)
Non-member: UI Actions hidden (kein Tenant‑Leak); Execution wird blockiert (Filament hidden→disabled chain), Defense‑in‑depth enthält zusätzlich serverseitige Guards.
Member ohne Capability: Action visible aber disabled + Standard-Tooltip; Execution wird blockiert (keine Side Effects).
Member mit Capability: Action enabled und ausführbar.
Destructive actions: über ->destructive() immer mit ->requiresConfirmation() + klare Warntexte (Execution bleibt über ->action(...)).
Wichtig: In Filament v5 sind hidden/disabled Actions typischerweise “silently blocked” (200, keine Ausführung). Die Tests prüfen daher UI‑State + “no side effects”, nicht nur HTTP‑Statuscodes.

Sicherheit / Scope
Keine neuen DB-Tabellen, keine Migrations, keine Microsoft Graph Calls (DB‑only bei Render; kein outbound HTTP).
Tenant Isolation bleibt Isolation‑Boundary (deny-as-not-found auf Tenant‑Ebene, Capability erst nach Membership).
Kein Asset-Setup erforderlich; keine neuen Filament Assets.
Compliance Notes (Repo-Regeln)
Filament v5 / Livewire v4.0+ kompatibel.
Keine Änderungen an Provider‑Registrierung (Laravel 11+/12: providers.php bleibt der Ort; hier unverändert).
Global Search: keine gezielte Änderung am Global‑Search-Verhalten in dieser PR.
Tests / Qualität
Pest Feature/Unit Tests für Member/Non-member/Tooltip/Destructive/Regression‑Guard.
Guard-Test: “No ad-hoc Filament auth patterns”.
Full suite laut Tasks: vendor/bin/sail artisan test --compact → 837 passed, 5 skipped.
Checklist: requirements.md vollständig (16/16).
Review-Fokus
API‑Usage in neuen/angepassten Filament Actions: UiEnforcement::forAction/forTableAction/forBulkAction(...)->requireCapability(...)->apply()
Guard-Test soll “red” werden, sobald jemand neue ad-hoc Auth‑Patterns einführt (by design).

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #81
2026-01-30 16:58:02 +00:00

167 lines
4.3 KiB
PHP

<?php
namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Livewire\Attributes\On;
class CreateRestoreRun extends CreateRecord
{
use HasWizard;
protected static string $resource = RestoreRunResource::class;
protected function authorizeAccess(): void
{
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
abort(404);
}
$capabilityResolver = app(CapabilityResolver::class);
if (! $capabilityResolver->isMember($user, $tenant)) {
abort(404);
}
if (! $capabilityResolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
}
public function getSteps(): array
{
return RestoreRunResource::getWizardSteps();
}
protected function afterFill(): void
{
$backupSetIdRaw = request()->query('backup_set_id');
if (! is_numeric($backupSetIdRaw)) {
return;
}
$backupSetId = (int) $backupSetIdRaw;
if ($backupSetId <= 0) {
return;
}
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$belongsToTenant = BackupSet::query()
->where('tenant_id', $tenant->id)
->whereKey($backupSetId)
->exists();
if (! $belongsToTenant) {
return;
}
$backupItemIds = $this->normalizeBackupItemIds(request()->query('backup_item_ids'));
$scopeModeRaw = request()->query('scope_mode');
$scopeMode = in_array($scopeModeRaw, ['all', 'selected'], true)
? $scopeModeRaw
: ($backupItemIds !== [] ? 'selected' : 'all');
$this->data['backup_set_id'] = $backupSetId;
$this->form->callAfterStateUpdated('data.backup_set_id');
$this->data['scope_mode'] = $scopeMode;
$this->form->callAfterStateUpdated('data.scope_mode');
if ($scopeMode === 'selected') {
if ($backupItemIds !== []) {
$this->data['backup_item_ids'] = $backupItemIds;
}
$this->form->callAfterStateUpdated('data.backup_item_ids');
}
}
/**
* @return array<int>
*/
private function normalizeBackupItemIds(mixed $raw): array
{
if (is_string($raw)) {
$raw = array_filter(array_map('trim', explode(',', $raw)));
}
if (! is_array($raw)) {
return [];
}
$itemIds = [];
foreach ($raw as $value) {
if (is_int($value) && $value > 0) {
$itemIds[] = $value;
continue;
}
if (is_string($value) && ctype_digit($value)) {
$itemId = (int) $value;
if ($itemId > 0) {
$itemIds[] = $itemId;
}
}
}
$itemIds = array_values(array_unique($itemIds));
sort($itemIds);
return $itemIds;
}
protected function getSubmitFormAction(): Action
{
return parent::getSubmitFormAction()
->label('Create restore run')
->icon('heroicon-o-check-circle');
}
protected function handleRecordCreation(array $data): Model
{
return RestoreRunResource::createRestoreRun($data);
}
#[On('entra-group-cache-picked')]
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
{
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
$this->data['check_summary'] = null;
$this->data['check_results'] = [];
$this->data['checks_ran_at'] = null;
$this->data['preview_summary'] = null;
$this->data['preview_diffs'] = [];
$this->data['preview_ran_at'] = null;
$this->form->fill($this->data);
if (method_exists($this, 'unmountAction')) {
$this->unmountAction();
}
}
}