RBAC & Laravel Policies
The system's RBAC schema (PERMISSIONS, ROLES, USER_ROLE_ASSIGNMENTS,
RESOURCE_ACCESS_GRANTS) is more sophisticated than any package provides. Custom Laravel
Policies query this schema directly. Filament respects Laravel Policies automatically.
The RBAC Schema (unchanged from original spec)
PERMISSIONS code = 'CASE.UPDATE', resource_type = 'CASE', action = 'UPDATE'
↑ many-to-many
ROLES code = 'CASE_MANAGER', scope_type = 'FIRM'
↑ assigned via
USER_ROLE_ASSIGNMENTS user_id, role_id, law_firm_id, matter_id, starts_at, ends_at
+
RESOURCE_ACCESS_GRANTS user_id, resource_type, resource_id, access_level (polymorphic ACL)
The RbacCheckerService
All permission checks go through one service. Policies delegate to it — no policy directly queries the RBAC tables:
// app/Services/RBAC/RbacCheckerService.php
class RbacCheckerService
{
public function check(User $user, string $permissionCode, int $firmId, ?int $matterId = null): bool
{
// 1. Check resource-level DENY grant (highest priority)
// 2. Check resource-level ALLOW grant
// 3. Check role-based permission via USER_ROLE_ASSIGNMENTS → ROLES → ROLE_PERMISSIONS
// Temporal: ignore assignments where ends_at < now()
}
public function visibleFields(User $user, string $resourceType, int $firmId): array
{
// Returns which fields the user can see based on their role's
// fields_mode / fields configuration on ROLE_PERMISSIONS
}
}
Laravel Policy Example
// app/Policies/MatterPolicy.php
class MatterPolicy
{
public function __construct(private RbacCheckerService $rbac) {}
public function view(User $user, Matter $matter): bool
{
return $this->rbac->check($user, 'CASE.READ', $matter->law_firm_id, $matter->id);
}
public function update(User $user, Matter $matter): bool
{
return $this->rbac->check($user, 'CASE.UPDATE', $matter->law_firm_id, $matter->id);
}
public function create(User $user): bool
{
$firmId = Filament::getTenant()->id;
return $this->rbac->check($user, 'CASE.CREATE', $firmId);
}
}
Filament Integration
Filament calls the registered Policy automatically on every resource action. No extra
wiring is needed beyond registering the policy in AuthServiceProvider:
// app/Providers/AuthServiceProvider.php
protected $policies = [
Matter::class => MatterPolicy::class,
Client::class => ClientPolicy::class,
Invoice::class => InvoicePolicy::class,
TimeEntry::class => TimeEntryPolicy::class,
Document::class => DocumentPolicy::class,
];
Filament's table actions (EditAction, DeleteAction) and page access (canCreate(),
canEdit(), canDelete()) are all gated by these policies automatically.
Field-Level Visibility
Sensitive fields (SSN, billing rates, salary) are hidden from roles that lack the appropriate field-level policy. In Filament forms and tables:
// In a Filament Resource form
TextInput::make('ssn')
->visible(fn () => app(RbacCheckerService::class)
->visibleFields(auth()->user(), 'CLIENT', Filament::getTenant()->id)
['ssn'] ?? false
),
Seeded Roles and Permissions
The RolesAndPermissionsSeeder populates the ROLES, PERMISSIONS, and ROLE_PERMISSIONS
tables with the system defaults. These match the permission scopes defined in the
API specification.
| Role | Key Permissions |
|---|---|
FIRM_ADMIN | All permissions within firm |
LAWYER | CASE.*, CLIENT.READ, DOCUMENT.*, TIME_ENTRY.*, BILLING.READ |
PARALEGAL | CASE.READ, DOCUMENT.*, TIME_ENTRY.CREATE |
BILLING_ADMIN | INVOICE.*, PAYMENT.*, TIME_ENTRY.READ |
RECEPTIONIST | CLIENT.*, APPOINTMENT.* |
CLIENT_PORTAL | CASE.READ (own matters only), INVOICE.READ, DOCUMENT.READ |
PARTNER_PORTAL | CASE.READ (assigned matters), TIME_ENTRY.CREATE |