⌘K

Začínáme

Autorizace

Každé odmítnutí jde přes laravelí Gate — na co se ho ptá tabulka, formulář, akce a stránka, a kdy.

Na této stránce

Wire používá Laravel Gate a policies. Autorizace tak zůstává kompatibilní s nativními Laravel policies, Spatie Permission a balíčky, které registrují abilities do Gate.

Sdílená pravidla komponent

Sloupce, filtry, akce, pole a widgety mohou používat sdílené autorizační metody, když komponenta podporuje viditelnost nebo autorizaci.

Action::make('approve')
->label('Approve')
->authorize('approve')
->action(fn (Order $record) => $record->approve());
 
TextColumn::make('internal_note')
->label('Internal note')
->permission('orders.internal-notes.view');
 
SelectFilter::make('department_id')
->authorizeUsing(fn (User $user) => $user->is_admin);

Pořadí vyhodnocení:

Pravidlo Chování
Žádná autorizace nakonfigurovaná Povoleno
Žádný přihlášený uživatel Zamítnuto
authorizeUsing() Vlastní callback má prioritu
authorize() Kontroluje Laravel Gate ability
permission() Kontroluje permission řetězec přes Gate

Autorizace po záznamech

Callback authorizeUsing() dostává přihlášeného uživatele a — kde ho povrch má — záznam řádku, takže autorizaci lze zúžit na jednotlivé záznamy:

Action::make('approve')
->authorizeUsing(fn (User $user, $record) => $user->id === $record?->manager_id)
->action(fn (Order $record) => $record->approve());

Záznam je přítomný u řádkových akcí; je null u povrchů bez záznamu (strukturální viditelnost sloupce/filtru, pole, widgety), takže jednoargumentová closura fn ($user) => … zůstává platná všude.

Tohle řídí, zda celý sloupec/akce strukturálně existuje (vyhodnoceno jednou). Pro skrytí nebo redakci jedné buňky na řádek — např. zobrazit salary jen na záznamech, které uživatel smí vidět — použijte místo toho visibleForRecord() sloupce, který běží při renderu buňky se záznamem daného řádku:

TextColumn::make('salary')
->visibleForRecord(fn ($record) => auth()->user()->can('viewSalary', $record));

Policies tabulky

Zapněte kontroly policies na tabulce pomocí authorize().

use NyonCode\WireTable\Table;
 
public function table(Table $table): Table
{
return $table
->model(Order::class)
->authorize()
->columns([
// ...
]);
}

Wire kontroluje tyto policy metody, když jsou potřeba:

Schopnost tabulky Policy ability
Vytvořit záznam create
Aktualizovat záznam update
Smazat záznam delete
Zobrazit záznam view

Přepisy tabulky

Přepisy použijte, když tabulka potřebuje pravidla odlišná od policy modelu.

return $table
->model(Order::class)
->authorize()
->authorizeCreate(fn () => auth()->user()?->can('create', Order::class) ?? false)
->authorizeUpdate(fn (Order $record) => ! $record->is_locked)
->authorizeDelete(fn (Order $record) => $record->status === 'draft')
->authorizeView(fn (Order $record) => $record->tenant_id === auth()->user()?->tenant_id);

Každý přepis přijímá boolean nebo closuru.

Inline editace

Editovatelné sloupce mohou pro inline editaci vyžadovat Gate ability.

TextInputColumn::make('price')
->authorizeInline('orders.update-price');

Pokud uživatel neprojde kontrolou ability, sloupec zůstane viditelný, ale inline editace je zamítnuta.

Akce

Akce lze skrýt nebo zamítnout pomocí Gate abilities, permission řetězců nebo vlastních callbacků.

Action::make('refund')
->label('Refund')
->authorize('refund')
->visible(fn (Order $record) => $record->is_paid)
->requiresConfirmation()
->action(fn (Order $record) => $record->refund());

Pro jednoduché permission řetězce:

Action::make('export')
->permission('orders.export')
->action(fn () => $this->exportTable());

Formuláře

Formuláře mohou používat policies modelu pro create a update.

use NyonCode\WireForms\Forms\Form;
 
public function form(Form $form): Form
{
return $form
->model($this->user ?? User::class)
->authorize()
->schema([
// ...
]);
}

Když je authorize() zapnuto:

Stav formuláře Policy ability
Třída modelu nebo neuložený model create
Existující instance modelu update

Při zamítnutí je formulář read-only a nelze ho uložit.

Pro vlastní pravidla formuláře:

return $form
->model($this->user)
->authorizeUsing(fn (User $user) => $user->hasRole('editor'))
->schema([
// ...
]);

Sortable

Sortable operace by měly být chráněné v hoocích vaší Livewire komponenty.

public function beforeRowsReordered(array $orderedIds): void
{
$this->authorize('reorder', Task::class);
}

Hooky životního cyklu viz Sortable řazení řádků.

Související dokumentace

Dokument Co pokrývá
Core Akce Řádkové, hromadné, hlavičkové akce a modální akce
Přehled tabulek Nastavení tabulky a API na úrovni tabulky
Přehled formulářů Nastavení formuláře a chování ukládání
Audit Log Záznam změn modelů po úspěšné autorizaci

Multi-tenancy

Ve výchozím stavu vypnutá — většina aplikací má jednoho tenanta a scopovat je by byla WHERE klauzule koupená za nic. Jakmile je zapnutá, je striktní.

Multi-tenant aplikace jsou tři kusy a každý má vlastní stránku:

Kus Co dělá Kde
Tenantová zóna Dá firmu do URL, ověří proti ní člověka, vstoupí do ní a přenese ji do každého odkazu Tenancy
Data Omezí každý model vlastněný tenantem na firmu, do které se vstoupilo — řádky, nebo vlastní databáze tato sekce
Firmy Registrace firmy, její profil, pozvánky a správa členů Firmy

Zóna a data stačí; modul firem jsou obrazovky, které by si aplikace jinak psala sama.

// config/wire-core.php
'tenancy' => [
'enabled' => true,
'column' => 'tenant_id',
'isolation' => 'column', // or a class implementing IsolatesTenants
],

Aktuální tenant je ten, do kterého se vstoupilo (viz níže), a když se nevstoupilo nikam, žádný tenant není — se zapnutou tenancy to znamená prázdnou stránku. Aplikace bez tenanta v URL — třeba každý člověk v právě jedné firmě — odpoví jinak: naváže vlastní resolver, který výchozí úplně nahradí:

use NyonCode\WireCore\Core\Tenancy\Contracts\TenantResolver;
 
app()->bind(TenantResolver::class, fn () => new class implements TenantResolver {
public function resolve(): int|string|null
{
return auth()->user()?->tenant_id;
}
});

Pak označ modely, které tenantovi patří:

use NyonCode\WireCore\Core\Tenancy\Concerns\BelongsToTenant;
 
class Invoice extends Model
{
use BelongsToTenant;
}

Zapíná se pro každý model zvlášť, protože framework nemůže vědět, které z vašich tabulek tenant vlastní, a hádat by znamenalo hádat, kdo co smí vidět.

Vstup do tenanta

CurrentTenant drží tenanta, ve kterém se pracuje, a výchozí resolver ho čte — takže vstoupit do tenanta stačí k tomu, aby ho následoval každý scopovaný model. Pro request to udělá middleware tenantové zóny; job, příkaz, seeder nebo test to udělá přes runAs():

use NyonCode\WireCore\Core\Tenancy\Tenancy;
 
app(Tenancy::class)->runAs($company, function (Company $company) {
Invoice::query()->where('due_at', '<', now())->each->remind();
});

runAs() obnoví, co bylo aktuální předtím — jiného tenanta, nebo žádného —, i když callback vyhodí výjimku, a vnořená volání se rozbalí v pořadí. Držitel je scoped: request pod Octane i job ve frontě začínají bez tenanta, takže ten, kdo zapomene vstoupit, neuvidí žádné řádky, a ne řádky předchozího jobu.

Práce ve frontě si nese svého tenanta. Job zařazený uvnitř tenanta si do payloadu zapíše třídu a klíč tenanta a worker do něj před handle() vstoupí — takže notifikace, export nebo import spuštěný v Acme poběží v Acme, na workeru, který o requestu nic neví. Job zařazený mimo tenanta běží mimo něj a podle fail-safe nevidí žádné řádky. Job, jehož tenant byl mezitím smazán, selže, místo aby běžel v žádném. Job sync běží uvnitř requestu a vlastní tenant requestu se mu po doběhnutí vrátí.

Funguje i runAs($company, fn () => SendReport::dispatch()). dispatch() vrací PendingDispatch, který job zařadí, až zanikne, a vrácený z callbacku by zanikl až po opuštění tenanta — proto ho runAs() zahodí ještě uvnitř, job se zařadí v tenantovi a volání vrátí null.

Jak se tenanti od sebe oddělují, říká isolation. column — jedna databáze, sloupec tenanta — je výchozí a nepotřebuje nic kromě tenanta samotného; třída implementující IsolatesTenants (enter(Model $tenant), leave()) je způsob, jak připojit jinou strategii, mezi nimi databázi pro každého tenanta.

Databáze pro každého tenanta

Když smlouva žádá oddělení tenantů něčím víc než klauzulí WHERE, nastavte isolation na database. Každý tenant má pak vlastní databázi a hranicí je připojení, ne sloupec:

// config/wire-core.php
'tenancy' => [
'enabled' => true,
'isolation' => 'database',
'model' => App\Models\Company::class,
'database' => [
'connection' => 'tenant', // definované v config/database.php
'name' => 'tenant_{key}', // {key}, {slug}; u SQLite cesta k souboru
'admin_connection' => null, // kdo spouští CREATE DATABASE
'migrations' => 'database/migrations/tenant',
],
],

Modely tenanta použijí BelongsToTenantDatabase místo BelongsToTenant — bez sloupce a bez scope; čtou se přes připojení tenant, které vstup do tenanta nasměruje na jeho databázi. Firmy, uživatelé a kdo kam patří zůstávají na vlastním připojení aplikace.

php artisan wire:tenants:create acme globex # vytvořit a zmigrovat
php artisan wire:tenants:create --all --no-migrate
php artisan wire:tenants:migrate # každý tenant
php artisan wire:tenants:migrate --tenant=acme --fresh --seed

Databáze se vytváří přes schema builder administrativního připojení — CREATE DATABASE na MySQL a PostgreSQL, prázdný soubor na SQLite —, takže uživatel toho připojení na to musí mít právo. Bez vstupu do tenanta připojení tenant nejmenuje žádnou databázi a první dotaz vyhodí výjimku: stejný směr jako prázdný výsledek scope u sloupce, jen hlasitěji. Všechno ostatní — runAs(), tenantová zóna, joby ve frontě — funguje pod oběma stejně.

Fail-safe

Zapnutá tenancy bez resolvovaného tenanta vrací nic, nikdy vše.

To je celý bezpečnostní příběh v jedné větě. Každý běžný stav vyrobí null tenanta — před přihlášením, na queue workeru, v konzolovém příkazu — takže scope, který by null četl jako „bez omezení“, by každému z nich podal všechny řádky. Místo toho omezí na 0 = 1.

Záměrně to není ani where tenant_id is null: řádek, který nikdo nevlastní, by pak viděli všichni, což je tentýž únik v jiném kabátě.

Co je scopované

Globální scope, ne plugin hook — a ta volba je pointa. Hook pokryje jednu čtecí cestu, a jen když je zrovna navázaný plugin manager. Globální scope pokryje každý dotaz, který Eloquent postaví:

Scopované
Invoice::query(), výpis tabulky, relace ano
Invoice::find($id) ve vašem vlastním controlleru ano
->update() a ->delete() ano
Frontovaný job resolvující záznamy podle klíče ano
Invoice::create() připíše se aktuálnímu tenantovi

Create bez resolvovaného tenanta vyhodí výjimku místo zápisu řádku s null tenantem — takový řádek by pak byl neviditelný pro každý scopovaný dotaz, takže by uživatelova práce byla pryč a nikdo by to neřekl. Explicitně nastavený sloupec se nechá být, kvůli seederu nebo záměrnému přesunu mezi tenanty.

Sloupec je kvalifikovaný, protože scopovaný model se běžně joinuje a joinovaná tabulka mívá vlastní tenant_id.

Čtení napříč tenanty

Invoice::acrossAllTenants()->count();

Záměrně upovídané a snadno greppovatelné: admin report nebo konzolový příkaz má reálnou potřebu a revize musí najít každé místo, které si ji nárokovalo.

Zdroj, na který Eloquent nedosáhne

Non-Eloquent DataSource — CollectionDataSource, zdroj nad API — nestaví žádný Eloquent dotaz, takže na něj globální scope nedosáhne. Obalte ho:

use NyonCode\WireCore\Core\Tenancy\TenantScopedDataSource;
 
$source = new TenantScopedDataSource(new CollectionDataSource($rows), app(Tenancy::class));

Omezuje plán, ne řádky, které se vrátí — a právě proto je bezpečný na všech metodách, ne jen na té, která řádky vrací: count() a paginate() odpovídá zdroj, aniž by řádky vůbec vydal, a zdroj, který ten filtr neumí, ho hlasitě odmítne (UnsupportedQueryAspectException) místo aby vrátil řádky, které nikdo neomezil.

resolveRecord() bere klíč, ne plán, takže tam se záznam načte a zkontroluje — bez toho se tenant dostane na cizí řádek tím, že si do URL napíše jeho id.

Fail-safe je tentýž, jaký má TenantScope: tenancy zapnutá a žádný tenant rozpoznaný znamená nic. Vypnutá tenancy deleguje beze změny, takže obalení zdroje jednotenantovou aplikaci nic nestojí.

Třetím argumentem předáte sloupec tam, kde si ho zdroj pojmenoval jinak než konfigurace.