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¶
- All 10 Roles
- Full Permission Matrix Table
- Permission Groups (UI Groupings)
- React Implementation Pattern
- PHP Implementation Pattern
- Dealer Scope Enforcement
- 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_adminandadminbypass JSONB entirely —✓means "always allowed." For all other roles,Emeans "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>
)}
Sidebar Navigation Pattern¶
// 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);
Cascading to Related Tables¶
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:
- Dealer-scoped financial queries need no JOIN to
customers— they filter directly ondealer_id - Financial records are never orphaned if a customer's dealer is changed
- 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:
- Add a
dealer_idcolumn (denormalized, not just via FK through customers) - Implement
scopeVisibleTo()or equivalent on the new model - Ensure queue jobs query with
withoutGlobalScopes()and explicit dealer_id filters - Add the table to
AuditLoggerif the feature performs create/update/delete operations
Step 9: Test Permission Enforcement¶
Before shipping, verify:
curl -X GET /your-featurewithout auth → 302 to/login(not 200 or 403)curl -X GET /your-featurewith auth but missing permission → 403curl -X GET /your-featurewith 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.