Access Control (ACL)
Prezentare generală
Leto include un sistem ACL bazat pe permisiuni, nu pe roluri. Declari accesul prin atribute pe modele, iar ORM-ul verifică automat permisiunile utilizatorului curent la fiecare operațiune CRUD (find, get, create, update, delete). Utilizatorul este reprezentat de un AuthProvider, o interfață pe care o implementezi în aplicație.
#[ACLModel(read: ['user.read'], edit: ['user.edit'], create: ['user.create'], delete: ['user.delete'])]
#[Table('documents')]
class Document extends Model
{
#[ACLField(read: ['document.read'], edit: ['document.edit'])]
public string $title;
#[ACLField(read: ['admin.read'], edit: [])] // edit:[] = nimeni nu poate edita
public string $internal_notes;
}Configurare pas cu pas
1. Implementează AuthProvider
use Leto\ACL\AuthProvider;
class AppAuthProvider implements AuthProvider
{
public function checkPermission(?string $scope, string $permissionName): bool
{
return Session::user()?->hasPermission($scope, $permissionName) ?? false;
}
public function isSystem(): bool { return false; }
public function check(): bool { return Session::user() !== null; }
}2. Înregistrează provider-ul la bootstrap
// În index.php sau într-un middleware global
ACL::setProvider(new AppAuthProvider());3. Adaugă atribute pe modele
#[ACLDefaultScope('App')]
#[ACLModel(read: ['user.read'], create: ['user.create'])]
class User extends Model { ... }4. Gata
ACL-ul e acum activ automat la toate operațiunile CRUD pe modelele cu reguli. Restul paginii e referință: atributele disponibile, API-ul de verificare și regulile de rezoluție.
Tipuri de acces: AccessType
AccessType este o clasă cu constante de tip bitmask (nu un enum):
final class AccessType
{
public const int READ = 1;
public const int EDIT = 2;
public const int CREATE = 4;
public const int DELETE = 8;
}Le combini cu operatorul | în handleri:
#[ACLHandler(AccessType::READ | AccessType::EDIT)]
public function canAccess(): bool { ... }#[Table], #[ConnectionName], #[ACLModel]) NU se moștenesc. Dacă ai o clasă de bază (ex: BaseModel), fiecare subclasă își declară propriile atribute de clasă. Atributele de pe proprietăți (#[ACLField], #[PrimaryKey]) sunt vizibile prin moștenire.Atributele ACL
#[ACLModel]: reguli la nivel de clasă
#[ACLModel(
read: ['user.read'], // string[], default [] = nicio permisiune statică
edit: ['user.edit'],
create: ['user.create'],
delete: ['user.delete'],
match: MatchType::ANY, // ANY (default) sau ALL
)]Un model fără niciun atribut ACL este în mod liber: totul e permis. Din momentul în care are măcar o regulă (model, field, handler sau bulk handler), intră în mod restricționat: orice operațiune nedeclarată e implicit DENY.
#[ACLDefaultScope]: scope implicit pentru permisiuni
#[ACLDefaultScope('School')]
#[ACLModel(read: ['document.read'])]
class Document extends Model { ... }
// 'document.read' este evaluat ca 'School::document.read'
Când o permisiune nu conține prefix de scope, se adaugă automat scope-ul implicit al modelului. O permisiune individuală poate suprascrie scope-ul cu sintaxa Scope::name:
#[ACLField(read: ['AnotherScope::document.read'])] // ignoră ACLDefaultScope
#[ACLField]: reguli per câmp
#[ACLField(
read: ['document.read'], // null = moștenește din ACLModel, [] = deny explicit
edit: [], // [] = nimeni nu editează (read-only)
match: MatchType::ANY,
)]
public string $title;Dacă un câmp nu are #[ACLField], regula cascadează din #[ACLModel].
#[ACLHandler]: handler dinamic per instanță
Când regulile statice nu sunt suficiente (ex: “doar autorul poate edita”), scrii un handler. Primul parametru e tipul de acces; $fields (opțional) îl leagă de anumite câmpuri.
#[ACLModel(read: ['document.read'])]
class Document extends Model
{
public string $status = 'draft';
public int $owner_id;
#[ACLHandler(AccessType::READ | AccessType::EDIT)]
public function canAccess(): bool
{
if ($this->status === 'published') {
// Provider-ul curent e sursa ta de autentificare.
// Expune-i metodele proprii (ex: currentUserId()) în implementarea ta.
$provider = \Leto\ACL\ACL::getProvider();
return $provider !== null && $this->owner_id === $provider->currentUserId();
}
return true;
}
}Comportamentul depinde de nivel:
- Class-level (fără
$fields): handlerul poate atât acorda (true) cât și revoca (false) accesul, suprascriind regula statică. - Field-level (cu
$fields): rulează doar dacă regula statică a câmpului nu a decis deja (câmpul nu are#[ACLField]pentru acel acces, sau are o permisiune pe care userul nu o deține). Rezultatul e final și suprascrie cascada din#[ACLModel]: poate atât acorda cât și revoca.
#[ACLField(read: [])] sau edit: []) scurtcircuitează înaintea handlerului. Un handler field-level nu poate niciodată debloca un câmp []-deny.Handlerii se execută o singură dată per instanță: rezultatul e cache-uit, deci apelurile ulterioare (din views, de exemplu) nu re-execută metoda.
#[ACLBulkHandler]: handler pentru array de instanțe
Ca #[ACLHandler], dar primește un array de instanțe și returnează un array de bool în aceeași ordine. Se folosește când verifici acces pe o listă de modele deodată, ca să nu ruleze handlerul o dată per rând.
#[ACLBulkHandler(AccessType::READ | AccessType::EDIT)]
public static function bulkCanAccess(array $instances): array
{
return array_map(fn($doc) => $doc->status !== 'archived', $instances);
}Sintaxa permisiunilor: scope::permission
O permisiune poate include un prefix de scope, separat prin :::
| String permisiune | Scope | Nume |
|---|---|---|
'School::document.read' | 'School' | 'document.read' |
'document.read' | defaultScope (din #[ACLDefaultScope]) | 'document.read' |
Scope-ul explicit (Scope::name) are prioritate și ignoră defaultScope.
AuthProvider: cine e utilizatorul?
interface AuthProvider
{
public function checkPermission(?string $scope, string $permissionName): bool;
public function isSystem(): bool; // true = bypass ACL (cron, importuri)
public function check(): bool; // true = user autentificat
}checkPermission() primește scope-ul (sau null pentru permisiuni globale) și numele permisiunii, și decide dacă userul curent o are. Îl înregistrezi la începutul request-ului cu ACL::setProvider($provider).
MatchType: ANY vs ALL
#[ACLModel(read: ['document.read', 'admin.read'], match: MatchType::ALL)]
MatchType::ANY(default): e suficientă o permisiune din listă.MatchType::ALL: sunt necesare toate permisiunile din listă.
API-ul public
Verificări (returnează bool)
ACL::canRead($model, $field); // string|object|array, string|array|null → bool|bool[]
ACL::canEdit($model, $field);
ACL::canCreate($model);
ACL::canDelete($model);Argumentul poate fi o clasă (Document::class, gate la nivel de clasă), o instanță ($document, verificare + handler), sau un array de instanțe ([$a, $b], returnează bool[] și folosește BulkHandler dacă există).
Verificări all-or-nothing (returnează un singur bool)
// canRead pe mai multe câmpuri returnează un array asociativ
ACL::canRead($doc, ['title', 'body']); // ['title' => true, 'body' => false]
// canReadAll / canEditAll colapsează într-un singur bool (true doar dacă TOATE trec)
ACL::canReadAll($doc, ['title', 'body']); // false
ACL::canEditAll($doc, ['title', 'body']);Enforce (aruncă ACLException)
attempt*() deleagă către can*() și aruncă Leto\ACL\ACLException (extinde \RuntimeException) dacă accesul e refuzat:
use Leto\ACL\ACLException;
try {
ACL::attemptEdit($document, ['title', 'body']);
} catch (ACLException $e) {
Response::json(['error' => 'Acces interzis'], 403)->send();
}Disponibile: attemptRead, attemptEdit, attemptCreate, attemptDelete.
Middleware
$middleware = ACL::middleware('read', ['title', 'body'], fn($req) => Document::find($req->id));Middleware-ul folosește canReadAll/canEditAll pentru o singură decizie binară pe câmpuri multiple.
Reguli de rezoluție
Mod liber vs. mod restricționat
// Model FĂRĂ nicio regulă ACL → mod liber
class Comment extends Model
{
public string $body;
}
ACL::canRead($comment, 'body'); // ✅ true (totul e permis)
// Model CU cel puțin o regulă ACL → mod restricționat
#[ACLModel(read: ['document.read'])]
class Student extends Model
{
#[ACLField(read: ['secretariat'])]
public string $nin;
public string $phone; // fără #[ACLField]
}
ACL::canRead($student, 'nin'); // depinde de permisiunea 'secretariat'
ACL::canRead($student, 'phone'); // ❌ false: câmp nedeclarat, cascada din #[ACLModel] nu se potrivește
Permisiunea wildcard *
'*' într-o listă de permisiuni înseamnă oricine, inclusiv neautentificați:
#[ACLField(read: ['*'])] // oricine poate citi
public string $public_name;Comparativ:
[]: nimeni (DENY explicit).- Lipsa regulii: depinde de mod liber vs. restricționat (ALLOW pe model liber, DENY pe model restricționat).
Fără user autentificat
Dacă o regulă cere o permisiune și nu există user autentificat, rezultatul e DENY. Wildcard-ul * și handlerii care nu verifică autentificarea sunt singurele căi prin care un neautentificat primește acces.
Context de sistem → bypass
Dacă AuthProvider::isSystem() returnează true (CLI, cron, scheduler, token intern), ACL-ul e ocolit complet.
Tabel de adevăr
Pe modelul Student de mai sus, cu un handler field-level pentru phone:
| Utilizator | Câmp | Rezultat | Motiv |
|---|---|---|---|
| Secretară | nin | ✅ | are permisiunea secretariat |
| Profesor | nin | ❌ | nu are permisiunea |
| Părinte (propriul copil) | phone | ✅ | handlerul returnează true |
| Părinte (alt copil) | phone | ❌ | handlerul returnează false |
| Neautentificat | nin | ❌ | ACL există, fără user → DENY |
| Oricine | câmp cu read: ['*'] | ✅ | wildcard |
| Sistem (cron) | orice | ✅ | isSystem() → bypass |
Comportamente utile
edit: [] vs. omiterea câmpului
#[ACLField(edit: [])]: blochează explicit cascada din#[ACLModel], câmpul devine read-only.#[ACLField(read: [...])]fărăedit:editrămânenull, deci cascada din model se aplică.
ACL pe relații și computable
ACL-ul nu se aplică pe relații (#[BelongsTo], etc.) sau pe atribute calculate (#[Computable]). Ca să restricționezi accesul la o relație, pui reguli direct pe entitatea țintă.
Protecția PK-urilor auto-increment
Independent de ACL, ORM-ul blochează modificarea coloanelor PK auto-increment: $model->id = 999; $model->update() aruncă excepție chiar și pe modele fără ACL.
User::where(...)->update() și User::where(...)->delete()) sar complet peste ACL (și peste hook-uri). Doar modul single-entity ($user->update() pe o instanță) trece prin verificări. Dacă ai nevoie de ACL pe bulk, iterează manual entitățile.Modificări trusted: updateTrusted()
updateTrusted() (din trait-ul ACLCapability, deja inclus în Model) salvează o instanță ocolind ACL-ul. E util pentru cod intern (hook-uri, importuri, sincronizări) care trebuie să scrie câmpuri read-only fără a acorda permisiuni utilizatorului.
#[ACLModel(edit: ['invoice.edit'])]
class Invoice extends Model
{
#[Decimal(10, 2)]
#[ACLField(edit: [])] // read-only pentru utilizatori
public Number $total;
#[BeforeUpdate]
protected function recomputeTotal(): void
{
// Hook-ul e trusted: poate scrie 'total' chiar dacă e read-only pentru user
$this->total = $this->unit_price * $this->quantity;
}
}
// Input de la utilizator: ACL blochează scrierea directă pe 'total'
$invoice->total = new Number('100.00');
$invoice->update(); // → ACLException
// Cod intern trusted: ocolește ACL
$invoice->total = new Number('999.99');
$invoice->updateTrusted(); // salvat fără eroare
updateTrusted() doar în context trusted (hook-uri, cron-uri, logică internă). Nu-l expune în controllere care procesează input de la utilizator.Limitări
- ACL funcționează doar pe Model: pe
stdClasssau clase fărăextends ModelaruncăLeto\ACL\ACLException. - Regulile statice (
#[ACLModel],#[ACLField],#[ACLDefaultScope]) sunt precalculate ladescribe()și rămân fixe per clasă.
Rezumat atribute
| Atribut | Pe | Descriere |
|---|---|---|
#[ACLModel(read, edit, create, delete)] | clasă | Reguli implicite pentru toate câmpurile |
#[ACLDefaultScope('ScopeName')] | clasă | Scope implicit pentru permisiunile fără prefix |
#[ACLField(read, edit)] | proprietate | Reguli per câmp; null = moștenește din model |
#[ACLHandler(access, ?fields)] | metodă | Handler dinamic per instanță (cache-uit) |
#[ACLBulkHandler(access, ?fields)] | metodă statică | Handler dinamic pentru array de instanțe |