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
154 lines
5.4 KiB
PHP
154 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\FindingResource\Pages;
|
|
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\Finding;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Rbac\UiEnforcement;
|
|
use App\Support\Rbac\UiTooltips;
|
|
use Filament\Actions;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ListRecords;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Arr;
|
|
|
|
class ListFindings extends ListRecords
|
|
{
|
|
protected static string $resource = FindingResource::class;
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
UiEnforcement::forAction(
|
|
Actions\Action::make('acknowledge_all_matching')
|
|
->label('Acknowledge all matching')
|
|
->icon('heroicon-o-check')
|
|
->color('gray')
|
|
->requiresConfirmation()
|
|
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
|
->modalDescription(function (): string {
|
|
$count = $this->getAllMatchingCount();
|
|
|
|
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
|
})
|
|
->form(function (): array {
|
|
$count = $this->getAllMatchingCount();
|
|
|
|
if ($count <= 100) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
TextInput::make('confirmation')
|
|
->label('Type ACKNOWLEDGE to confirm')
|
|
->required()
|
|
->in(['ACKNOWLEDGE'])
|
|
->validationMessages([
|
|
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
|
]),
|
|
];
|
|
})
|
|
->action(function (array $data): void {
|
|
$query = $this->buildAllMatchingQuery();
|
|
$count = (clone $query)->count();
|
|
|
|
if ($count === 0) {
|
|
Notification::make()
|
|
->title('No matching findings')
|
|
->body('There are no new findings matching the current filters.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$updated = $query->update([
|
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
|
'acknowledged_at' => now(),
|
|
'acknowledged_by_user_id' => auth()->id(),
|
|
]);
|
|
|
|
$this->deselectAllTableRecords();
|
|
$this->resetPage();
|
|
|
|
Notification::make()
|
|
->title('Bulk acknowledge completed')
|
|
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
|
->success()
|
|
->send();
|
|
})
|
|
)
|
|
->preserveVisibility()
|
|
->requireCapability(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE)
|
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
->apply(),
|
|
];
|
|
}
|
|
|
|
protected function buildAllMatchingQuery(): Builder
|
|
{
|
|
$query = Finding::query();
|
|
|
|
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
|
|
|
if (! is_numeric($tenantId)) {
|
|
return $query->whereRaw('1 = 0');
|
|
}
|
|
|
|
$query->where('tenant_id', (int) $tenantId);
|
|
|
|
$query->where('status', Finding::STATUS_NEW);
|
|
|
|
$findingType = $this->getFindingTypeFilterValue();
|
|
if (is_string($findingType) && $findingType !== '') {
|
|
$query->where('finding_type', $findingType);
|
|
}
|
|
|
|
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
|
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
|
if (is_string($scopeKey) && $scopeKey !== '') {
|
|
$query->where('scope_key', $scopeKey);
|
|
}
|
|
|
|
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
|
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
|
|
if (is_numeric($baselineRunId)) {
|
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
|
}
|
|
|
|
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
|
if (is_numeric($currentRunId)) {
|
|
$query->where('current_run_id', (int) $currentRunId);
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
protected function getAllMatchingCount(): int
|
|
{
|
|
return (int) $this->buildAllMatchingQuery()->count();
|
|
}
|
|
|
|
protected function getStatusFilterValue(): string
|
|
{
|
|
$state = $this->getTableFilterState('status') ?? [];
|
|
$value = Arr::get($state, 'value');
|
|
|
|
return is_string($value) && $value !== ''
|
|
? $value
|
|
: Finding::STATUS_NEW;
|
|
}
|
|
|
|
protected function getFindingTypeFilterValue(): string
|
|
{
|
|
$state = $this->getTableFilterState('finding_type') ?? [];
|
|
$value = Arr::get($state, 'value');
|
|
|
|
return is_string($value) && $value !== ''
|
|
? $value
|
|
: Finding::FINDING_TYPE_DRIFT;
|
|
}
|
|
}
|