Salt la conținut

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 { ... }
Atributele la nivel de clasă (#[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.
Un câmp cu deny explicit (#[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 permisiuneScopeNume
'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:

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:

UtilizatorCâmpRezultatMotiv
Secretarăninare permisiunea secretariat
Profesorninnu are permisiunea
Părinte (propriul copil)phonehandlerul returnează true
Părinte (alt copil)phonehandlerul returnează false
NeautentificatninACL există, fără user → DENY
Oricinecâmp cu read: ['*']wildcard
Sistem (cron)oriceisSystem() → 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: edit rămâne null, 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.

Operațiunile bulk (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
Folosește 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 stdClass sau clase fără extends Model aruncă Leto\ACL\ACLException.
  • Regulile statice (#[ACLModel], #[ACLField], #[ACLDefaultScope]) sunt precalculate la describe() și rămân fixe per clasă.

Rezumat atribute

AtributPeDescriere
#[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)]proprietateReguli 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