Skip to content

PHASE 2 — Permission Matrix (Complete Authorization Reference)

System: PyroRadius ISP Billing & RADIUS Management
Stack: Laravel 13 · Inertia.js v2 · React 18 · PostgreSQL 16
Last Updated: 2026-07-13
Document: PHASE2_02

This is the definitive reference for PyroRadius's authorization system. It covers all 10 roles, every permission key (~80), the full role-permission matrix, implementation patterns in PHP and React, dealer scope logic, and the step-by-step guide for adding a new permission.


Table of Contents

  1. All 10 Roles
  2. Full Permission Matrix Table
  3. Permission Groups (UI Groupings)
  4. React Implementation Pattern
  5. PHP Implementation Pattern
  6. Dealer Scope Enforcement
  7. Adding a New Permission

1. All 10 Roles

Role 1: super_admin

Role string: super_admin
Created by: Only existing super_admins (cannot be assigned via UI dealer management)
Permission bypass: Yes — skips all JSONB permission checks entirely
Scope bypass: Yes — sees all data across all dealers

Full capabilities: - Complete access to all modules: customers, billing, packages, NAS, OLT, TR-069, WhatsApp, reports, import, audit, health, settings - System-level settings (RADIUS config, company profile, key-value store) - Manage all user accounts including other admins - Prune audit logs (bulk deletion via Artisan command) - Manage cron job scheduler (add/edit/disable/delete any job) - View As any dealer without restriction - Factory reset CPE devices via TR-069 - Permanently delete archived billing months

What super_admin cannot do (by policy): - Execute raw shell commands via UI - Permanently delete individual paid invoices (archive-only) - Delete audit_log records individually (only bulk prune command)


Role 2: admin

Role string: admin
Created by: super_admin only
Permission bypass: Yes — same JSONB bypass as super_admin
Scope bypass: Yes — sees all data

Full capabilities: - All customer CRUD, billing, packages, NAS, OLT, WhatsApp, reports, import, audit, health, cron jobs - Create and edit dealers and sub_dealers (not other admins) - View As any dealer - Manage TR-069 device provisioning - Run billing archives - Export all reports

What admin cannot do: - Access system-level settings (some keys gated at super_admin) - Manage other admin user accounts - Prune audit logs


Role 3: noc_admin

Role string: noc_admin
Created by: admin or super_admin
Permission bypass: No — uses JSONB permissions
Scope: Sees all customers (no dealer filter) for network operations

Primary purpose: Network Operations Center staff. Monitor and fix connectivity issues.

Typical capabilities granted: - customers.view — view customer details to find their NAS/PPPoE config - customers.edit — update PPPoE credentials, change NAS assignment - customers.disconnect — kick active RADIUS sessions - customers.sync — force RADIUS re-sync for a customer - network.nas_view — view NAS devices and active sessions - network.nas_manage — add/edit NAS devices (if senior NOC) - network.gpon_view — view OLT and ONT status - network.graphs — view traffic graphs and session history - tr069.view — view CPE device status - tr069.manage — push provisioning to CPE - tr069.reboot — reboot CPE remotely - admin.health — view system health dashboard

What noc_admin typically cannot do: - Billing operations (invoices, payments, recharge) - User/dealer management - System settings - WhatsApp/campaign management


Role 4: billing_admin

Role string: billing_admin
Created by: admin or super_admin
Permission bypass: No — uses JSONB permissions
Scope: Sees all customers for billing operations

Primary purpose: Billing staff who process payments, manage invoices, and handle collections.

Typical capabilities granted: - customers.view — view customer detail for billing context - customers.recharge — process recharge/renewal - customers.extend — extend service period - billing.view — view billing dashboard - billing.collect — record payments - billing.invoices — view and create invoices - billing.delete — void invoices (with audit trail) - billing.reminders — send payment reminders - reports.view — access reports - reports.billing — revenue and collection reports - reports.recovery — defaulter reports - reports.export — export reports to Excel - whatsapp.send — send WhatsApp reminders manually - whatsapp.view — view outbox status - whatsapp.queue — manage outbox queue

What billing_admin typically cannot do: - Edit customer profile/PPPoE settings - Delete customers - Network/NAS/OLT management - TR-069 management - System settings


Role 5: support_agent

Role string: support_agent
Created by: admin or super_admin
Permission bypass: No — uses JSONB permissions
Scope: Sees all customers (system-wide) for support purposes

Primary purpose: Customer support staff handling tickets and basic service questions.

Typical capabilities granted: - customers.view — view customer details to assist with support queries - customers.extend — grant brief grace extensions for service issues - tickets.view — view all support tickets - tickets.create — create new tickets - tickets.manage — assign and resolve tickets - nav.support — navigation: support/tickets section

What support_agent typically cannot do: - Billing operations (invoices, payments, recharge) - Edit customer profile - Delete customers - Any network or NAS operations - Reports or data export - WhatsApp campaigns


Role 6: dealer

Role string: dealer
Created by: admin or super_admin
Permission bypass: No — all access via JSONB permissions
Scope: Strict — only sees customers WHERE dealer_id = auth()->id()

Primary purpose: Independent reseller. Manages their own customer base, collects payments, handles their own billing operations.

Capabilities always available (no permission key needed): - View own profile and account - View own financial summary - View basic customer list (name, status, expiry) — minimal info - View own sub-dealers

Capabilities controlled by explicit permission grants:

Permission Key Business Action
customers.view Full customer detail (PPPoE creds, package, ledger)
customers.create Add new customers
customers.edit Edit customer profile, change package, change status
customers.delete Soft-delete customers
customers.recharge Process renewals, generate invoices, record payments
customers.extend Extend service period only
customers.disconnect Kick active RADIUS sessions
billing.view Billing dashboard
billing.invoices View all invoices
billing.collect Record payments
reports.view Reports landing page
reports.billing Revenue and collection reports
reports.customer Customer list reports
reports.export Export to Excel
whatsapp.send Send WhatsApp manually
whatsapp.view View outbox/status
tickets.view View tickets
tickets.create Create tickets
tickets.manage Assign and resolve tickets
network.graphs View ONT signal / customer graphs

What dealer cannot do (regardless of permissions): - See any other dealer's customers (hard database constraint) - Access system settings or RADIUS configuration - Manage packages or NAS devices (can only use pre-assigned packages) - View audit logs of other dealers - Use the View As feature - Create admin-level user accounts - Run WhatsApp campaigns (admin-level feature)


Role 7: sub_dealer

Role string: sub_dealer
Created by: admin, super_admin, or their parent dealer
Permission bypass: No — subset of parent dealer's permissions
Scope: Only sees customers WHERE sub_dealer_id = auth()->id()

Primary purpose: A sales agent or franchise partner under a dealer. Limited to their own assigned customers.

Permission inheritance rule: A sub_dealer can only be granted permissions that their parent dealer also has. Admin cannot grant a sub_dealer broader access than the parent.

Typical capabilities: - customers.view — view their own customers - customers.create — add customers (assigned to parent dealer AND this sub_dealer) - customers.edit — edit their customers - billing.collect — record payments for their customers - billing.invoices — view their customers' invoices - tickets.view — view tickets for their customers - tickets.create — raise tickets

What sub_dealer cannot do: - See parent dealer's directly-owned customers (those with sub_dealer_id = NULL) - See other sub_dealers' customers - Delete customers (rarely granted) - Access any reports beyond their own scope - WhatsApp, campaigns, network operations


Role 8: field_engineer

Role string: field_engineer
Created by: admin or super_admin via engineer team management
Permission bypass: No — uses JSONB permissions
Scope: Typically scoped to the dealer they are assigned to (dealer_id = $user->parent_id)

Primary purpose: On-ground installation and fault technician. Needs to view customer location/connection info and update ticket/task status.

Typical capabilities granted: - customers.view — view customers in their assigned dealer's area (location, ONT serial, NAS port) - tickets.view — view assigned tickets - tickets.manage — update ticket status (in_progress, resolved) and add notes - network.gpon_view — view ONT signal data relevant to their installation - tr069.view — view CPE device parameters (for diagnosis) - admin.field_engineers — their own engineer dashboard and task list

What field_engineer cannot do: - Billing operations of any kind - Create or delete customers - RADIUS management - Settings or administration - Reports


Role 9: auditor

Role string: auditor
Created by: admin or super_admin
Permission bypass: No — uses JSONB permissions
Scope: Read-only across all data (system-wide, no dealer filter)

Primary purpose: Internal or external auditor who needs read-only access to all operational data for compliance, financial review, or security assessment.

Typical capabilities granted (all read-only): - customers.view — view all customers across all dealers - billing.view — billing dashboard - billing.invoices — view all invoices - reports.view — all reports - reports.billing — financial reports - reports.customer — customer reports - reports.export — export data for audit - network.nas_view — view NAS devices - network.gpon_view — view OLT/ONT - admin.audit — full audit log access - nav.dashboard — dashboard stats

What auditor cannot do: - Create, edit, or delete any record - Process payments or recharges - Send WhatsApp messages - Change any settings - Trigger any system operations


Role 10: collection_officer

Role string: collection_officer
Created by: admin or super_admin
Permission bypass: No — uses JSONB permissions
Scope: Limited to assigned dealers and/or assigned areas only

Primary purpose: Field collection agent who visits customers to collect payments. Has limited billing-only access for their assigned territory.

Scope mechanism: - collection_officer_dealers join table: which dealers' customers they can see - collection_officer_areas join table: which areas they can see - Customer query: WHERE dealer_id IN (assigned_dealer_ids) OR area_id IN (assigned_area_ids)

Typical capabilities granted: - customers.view — view customers in assigned territory - billing.view — billing overview - billing.collect — record cash payments - billing.invoices — view invoices for receipt verification - reports.billing — collection report for their territory

What collection_officer cannot do: - Access customers outside assigned dealers/areas - Create or edit customers - Recharge or extend (billing_admin privilege) - Any network operations - Reports for other territories - WhatsApp or campaigns


2. Full Permission Matrix Table

Legend: - — Always granted (role-level bypass or default for this role) - E — Explicitly granted (must be turned on by admin in the JSONB permissions editor) - — Not available / permanently denied for this role

Note: super_admin and admin bypass JSONB entirely — means "always allowed." For all other roles, E means "can be granted, off by default." for non-admin roles means the permission is on by default when the role is created.

Permission Key Description super_admin admin noc_admin billing_admin support_agent dealer sub_dealer field_engineer auditor collection_officer
CUSTOMER PERMISSIONS
customers.view View full customer detail pages E E E E E E E E
customers.create Create new customers E E
customers.edit Edit customer profile, package, status E E E
customers.delete Soft-delete customers E
customers.deleted.view View deleted customer list E E
customers.restore Restore soft-deleted customers E
customers.permanent_delete Hard delete (super_admin only)
customers.recharge Process renewal + payment + RADIUS sync E E E E
customers.extend Extend expiry without new invoice E E E E
customers.disconnect Kick active RADIUS session E E
customers.status Toggle active/suspended status E E
customers.sync Force RADIUS re-sync E
BILLING PERMISSIONS
billing.view Billing dashboard access E E E E E
billing.collect Record payment transactions E E E E
billing.invoices View and create invoices E E E E E
billing.delete Void/delete invoices E
billing.reminders Send payment reminder notifications E E
REPORTS PERMISSIONS
reports.view Access reports module E E E E
reports.export Export reports to Excel/CSV E E E
reports.customer Customer list and status reports E E E
reports.billing Revenue and collection reports E E E E
reports.recovery Defaulter and recovery reports E E E
reports.network Network and RADIUS usage reports E E
reports.ftth FTTH/OLT ONT reports E E E
reports.area Area-based customer reports E E E E
reports.package Package adoption reports E E E
reports.ticket Ticket/support reports E E
reports.whatsapp WhatsApp delivery analytics E E E
TICKETS PERMISSIONS
tickets.view View support tickets E E E E E
tickets.create Create new tickets E E E
tickets.manage Assign, resolve, close tickets E E E
tickets.delete Delete tickets
WHATSAPP PERMISSIONS
whatsapp.send Send WhatsApp message manually E E
whatsapp.templates Manage message templates
whatsapp.campaigns Create and run broadcast campaigns
whatsapp.bulk Send bulk WhatsApp to customer list E E
whatsapp.view View WhatsApp dashboard and status E E
whatsapp.sessions Manage WhatsApp sessions (QR scan)
whatsapp.automation Configure automated message triggers
whatsapp.logs View message delivery logs E E
whatsapp.queue View and manage outbox queue E
whatsapp.settings WhatsApp API configuration
NOTIFICATIONS PERMISSIONS
notifications.send Send system notifications to users
notifications.view View notification history E E E
NETWORK PERMISSIONS
network.nas_view View NAS devices and sessions E E
network.nas_manage Add/edit/delete NAS devices E
network.gpon_view View OLT and ONT status E E E
network.graphs View traffic graphs and session history E E E E
network.bypass_view View firewall/website bypass rules E
ADMIN PERMISSIONS
admin.users Manage user accounts (dealers, sub_dealers)
admin.packages Manage packages (create/edit/delete)
admin.audit View audit logs E
admin.health System health dashboard + cron manager E
admin.field_engineers Manage engineer teams and tasks E
admin.areas Manage area definitions
admin.masters System settings, company profile
admin.import.customers Bulk import customers via Excel
TR-069 PERMISSIONS
tr069.view View CPE devices in GenieACS E E E
tr069.manage Push provisioning parameters to CPE E
tr069.reboot Remote reboot of CPE device E
tr069.factory_reset Factory reset CPE device
tr069.settings Configure GenieACS connection settings
NAVIGATION PERMISSIONS
nav.dashboard Show Dashboard in sidebar E E E E E E E E
nav.customers Show Customers in sidebar E E E E E E E E
nav.billing Show Billing in sidebar E E E E E
nav.reports Show Reports in sidebar E E E E
nav.network Show Network in sidebar E E E
nav.support Show Support/Tickets in sidebar E E E E E
nav.administration Show Administration menu
nav.tr069 Show TR-069 in sidebar E E

3. Permission Groups (UI Groupings)

The permissions editor in the admin UI groups keys into logical sections. This maps to the PERMISSION_GROUPS constant in the frontend (likely resources/js/constants/permissions.js):

const PERMISSION_GROUPS = {
  "Customer Management": [
    "customers.view",
    "customers.create",
    "customers.edit",
    "customers.delete",
    "customers.deleted.view",
    "customers.restore",
    "customers.permanent_delete",
    "customers.recharge",
    "customers.extend",
    "customers.disconnect",
    "customers.status",
    "customers.sync",
  ],
  "Billing & Payments": [
    "billing.view",
    "billing.collect",
    "billing.invoices",
    "billing.delete",
    "billing.reminders",
  ],
  "Reports & Analytics": [
    "reports.view",
    "reports.export",
    "reports.customer",
    "reports.billing",
    "reports.recovery",
    "reports.network",
    "reports.ftth",
    "reports.area",
    "reports.package",
    "reports.ticket",
    "reports.whatsapp",
  ],
  "Support Tickets": [
    "tickets.view",
    "tickets.create",
    "tickets.manage",
    "tickets.delete",
  ],
  "WhatsApp Messaging": [
    "whatsapp.send",
    "whatsapp.bulk",
    "whatsapp.view",
    "whatsapp.logs",
    "whatsapp.queue",
    "whatsapp.templates",
    "whatsapp.campaigns",
    "whatsapp.sessions",
    "whatsapp.automation",
    "whatsapp.settings",
  ],
  "Notifications": [
    "notifications.send",
    "notifications.view",
  ],
  "Network & Infrastructure": [
    "network.nas_view",
    "network.nas_manage",
    "network.gpon_view",
    "network.graphs",
    "network.bypass_view",
  ],
  "TR-069 / CPE Management": [
    "tr069.view",
    "tr069.manage",
    "tr069.reboot",
    "tr069.factory_reset",
    "tr069.settings",
  ],
  "Administration": [
    "admin.users",
    "admin.packages",
    "admin.audit",
    "admin.health",
    "admin.field_engineers",
    "admin.areas",
    "admin.masters",
    "admin.import.customers",
  ],
  "Navigation Visibility": [
    "nav.dashboard",
    "nav.customers",
    "nav.billing",
    "nav.reports",
    "nav.network",
    "nav.support",
    "nav.administration",
    "nav.tr069",
  ],
};

Usage in the permissions editor UI: When an admin opens the Edit Permissions panel for a dealer, these groups are rendered as collapsible sections. Each permission within a section is shown as a toggle switch. Super_admin/admin accounts show a read-only "Full Access" badge instead of toggles.


4. React Implementation Pattern

Core Pattern

// In any React component or layout:
import { usePage } from '@inertiajs/react';

// The auth object is shared by HandleInertiaRequests middleware on every request.
const { auth } = usePage().props;

// Shorthand permission checker:
// - isAdmin means super_admin or admin → bypass all JSONB checks
// - Otherwise check the JSONB permissions object
const can = (key) => {
  if (auth?.isAdmin) return true;
  return !!(auth?.user?.permissions?.[key]);
};

Conditional Rendering

// Show a button only if user can recharge:
{can('customers.recharge') && (
  <button onClick={handleRecharge}>Recharge</button>
)}

// Show a nav link only if user can access billing:
{can('billing.invoices') && (
  <Link href={route('billing.invoices.index')}>Invoices</Link>
)}

// Show admin-only UI:
{auth?.isAdmin && (
  <Link href={route('packages.index')}>Manage Packages</Link>
)}

// Super admin only:
{auth?.isSuperAdmin && (
  <Link href={route('settings.radius')}>RADIUS Settings</Link>
)}
// resources/js/Components/Sidebar.jsx
import { usePage, Link } from '@inertiajs/react';

export default function Sidebar() {
  const { auth } = usePage().props;
  const { isAdmin, isSuperAdmin, user } = auth;

  // Helper: admin always passes; others check their JSONB permissions
  const can = (key) => isAdmin || !!(user?.permissions?.[key]);

  return (
    <nav>
      {/* Dashboard — available to all authenticated users */}
      <Link href={route('dashboard')}>Dashboard</Link>

      {/* Customers section */}
      {can('customers.view') && (
        <Link href={route('customers.index')}>Customers</Link>
      )}

      {/* Billing section */}
      {can('billing.view') && (
        <Link href={route('billing.invoices.index')}>Billing</Link>
      )}

      {/* Reports section */}
      {can('reports.view') && (
        <Link href={route('reports.index')}>Reports</Link>
      )}

      {/* Network section — admin or NOC */}
      {(isAdmin || can('network.nas_view') || can('network.gpon_view')) && (
        <Link href={route('nas.index')}>Network</Link>
      )}

      {/* WhatsApp section */}
      {(isAdmin || can('whatsapp.view')) && (
        <Link href={route('whatsapp.index')}>WhatsApp</Link>
      )}

      {/* Tickets section */}
      {can('tickets.view') && (
        <Link href={route('tickets.index')}>Support</Link>
      )}

      {/* Administration — admin only */}
      {isAdmin && (
        <>
          <Link href={route('packages.index')}>Packages</Link>
          <Link href={route('users.index')}>Users</Link>
          <Link href={route('audit.index')}>Audit Logs</Link>
        </>
      )}

      {/* System settings — super_admin only */}
      {isSuperAdmin && (
        <>
          <Link href={route('settings.general')}>Settings</Link>
          <Link href={route('health.index')}>Health & Scheduler</Link>
        </>
      )}
    </nav>
  );
}

Permission-Aware Table Actions

// resources/js/Pages/Customers/Index.jsx
export default function CustomersIndex({ customers }) {
  const { auth } = usePage().props;
  const can = (key) => auth.isAdmin || !!(auth.user?.permissions?.[key]);

  return (
    <table>
      <tbody>
        {customers.data.map(customer => (
          <tr key={customer.id}>
            <td>{customer.name}</td>
            <td>{customer.status}</td>
            <td className="flex gap-2">
              {/* View is always shown (permission checked on route) */}
              <Link href={route('customers.show', customer.id)}>View</Link>

              {can('customers.edit') && (
                <Link href={route('customers.edit', customer.id)}>Edit</Link>
              )}

              {can('customers.recharge') && (
                <button onClick={() => openRechargeModal(customer)}>
                  Recharge
                </button>
              )}

              {can('customers.disconnect') && (
                <button onClick={() => kickSession(customer.id)}>
                  Kick
                </button>
              )}

              {can('customers.delete') && (
                <button onClick={() => confirmDelete(customer.id)}>
                  Delete
                </button>
              )}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Important Security Note

Frontend permission checks are UX convenience only — they hide UI elements the user cannot use. They are never the security gate. All authorization enforcement is done server-side in controllers. Never rely on frontend permission checks for security.


5. PHP Implementation Pattern

The hasPermission() Method

Defined on the User model (or a HasPermissions trait):

// app/Models/User.php

public function isAdmin(): bool
{
    return in_array($this->role, ['super_admin', 'admin']);
}

public function isSuperAdmin(): bool
{
    return $this->role === 'super_admin';
}

public function hasPermission(string $key): bool
{
    // super_admin and admin bypass all JSONB permission checks
    if ($this->isAdmin()) {
        return true;
    }

    // For all other roles: check the JSONB permissions column
    // Missing key = false (same as explicitly set to false)
    $permissions = $this->permissions ?? [];
    return ($permissions[$key] ?? false) === true;
}

Controller-Level Permission Gate

// Standard pattern in every controller method:

public function store(Request $request): RedirectResponse
{
    // Step 1: Check permission
    if (!$request->user()->hasPermission('customers.create')) {
        abort(403, 'Permission denied: customers.create required.');
    }

    // Step 2: (For non-admin users) dealer scope is enforced in queries
    // Step 3: Business logic
}

public function destroy(Customer $customer): RedirectResponse
{
    // Check permission
    if (!$request->user()->hasPermission('customers.delete')) {
        abort(403);
    }

    // IDOR prevention: verify ownership
    $this->authorizeCustomerAccess($customer);

    // Proceed with delete
}

Using Laravel's $this->authorize() with Policies

// In a controller that has AuthorizesRequests trait:

public function show(Customer $customer): Response
{
    // This calls CustomerPolicy@view automatically
    $this->authorize('view', $customer);

    return Inertia::render('Customers/Show', [...]);
}

// app/Policies/CustomerPolicy.php:
public function before(User $user, string $ability): bool|null
{
    // Admin/super_admin pass all policy checks
    if ($user->isAdmin()) return true;
    return null; // Continue to method-specific check
}

public function view(User $user, Customer $customer): bool
{
    if (!$user->hasPermission('customers.view')) return false;
    // Dealer scope check
    return match($user->role) {
        'dealer'              => $customer->dealer_id === $user->id,
        'sub_dealer'          => $customer->sub_dealer_id === $user->id,
        'field_engineer'      => $customer->dealer_id === $user->parent_id,
        'collection_officer'  => $this->inCollectionScope($user, $customer),
        'noc_admin',
        'billing_admin',
        'support_agent',
        'auditor'             => true, // system-wide read, permission already checked
        default               => false,
    };
}

Checking Permission in Middleware

// app/Http/Middleware/CheckPermission.php
// Usage: Route::middleware(['auth', 'permission:reports.export'])

public function handle(Request $request, Closure $next, string $permission): Response
{
    if (!$request->user()?->hasPermission($permission)) {
        if ($request->inertia()) {
            return back()->with('error', 'You do not have permission to perform this action.');
        }
        abort(403, 'Forbidden');
    }

    return $next($request);
}

Programmatic Permission Grant/Revoke

// Grant a permission to a dealer:
$dealer = User::find($dealerId);
$permissions = $dealer->permissions ?? [];
$permissions['customers.recharge'] = true;
$dealer->permissions = $permissions;
$dealer->save();

// Revoke a permission:
$permissions = $dealer->permissions ?? [];
$permissions['customers.delete'] = false;
$dealer->permissions = $permissions;
$dealer->save();

// Grant multiple permissions at once (merge, don't replace):
$dealer->permissions = array_merge($dealer->permissions ?? [], [
    'whatsapp.send'  => true,
    'reports.view'   => true,
    'reports.export' => false,
]);
$dealer->save();

6. Dealer Scope Enforcement

Scope enforcement is the most critical security control in PyroRadius. Every query that reads customer data (or data derived from customers: invoices, payments, ledger entries, tickets) must be scoped to the authenticated user's access level.

The visibleTo() Query Scope

The Customer model implements a scopeVisibleTo(Builder $query, User $user) method:

// app/Models/Customer.php

public function scopeVisibleTo(Builder $query, User $user): Builder
{
    // Admins and admin-level staff see everything — no filter
    if ($user->isAdmin()) {
        return $query;
    }

    // Staff roles that see all customers system-wide
    // (they have no dealer, but are not admin-bypass)
    if (in_array($user->role, ['noc_admin', 'billing_admin', 'support_agent', 'auditor'])) {
        // No dealer filter — but permission gate already checked upstream
        return $query;
    }

    // Dealer sees only their own customers
    if ($user->role === 'dealer') {
        return $query->where('dealer_id', $user->id);
    }

    // Sub-dealer sees only their own assigned customers
    if ($user->role === 'sub_dealer') {
        return $query->where('sub_dealer_id', $user->id);
    }

    // Field engineer sees customers of their parent dealer
    if ($user->role === 'field_engineer') {
        return $query->where('dealer_id', $user->parent_id);
    }

    // Collection officer sees customers of their assigned dealers OR assigned areas
    if ($user->role === 'collection_officer') {
        $assignedDealerIds = $user->assignedDealers()->pluck('dealer_id');
        $assignedAreaIds   = $user->assignedAreas()->pluck('area_id');
        return $query->where(function ($q) use ($assignedDealerIds, $assignedAreaIds) {
            $q->whereIn('dealer_id', $assignedDealerIds)
              ->orWhereIn('area_id', $assignedAreaIds);
        });
    }

    // Default: see nothing (defensive — unknown roles get no data)
    return $query->whereRaw('1 = 0');
}

Usage Pattern in Controllers

// In every listing/index controller method:

$customers = Customer::with(['package', 'dealer'])
    ->visibleTo(auth()->user())   // <-- always chain this
    ->filter($request)
    ->paginate(25);

Dealer scoping does not stop at the customers table. All financially-linked tables also have dealer_id columns (intentional denormalization) and must be independently scoped:

// Invoices — always scope:
Invoice::where('dealer_id', auth()->id())->get();

// Payments — always scope:
Payment::where('dealer_id', auth()->id())->get();

// Ledger entries — always scope:
LedgerEntry::where('dealer_id', auth()->id())->get();

// WhatsApp logs — scope via customer relationship:
WhatsappLog::whereHas('customer', function ($q) use ($user) {
    $q->where('dealer_id', $user->id);
})->get();

// Tickets — scope by dealer:
Ticket::where('dealer_id', auth()->id())->get();

IDOR Prevention for Single-Record Access

Route model binding resolves Customer::find($id) before the controller runs, without any scope. This means the controller must explicitly verify ownership before serving data:

public function show(Customer $customer): Response
{
    $user = auth()->user();

    // 1. Permission check
    if (!$user->hasPermission('customers.view')) {
        abort(403);
    }

    // 2. Ownership check — IDOR prevention
    if (!$user->isAdmin()) {
        $allowed = match($user->role) {
            'dealer'             => $customer->dealer_id === $user->id,
            'sub_dealer'         => $customer->sub_dealer_id === $user->id,
            'field_engineer'     => $customer->dealer_id === $user->parent_id,
            'noc_admin',
            'billing_admin',
            'support_agent',
            'auditor'            => true,
            'collection_officer' => $this->inCollectionScope($user, $customer),
            default              => false,
        };

        if (!$allowed) {
            abort(403, 'This customer is not accessible to your account.');
        }
    }

    return Inertia::render('Customers/Show', [...]);
}

Warning: Queue Jobs and Artisan Commands

Jobs and commands run without an authenticated user. Any global scope that calls auth()->user() will return null in a job context. Always call models without global scopes in jobs:

// In a queue job — NEVER assume scoping:
$customers = Customer::withoutGlobalScopes()
    ->where('dealer_id', $this->dealerId) // explicit, not from auth()
    ->get();

// Or use forceAll scoped query explicitly:
Customer::withoutGlobalScopes()->where('expiry_date', '<', now())->chunk(100, function ($batch) {
    // process expired customers system-wide
});

The denormalized dealer_id Pattern

invoices, payments, and ledger_entries all carry their own dealer_id column. This intentional denormalization means:

  1. Dealer-scoped financial queries need no JOIN to customers — they filter directly on dealer_id
  2. Financial records are never orphaned if a customer's dealer is changed
  3. Historical audit trail preserves original dealer context even after customer reassignment

7. Adding a New Permission

Follow this checklist exactly when introducing a new feature that requires access control.

Step 1: Choose a Permission Key

Follow the resource.action naming convention:

Pattern Meaning
feature.view Read-only access
feature.create Create new records
feature.edit Modify existing records
feature.delete Remove records
feature.export Export data
feature.manage Full control (combined)

Example: Adding a "Topology Map" feature → use key topology.view

Rules: - Lowercase only - Use dots as separators (no underscores in keys) - Keep the namespace (first segment) consistent with existing groups (customers, billing, reports, network, admin, tr069, nav) - If the feature doesn't fit an existing namespace, create a new one and update PERMISSION_GROUPS


Step 2: Add the Check in the Controller

// app/Http/Controllers/TopologyController.php

public function index(Request $request): Response
{
    // Always check permission at the top of the method
    if (!$request->user()->hasPermission('topology.view')) {
        abort(403, 'Permission denied: topology.view required.');
    }

    // Apply dealer scope if the data is dealer-scoped
    // (topology may be system-wide for admin users)

    return Inertia::render('Network/Topology', [
        'nodes' => TopologyService::buildMap(auth()->user()),
    ]);
}

Step 3: Add to the Policy (if applicable)

Only needed if using Laravel's $this->authorize() pattern with a Policy class.

// app/Policies/TopologyPolicy.php (new file)

class TopologyPolicy
{
    public function before(User $user, string $ability): bool|null
    {
        if ($user->isAdmin()) return true;
        return null;
    }

    public function view(User $user): bool
    {
        return $user->hasPermission('topology.view');
    }
}

// Register in app/Providers/AuthServiceProvider.php:
protected $policies = [
    // existing policies...
    Topology::class => TopologyPolicy::class, // add this
];

Step 4: Add to PERMISSION_GROUPS Constant

// resources/js/constants/permissions.js
// (or wherever PERMISSION_GROUPS is defined)

const PERMISSION_GROUPS = {
  // ... existing groups ...

  // Add to the most appropriate group, or create a new one:
  "Network & Infrastructure": [
    "network.nas_view",
    "network.nas_manage",
    "network.gpon_view",
    "network.graphs",
    "network.bypass_view",
    "topology.view",          // <-- add here
  ],
};

Step 5: Add Frontend Guard

In the React component or layout that renders the topology link:

// In Sidebar.jsx or wherever the link lives:
const can = (key) => auth.isAdmin || !!(auth.user?.permissions?.[key]);

{can('topology.view') && (
  <Link href={route('topology.index')}>Topology Map</Link>
)}

// In the Topology page component itself, guard the render:
export default function Topology() {
  const { auth } = usePage().props;
  const can = (key) => auth.isAdmin || !!(auth.user?.permissions?.[key]);

  // This should never render if the controller aborted,
  // but extra defense is fine:
  if (!can('topology.view')) {
    return <div>Access Denied</div>;
  }

  return (/* ... topology UI ... */);
}

Step 6: Update the Permission Matrix (this document)

Add a row to Section 2 of this document with the new permission key, description, and which roles should have it by default.


Step 7: Update Documentation in User Roles

Add the new permission key to 04_User_Roles.md under the Permission Keys Reference table.


Step 8: Database Considerations (if new data table)

If the feature introduces a new table with dealer-scoped data:

  1. Add a dealer_id column (denormalized, not just via FK through customers)
  2. Implement scopeVisibleTo() or equivalent on the new model
  3. Ensure queue jobs query with withoutGlobalScopes() and explicit dealer_id filters
  4. Add the table to AuditLogger if the feature performs create/update/delete operations

Step 9: Test Permission Enforcement

Before shipping, verify:

  • curl -X GET /your-feature without auth → 302 to /login (not 200 or 403)
  • curl -X GET /your-feature with auth but missing permission → 403
  • curl -X GET /your-feature with auth and permission granted → 200
  • A dealer cannot access another dealer's data through the new route (IDOR test)
  • A super_admin and admin can access without any JSONB permission set

Cross-reference: For route-level details see PHASE2_01_Route_Feature_Map.md. For JSONB schema see 03_Database_Architecture.md. For session and middleware detail see 05_Authorization_Model.md. For role definitions and business context see 04_User_Roles.md.