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.