Skip to main content

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.

RoleKey Permissions
FIRM_ADMINAll permissions within firm
LAWYERCASE.*, CLIENT.READ, DOCUMENT.*, TIME_ENTRY.*, BILLING.READ
PARALEGALCASE.READ, DOCUMENT.*, TIME_ENTRY.CREATE
BILLING_ADMININVOICE.*, PAYMENT.*, TIME_ENTRY.READ
RECEPTIONISTCLIENT.*, APPOINTMENT.*
CLIENT_PORTALCASE.READ (own matters only), INVOICE.READ, DOCUMENT.READ
PARTNER_PORTALCASE.READ (assigned matters), TIME_ENTRY.CREATE