feat: Permission System Hướng B - Models, Command, User can(), session cache

This commit is contained in:
2026-04-29 08:25:37 +00:00
parent d2df9edd69
commit 40b75fcf75
15 changed files with 354 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Console\Commands;
use App\Models\PermissionModule;
use App\Models\RoleTemplate;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
class SyncPermissions extends Command
{
protected $signature = 'permissions:sync {--dry-run : Chỉ liệt kê, không lưu}';
protected $description = 'Quét Filament Resources và đồng bộ permission modules. Action mới mặc định TẮT.';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$this->info('Đang quét Filament Resources...');
$resourcesPath = app_path('Filament/Resources');
if (! is_dir($resourcesPath)) {
$this->error('Không tìm thấy thư mục Filament/Resources');
return self::FAILURE;
}
$modules = [];
$finder = new Finder();
$finder->files()->in($resourcesPath)->name('*Resource.php');
foreach ($finder as $file) {
$class = $this->getClassFromFile($file->getRealPath());
if (! $class || ! class_exists($class)) {
continue;
}
$ref = new ReflectionClass($class);
if (! $ref->hasProperty('permissionActions')) {
continue;
}
$prop = $ref->getProperty('permissionActions');
$prop->setAccessible(true);
$actions = $prop->getDefaultValue();
$label = 'Resource';
if ($ref->hasProperty('pluralModelLabel')) {
$labelProp = $ref->getProperty('pluralModelLabel');
$labelProp->setAccessible(true);
$label = $labelProp->getDefaultValue() ?? $label;
}
$moduleName = Str::snake(class_basename($class));
$moduleName = str_replace('_resource', '', $moduleName);
$modules[] = [
'module' => $moduleName,
'label' => $label,
'actions' => $actions ?? [],
];
}
if (empty($modules)) {
$this->warn('Không tìm thấy Resource nào có $permissionActions.');
return self::SUCCESS;
}
$this->info('Tìm thấy ' . count($modules) . ' module(s):');
foreach ($modules as $m) {
$this->line(" - {$m['module']}: " . implode(', ', $m['actions']));
}
if ($dryRun) {
$this->info('Dry-run: Không lưu thay đổi.');
return self::SUCCESS;
}
foreach ($modules as $m) {
$existing = PermissionModule::where('module', $m['module'])->first();
if ($existing) {
$oldActions = $existing->actions ?? [];
$newActions = $m['actions'];
if ($oldActions === $newActions) {
continue;
}
$added = array_diff($newActions, $oldActions);
$removed = array_diff($oldActions, $newActions);
$existing->update([
'label' => $m['label'],
'actions' => $newActions,
]);
if (! empty($added)) {
$this->warn(" [{$m['module']}] Thêm actions: " . implode(', ', $added));
// Action mới mặc định TẮT cho tất cả role
$this->disableNewActionsForAllRoles($m['module'], $added);
}
if (! empty($removed)) {
$this->warn(" [{$m['module']}] Xóa actions: " . implode(', ', $removed));
$this->removeActionsFromAllRoles($m['module'], $removed);
}
} else {
PermissionModule::create([
'module' => $m['module'],
'label' => $m['label'],
'actions' => $m['actions'],
]);
$this->info(" [{$m['module']}] Tạo mới module.");
// Module mới mặc định TẮT cho tất cả role
$this->disableNewActionsForAllRoles($m['module'], $m['actions']);
}
}
$this->info('Đồng bộ hoàn tất.');
return self::SUCCESS;
}
private function getClassFromFile(string $path): ?string
{
$contents = file_get_contents($path);
// Tìm namespace
$namespace = null;
if (preg_match('/namespace\s+([^;]+);/', $contents, $matches)) {
$namespace = $matches[1];
}
// Tìm class name
$class = null;
if (preg_match('/class\s+(\w+)/', $contents, $matches)) {
$class = $matches[1];
}
if ($namespace && $class) {
return $namespace . '\\' . $class;
}
return null;
}
private function disableNewActionsForAllRoles(string $module, array $actions): void
{
$roles = RoleTemplate::all();
foreach ($roles as $role) {
$perms = $role->permissions ?? [];
foreach ($actions as $action) {
if (isset($perms[$module]) && in_array($action, $perms[$module])) {
continue; // Đã có thì giữ nguyên
}
// Không thêm vào = mặc định TẮT
}
// Không cần update vì JSONB không lưu action mới = TẮT
}
}
private function removeActionsFromAllRoles(string $module, array $actions): void
{
$roles = RoleTemplate::all();
foreach ($roles as $role) {
$perms = $role->permissions ?? [];
if (isset($perms[$module])) {
$perms[$module] = array_values(array_diff($perms[$module], $actions));
if (empty($perms[$module])) {
unset($perms[$module]);
}
$role->update(['permissions' => $perms]);
}
}
}
}

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Appendices\Tables\AppendicesTable;
class AppendixResource extends Resource class AppendixResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Appendix::class; protected static ?string $model = Appendix::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -14,6 +14,7 @@ use App\Filament\Resources\Contracts\Tables\ContractsTable;
class ContractResource extends Resource class ContractResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Contract::class; protected static ?string $model = Contract::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Customers\Tables\CustomersTable;
class CustomerResource extends Resource class CustomerResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Customer::class; protected static ?string $model = Customer::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-users'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-users';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::CUSTOMER->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\FormTemplates\Tables\FormTemplatesTable;
class FormTemplateResource extends Resource class FormTemplateResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = FormTemplate::class; protected static ?string $model = FormTemplate::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-duplicate'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-duplicate';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::SETTING->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\PaymentFines\Tables\PaymentFinesTable;
class PaymentFineResource extends Resource class PaymentFineResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = PaymentFine::class; protected static ?string $model = PaymentFine::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-exclamation-triangle';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Payments\Tables\PaymentsTable;
class PaymentResource extends Resource class PaymentResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Payment::class; protected static ?string $model = Payment::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::FINANCE->value;

View File

@@ -14,6 +14,7 @@ use App\Filament\Resources\Products\Schemas\ProductForm;
class ProductResource extends Resource class ProductResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Product::class; protected static ?string $model = Product::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-squares-2x2';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Projects\Schemas\ProjectForm;
class ProjectResource extends Resource class ProjectResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Project::class; protected static ?string $model = Project::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::PROJECT->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::PROJECT->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\SalesPhases\Tables\SalesPhasesTable;
class SalesPhaseResource extends Resource class SalesPhaseResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = SalesPhase::class; protected static ?string $model = SalesPhase::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rocket-launch'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rocket-launch';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::WAREHOUSE->value;

View File

@@ -13,6 +13,7 @@ use App\Filament\Resources\Settlements\Tables\SettlementsTable;
class SettlementResource extends Resource class SettlementResource extends Resource
{ {
protected static array $permissionActions = ["view", "create", "update", "delete", "restore", "forceDelete"];
protected static ?string $model = Settlement::class; protected static ?string $model = Settlement::class;
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check'; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value; protected static string | \UnitEnum | null $navigationGroup = NavigationGroup::TRANSACTION->value;

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PermissionModule extends Model
{
use HasUuids, HasFactory;
protected $fillable = ['module', 'label', 'actions'];
protected $casts = [
'actions' => 'array',
];
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RoleTemplate extends Model
{
use HasUuids, HasFactory;
protected $fillable = ['name', 'description', 'permissions', 'is_active'];
protected $casts = [
'permissions' => 'array',
'is_active' => 'boolean',
];
public function users()
{
return $this->hasMany(User::class);
}
}

View File

@@ -29,9 +29,83 @@ class User extends Authenticatable implements FilamentUser
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'extra_permissions' => 'array',
'excluded_permissions' => 'array',
]; ];
} }
public function roleTemplate()
{
return $this->belongsTo(RoleTemplate::class);
}
/**
* Tính toán effective permissions từ role template + extra - excluded.
* Cache trong session (1 lần/login).
*/
public function getEffectivePermissions(): array
{
$cacheKey = "user.{$this->id}.permissions";
if (session()->has($cacheKey)) {
return session()->get($cacheKey);
}
$permissions = $this->calculateEffectivePermissions();
session()->put($cacheKey, $permissions);
return $permissions;
}
public function hasEffectivePermission(string $permission): bool
{
return in_array($permission, $this->getEffectivePermissions());
}
public function clearPermissionCache(): void
{
session()->forget("user.{$this->id}.permissions");
}
protected function calculateEffectivePermissions(): array
{
$templatePerms = [];
if ($this->roleTemplate) {
$templatePerms = $this->roleTemplate->permissions ?? [];
}
// Flatten template permissions từ {"contracts":["view","create"]} thành ["contracts.view","contracts.create"]
$templateFlat = [];
foreach ($templatePerms as $module => $actions) {
foreach ($actions as $action) {
$templateFlat[] = "{$module}.{$action}";
}
}
$extra = $this->extra_permissions ?? [];
$excluded = $this->excluded_permissions ?? [];
return array_values(array_diff(
array_unique(array_merge($templateFlat, $extra)),
$excluded
));
}
/**
* Override can() để tích hợp với Laravel Authorization.
* Nếu ability dạng "contracts.view" dùng effective permissions.
* Ngược lại fallback về parent.
*/
public function can($abilities, $arguments = []): bool
{
if (is_string($abilities) && str_contains($abilities, '.')) {
return $this->hasEffectivePermission($abilities);
}
return parent::can($abilities, $arguments);
}
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return true; return true;

View File

@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Bảng đăng ký các module có thể phân quyền (auto từ Resource)
Schema::create('permission_modules', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('module')->unique(); // contracts, payments, customers...
$table->string('label'); // Hợp đồng, Thu tiền...
$table->jsonb('actions'); // ["view","create","update","delete","restore","forceDelete","export"]
$table->timestamps();
});
// Bảng mẫu nhóm (Role Template)
Schema::create('role_templates', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name'); // Sales, Kế toán, Admin...
$table->text('description')->nullable();
$table->jsonb('permissions')->default('{}'); // {"contracts":["view","create"], "payments":["view"]}
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// Sửa users: thêm role_template_id, extra_permissions, excluded_permissions
Schema::table('users', function (Blueprint $table) {
$table->foreignUuid('role_template_id')->nullable()->constrained('role_templates')->nullOnDelete();
$table->jsonb('extra_permissions')->default('[]');
$table->jsonb('excluded_permissions')->default('[]');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropForeign(['role_template_id']);
$table->dropColumn(['role_template_id', 'extra_permissions', 'excluded_permissions']);
});
Schema::dropIfExists('role_templates');
Schema::dropIfExists('permission_modules');
}
};