Series 5 — Part 2 of 7
Legal teams have strict role hierarchies: a paralegal should not see billing rates; a client should only see their own matter. This article implements a five-role RBAC system with team isolation and row-level security in MySQL — without native row-level policies.
The Five Roles
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
team_id INT UNSIGNED NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
role ENUM('super_admin','admin','lawyer','staff','client') NOT NULL,
full_name VARCHAR(300) NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1
);
-- Role capability matrix
-- super_admin: all teams, all data
-- admin: own team, all data including billing
-- lawyer: own team, all matters + work_logs; billing read-only
-- staff: own team, assigned matters only; no billing
-- client: own matters only; no billing; no work_logs
Enforcing Team Isolation in PHP
class MatterRepository
{
public function __construct(
private readonly PDO $pdo,
private readonly int $teamId,
private readonly string $role
) {}
public function findById(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM matters WHERE id = ? AND team_id = ? AND deleted_at IS NULL'
);
$stmt->execute([$id, $this->teamId]);
$matter = $stmt->fetch();
if (!$matter) {
return null; // Returns null for both "not found" and "wrong team" — no distinction
}
return $this->applyRoleFilter($matter);
}
private function applyRoleFilter(array $matter): array
{
// Staff and clients don't see billing rate fields
if (in_array($this->role, ['staff', 'client'])) {
unset($matter['rate_per_hour'], $matter['billing_cap']);
}
return $matter;
}
}
Feature Gating by Role
class Permissions
{
private const CAPS = [
'approve_billing' => ['admin', 'super_admin'],
'view_invoices' => ['admin', 'super_admin', 'lawyer'],
'create_work_log' => ['admin', 'super_admin', 'lawyer', 'staff'],
'view_all_matters' => ['admin', 'super_admin', 'lawyer'],
'view_own_matters' => ['client', 'staff'],
];
public static function can(string $role, string $capability): bool
{
return in_array($role, self::CAPS[$capability] ?? []);
}
}
// Usage in a controller
if (!Permissions::can($user['role'], 'approve_billing')) {
http_response_code(403);
exit;
}
What to Watch For
- Returning 404 vs 403 — For multi-tenant data, always return 404 (not found) when a team_id mismatch occurs, not 403 (forbidden). Returning 403 confirms the record exists, which is itself a data leak for legal data.
- Super admin scope — Super admins cross team boundaries. Make sure every query that scopes to a team explicitly checks whether the user is a super_admin before applying the team filter.
- Client portal isolation — Clients should only ever see matters where they are listed in the parties table with
is_our_client = 1. Never let a client_id be the only access control — always JOIN through parties.