PyroRadius — Authorization Model¶
Version: 1.0 (Documentation) Last Updated: 2026-07-13
Table of Contents¶
- Authentication Overview
- Login Flow
- Session Management
- Authorization Architecture
- The
User::can()Permission Check - Dealer Scope Enforcement
- IDOR Prevention Patterns
- Policy Files
- Middleware Chain
- How to Add a New Permission
- Permission Keys Reference Table
- Examples in Controllers and React
Authentication Overview¶
PyroRadius uses Laravel Sanctum in session mode (not token mode). This means:
- Authentication is stateful and session-based (not JWT or API token)
- Sessions are stored in Redis (
SESSION_DRIVER=redis) - The browser receives a session cookie (encrypted, HTTP-only, SameSite)
- CSRF protection is active on all POST/PUT/DELETE routes via Laravel's CSRF middleware
- Inertia.js automatically includes the CSRF token in all XHR requests via the
X-XSRF-TOKENheader (read from the XSRF-TOKEN cookie)
There are two separate authentication contexts:
| Context | Guard | Middleware | Cookie | Session Prefix |
|---|---|---|---|---|
| Admin UI | web (default Laravel guard) |
auth |
laravel_session |
admin_ (or default) |
| Customer Portal | portal (custom guard) |
auth:portal |
portal_session |
portal_ |
Login Flow¶
Admin Login¶
1. User visits /login
→ Served by LoginController@showLoginForm
→ Inertia renders Auth/Login.jsx
2. User submits form: POST /login { email, password, remember }
→ LoginController@login
→ Validate input (email required, password required)
→ Auth::attempt(['email' => $email, 'password' => $password], $remember)
→ Laravel queries: SELECT * FROM users WHERE email = ? LIMIT 1
→ Verifies: password_verify($password, $user->password)
→ On FAILURE:
→ Increment rate limiter for IP + email combo
→ If > 5 failures: lockout for 60 seconds
→ Redirect back with error: "These credentials do not match our records."
→ On SUCCESS:
→ Session::regenerate() — prevents session fixation attacks
→ Store user ID in session
→ If $remember: set long-lived remember_token cookie
→ Redirect to /dashboard (or intended URL if redirected to login)
3. Subsequent requests (Inertia navigation):
→ Session cookie sent with each request
→ `auth` middleware resolves User model from session
→ Injects auth user into request lifecycle
→ HandleInertiaRequests shares auth data to all pages as $page.props.auth
Customer Portal Login¶
1. Customer visits /portal/login
→ PortalLoginController@show
→ Inertia renders Auth/PortalLogin.jsx (or similar)
2. Customer submits: POST /portal/login { username, password }
→ PortalLoginController@login
→ Auth::guard('portal')->attempt([...])
→ Queries customers table for matching credentials
→ On success: session under portal guard
→ Redirect to /portal/dashboard
Remember Me¶
- Uses Laravel's built-in remember token mechanism
remember_tokencolumn onuserstable stores an encrypted token- When remember=true, browser receives a long-lived
remember_web_*cookie - On return visit without a session, Laravel auto-logs in if the remember cookie's token matches
Session Management¶
Sessions are stored in Redis using the SESSION_DRIVER=redis configuration.
| Setting | Value | Purpose |
|---|---|---|
SESSION_DRIVER |
redis |
Redis-backed session storage |
SESSION_LIFETIME |
120 (minutes) | Session expires after 2 hours of inactivity |
SESSION_ENCRYPT |
true | Session data is encrypted before storing in Redis |
SESSION_SAME_SITE |
lax |
CSRF protection via SameSite cookie attribute |
SESSION_HTTP_ONLY |
true | JavaScript cannot read the session cookie |
SESSION_SECURE |
true (production) | Cookie only sent over HTTPS |
Session Data Structure¶
Each admin session in Redis contains:
_token → CSRF token (string)
_previous.url → Last URL for redirect-back
login_web_* → Authenticated user ID (encrypted)
viewing_as_dealer → Dealer ID being viewed as (View As feature, nullable)
flash.* → Flash message data (one-request lifetime)
Session Security Controls¶
- Session regeneration on login —
Session::regenerate()is called immediately after successful authentication. This generates a new session ID, preventing session fixation attacks where an attacker pre-sets a known session ID. - Session invalidation on logout —
Auth::logout()invalidates the entire session and regenerates the token. - Rate limiting — Login attempts are rate-limited per IP + email combination (Laravel's
RateLimiter).
Authorization Architecture¶
PyroRadius uses a layered authorization approach. Every protected action passes through multiple gates:
HTTP Request
│
▼
1. MIDDLEWARE: auth (is user logged in?)
│ No → redirect to /login
│ Yes ↓
▼
2. MIDDLEWARE: CheckPermission (or inline in controller)
│ No permission → abort(403) or redirect with error
│ Has permission ↓
▼
3. CONTROLLER: Dealer scope applied to every query
│ Customer query always includes: WHERE dealer_id = auth()->id()
│ (admin/super_admin bypass scoping and see all)
▼
4. POLICY (optional): Fine-grained resource-level check
│ e.g., "can this specific dealer delete this specific customer?"
▼
5. ACTION: Business logic executes
Key Principle: Defense in Depth¶
No single layer is trusted alone: - The UI hides buttons the user doesn't have permission for (UX layer) - The middleware/controller checks the permission server-side (security layer) - The database query always includes dealer scoping (data isolation layer) - Policies provide per-resource checks where needed (fine-grained layer)
The User::can() Permission Check¶
The User model includes logic to check JSONB-based permissions. This is the primary authorization mechanism for dealer/sub-dealer users.
Logic¶
// Conceptual implementation in 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 permission checks
if ($this->isAdmin()) {
return true;
}
// For dealer/sub_dealer: check JSONB permissions array
$permissions = $this->permissions ?? [];
return ($permissions[$key] ?? false) === true;
}
// Override Laravel's Gate can() to check JSONB permissions:
// (via Gate::before or custom Policy resolution)
public function can($ability, $arguments = []): bool
{
return $this->hasPermission($ability);
}
Usage in Controllers¶
// In CustomerController.php:
public function store(Request $request): RedirectResponse
{
// Check permission before proceeding
if (!auth()->user()->hasPermission('customers.create')) {
abort(403, 'You do not have permission to create customers.');
}
// ... create customer logic
}
public function destroy(Customer $customer): RedirectResponse
{
// Check permission
if (!auth()->user()->hasPermission('customers.delete')) {
abort(403);
}
// Also verify this customer belongs to this dealer (dealer scope check)
$this->authorizeCustomerAccess($customer);
$customer->delete();
// ...
}
Helper Method in Controllers¶
A base controller or trait typically provides a helper for dealer scoping + permission check together:
// In app/Http/Controllers/Controller.php or a trait:
protected function authorizeCustomerAccess(Customer $customer): void
{
$user = auth()->user();
if ($user->isAdmin()) {
return; // Admin sees everything
}
// Dealer: customer must belong to this dealer
if ($user->role === 'dealer' && $customer->dealer_id !== $user->id) {
abort(403, 'Access denied: this customer does not belong to your account.');
}
// Sub-dealer: customer must belong to this sub-dealer
if ($user->role === 'sub_dealer' && $customer->sub_dealer_id !== $user->id) {
abort(403, 'Access denied: this customer does not belong to your account.');
}
}
Dealer Scope Enforcement¶
Dealer scoping is the most critical security control in PyroRadius. Every query that touches customer data, invoices, payments, or tickets must be scoped by dealer.
The Core Scoping Pattern¶
// In any controller method that lists or fetches customer-related data:
$user = auth()->user();
$query = Customer::query();
if (!$user->isAdmin()) {
if ($user->role === 'dealer') {
$query->where('dealer_id', $user->id);
} elseif ($user->role === 'sub_dealer') {
$query->where('sub_dealer_id', $user->id);
}
}
$customers = $query->get();
Applied to All Related Tables¶
Dealer scoping is not just on customers. It cascades to all related tables:
// Invoices
Invoice::where('dealer_id', $user->id)->get();
// Payments
Payment::where('dealer_id', $user->id)->get();
// LedgerEntries
LedgerEntry::where('dealer_id', $user->id)->get();
// Tickets
Ticket::where('dealer_id', $user->id)->get();
// WhatsApp logs (via customer relationship)
WhatsappLog::whereHas('customer', fn($q) => $q->where('dealer_id', $user->id))->get();
Global Scopes (if used)¶
Some models may implement a global scope that automatically applies dealer scoping when the model is queried in a web request context:
// Example: CustomerDealerScope.php
class CustomerDealerScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$user = auth()->user();
if ($user && !$user->isAdmin()) {
if ($user->role === 'dealer') {
$builder->where('dealer_id', $user->id);
} elseif ($user->role === 'sub_dealer') {
$builder->where('sub_dealer_id', $user->id);
}
}
}
}
Warning: If global scopes are used, they must be explicitly removed in admin commands and queue jobs that run without an authenticated user context (
Customer::withoutGlobalScopes()->...). Queue jobs and Artisan commands do not have an authenticated user — a global scope that checksauth()->user()would return null and skip scoping, which would expose all data. Always verify scoping in command/job context separately.
The denormalized dealer_id Pattern¶
Notice that invoices, payments, and ledger_entries all have their own dealer_id column. This is intentional denormalization. Rather than joining through customers to check dealer_id on every query, the dealer_id is stored directly on financial records. This:
- Makes dealer-scoped financial queries faster (no join needed)
- Makes it impossible to orphan financial records if a customer's dealer_id changes
- Ensures audit trail integrity
IDOR Prevention Patterns¶
IDOR (Insecure Direct Object Reference) occurs when a user guesses or manipulates a resource ID to access data they shouldn't see. In PyroRadius, IDOR is prevented by:
Pattern 1: Route Model Binding with Scope Verification¶
// Route: GET /customers/{customer}
// Laravel resolves: Customer::find($id) → passes to controller
public function show(Customer $customer): Response
{
// If a dealer accesses /customers/999 where customer 999 belongs
// to a different dealer, this check catches it:
$this->authorizeCustomerAccess($customer);
return Inertia::render('Customers/Show', ['customer' => $customer]);
}
Pattern 2: Scoped Queries (Never Trust the URL ID Alone)¶
// Vulnerable (don't do this):
$invoice = Invoice::find($request->invoice_id);
// Safe (always scope):
$invoice = Invoice::where('id', $request->invoice_id)
->where('dealer_id', auth()->id())
->firstOrFail();
// Returns 404 instead of 403 for non-existent or out-of-scope records
// This prevents confirming the existence of records the user can't access
Pattern 3: Policy Authorization¶
// In InvoiceController:
public function show(Invoice $invoice): Response
{
$this->authorize('view', $invoice); // Calls InvoicePolicy@view
// ...
}
// In app/Policies/InvoicePolicy.php:
public function view(User $user, Invoice $invoice): bool
{
if ($user->isAdmin()) return true;
return $invoice->dealer_id === $user->id;
}
Pattern 4: Mass Assignment Protection¶
All models define $fillable arrays. The $guarded = ['id', 'dealer_id'] pattern prevents dealer_id from being mass-assigned via request data:
class Customer extends Model
{
protected $fillable = [
'name', 'email', 'phone', 'package_id', 'connection_type_id',
'pppoe_username', 'pppoe_password', 'address', 'expiry_date',
// ... other fields
// NOT dealer_id — this is set explicitly in the controller
];
// In controller:
$customer = new Customer($request->validated());
$customer->dealer_id = auth()->id(); // Set explicitly, not from request
$customer->save();
}
Policy Files¶
Laravel policies in app/Policies/ provide resource-level authorization. Each policy corresponds to a model.
| Policy File | Model | Key Methods |
|---|---|---|
CustomerPolicy.php |
Customer | view, create, update, delete, viewPppoe |
InvoicePolicy.php |
Invoice | view, create, update, download |
PaymentPolicy.php |
Payment | view, create, delete (refund/void) |
PackagePolicy.php |
Package | view, create, update, delete |
NasPolicy.php |
Nas | view, create, update, delete |
OltPolicy.php |
Olt | view, create, update, delete |
TicketPolicy.php |
Ticket | view, update, resolve, delete |
UserPolicy.php |
User | view, create, update, delete (manage dealers/sub-dealers) |
AuditLogPolicy.php |
AuditLog | view, prune |
ReportPolicy.php |
(abstract) | view, export |
WhatsappTemplatePolicy.php |
WhatsappTemplate | view, create, update, delete |
CampaignPolicy.php |
Campaign | view, create, run, delete |
Policy Registration¶
Policies are registered in app/Providers/AuthServiceProvider.php:
protected $policies = [
Customer::class => CustomerPolicy::class,
Invoice::class => InvoicePolicy::class,
Payment::class => PaymentPolicy::class,
Package::class => PackagePolicy::class,
Nas::class => NasPolicy::class,
Olt::class => OltPolicy::class,
Ticket::class => TicketPolicy::class,
User::class => UserPolicy::class,
// ... etc.
];
Policy Pattern for Dealer-Scoped Resources¶
// app/Policies/CustomerPolicy.php
class CustomerPolicy
{
public function before(User $user, string $ability): bool|null
{
// super_admin and admin pass all checks
if ($user->isAdmin()) {
return true;
}
return null; // Continue to specific method check
}
public function view(User $user, Customer $customer): bool
{
if (!$user->hasPermission('customers.view')) return false;
return match($user->role) {
'dealer' => $customer->dealer_id === $user->id,
'sub_dealer' => $customer->sub_dealer_id === $user->id,
default => false,
};
}
public function create(User $user): bool
{
return $user->hasPermission('customers.create');
}
public function update(User $user, Customer $customer): bool
{
if (!$user->hasPermission('customers.edit')) return false;
return $this->view($user, $customer); // Must also be able to view it
}
public function delete(User $user, Customer $customer): bool
{
if (!$user->hasPermission('customers.delete')) return false;
return $this->view($user, $customer);
}
}
Middleware Chain¶
Every admin web request passes through this middleware stack (defined in app/Http/Kernel.php):
web group:
1. EncryptCookies — encrypts/decrypts all cookies
2. AddQueuedCookiesToResponse — appends queued cookies to response
3. StartSession — starts/reads Redis session
4. ShareErrorsFromSession — shares validation errors across redirect
5. VerifyCsrfToken — validates X-XSRF-TOKEN on mutations
6. SubstituteBindings — resolves route model bindings
Route-specific middleware:
7. auth — verifies user is authenticated
8. HandleInertiaRequests — shares auth/flash data to Inertia pages
9. CheckPermission (or inline) — verifies specific permission key
auth Middleware¶
Standard Laravel middleware. Redirects to /login if not authenticated. For portal routes, uses auth:portal to check the portal guard.
HandleInertiaRequests Middleware¶
This middleware runs on every Inertia request and populates shared page props:
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
$user = $request->user();
return array_merge(parent::share($request), [
'auth' => [
'user' => $user ? [
'id' => $user->id,
'name' => $user->name,
'email'=> $user->email,
'role' => $user->role,
] : null,
'permissions' => $user?->permissions ?? [],
'isAdmin' => $user?->isAdmin() ?? false,
'isSuperAdmin'=> $user?->isSuperAdmin() ?? false,
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
'warning' => $request->session()->get('warning'),
],
'viewingAs' => $request->session()->get('viewing_as_dealer'),
'appSettings' => Cache::remember('app_settings', 300, fn() => Setting::pluck('value', 'key')),
]);
}
CheckPermission Middleware (if implemented as middleware)¶
Some routes may use a dedicated middleware that checks a specific permission key:
// Usage in routes/web.php:
Route::get('/reports', [ReportController::class, 'index'])
->middleware(['auth', 'permission:reports.view']);
// app/Http/Middleware/CheckPermission.php:
public function handle(Request $request, Closure $next, string $permission): Response
{
if (!$request->user()->hasPermission($permission)) {
if ($request->inertia()) {
return back()->with('error', 'Access denied.');
}
abort(403, 'Forbidden');
}
return $next($request);
}
How to Add a New Permission¶
When adding a new feature that requires access control:
Step 1: Choose a Permission Key¶
Follow the resource.action naming convention:
- feature.view — read access
- feature.create — create new records
- feature.edit — modify existing records
- feature.delete — remove records
- feature.export — export data
Example for a new "Topology Map" feature: topology.view
Step 2: Add the Check in the Controller¶
// In app/Http/Controllers/TopologyController.php:
public function index(): Response
{
if (!auth()->user()->hasPermission('topology.view')) {
abort(403, 'You do not have permission to view the topology map.');
}
// ... controller logic
return Inertia::render('Network/Topology', $data);
}
Step 3: Add to the Policy (if applicable)¶
// In app/Policies/TopologyPolicy.php:
public function view(User $user): bool
{
return $user->hasPermission('topology.view');
}
Step 4: Update the User Management UI¶
In the dealer permissions form (wherever admins grant permissions to dealers), add a checkbox for the new topology.view key. This is typically in a React component like Users/EditPermissions.jsx.
Step 5: Add Frontend Guard¶
In the React component that renders the topology link/button:
const { auth } = usePage().props;
{(auth.isAdmin || auth.permissions?.['topology.view']) && (
<Link href={route('topology.index')}>Topology Map</Link>
)}
Step 6: Document the Permission¶
Add the new permission key to the permission reference table in this document and in 04_User_Roles.md.
Permission Keys Reference Table¶
| Permission Key | What It Controls | Super Admin | Admin | Dealer (default) | Sub Dealer (default) |
|---|---|---|---|---|---|
customers.view |
View full customer detail | Auto | Auto | Explicit | Explicit |
customers.create |
Create new customers | Auto | Auto | Explicit | Explicit |
customers.edit |
Edit customer data | Auto | Auto | Explicit | Explicit |
customers.delete |
Delete customers | Auto | Auto | Explicit | No |
customers.pppoe |
View PPPoE password | Auto | Auto | Explicit | No |
billing.invoices |
View invoices | Auto | Auto | Explicit | Explicit |
billing.payments |
Record payments | Auto | Auto | Explicit | Explicit |
billing.recharge |
Renew subscriptions | Auto | Auto | Explicit | Explicit |
billing.ledger |
View ledger entries | Auto | Auto | Explicit | No |
billing.adjustments |
Manual adjustments | Auto | Auto | No | No |
reports.view |
Access reports | Auto | Auto | Explicit | No |
reports.export |
Export to Excel | Auto | Auto | Explicit | No |
whatsapp.send |
Send WhatsApp manually | Auto | Auto | Explicit | No |
whatsapp.templates |
Manage templates | Auto | Auto | No | No |
whatsapp.campaigns |
Run campaigns | Auto | Auto | No | No |
tickets.view |
View tickets | Auto | Auto | Explicit | Explicit |
tickets.create |
Create tickets | Auto | Auto | Explicit | Explicit |
tickets.manage |
Assign/resolve tickets | Auto | Auto | Explicit | No |
network.nas |
View NAS devices | Auto | Auto | No | No |
network.olts |
View OLT devices | Auto | Auto | No | No |
network.signal |
View ONT signal | Auto | Auto | Explicit | No |
import.customers |
Bulk import | Auto | Auto | No | No |
audit.view |
View audit logs | Auto | Auto | No | No |
system.settings |
Edit settings | Auto | No | No | No |
system.company |
Edit company profile | Auto | No | No | No |
system.crons |
Manage cron jobs | Auto | Auto | No | No |
Legend: - Auto — Role bypasses JSONB check entirely (always allowed) - Explicit — Must be explicitly granted in the dealer's JSONB permissions - No — Not granted by default (admin can grant explicitly if needed)
Examples in Controllers and React¶
Controller: Listing Customers with Full Authorization¶
// app/Http/Controllers/CustomerController.php
public function index(Request $request): Response
{
// Step 1: Check view permission
if (!auth()->user()->hasPermission('customers.view')) {
abort(403, 'Permission denied: customers.view required.');
}
$user = auth()->user();
// Step 2: Build scoped query
$query = Customer::with(['package', 'area', 'customerStatus'])
->orderBy('name');
// Step 3: Apply dealer scope
if (!$user->isAdmin()) {
if ($user->role === 'dealer') {
$query->where('dealer_id', $user->id);
} elseif ($user->role === 'sub_dealer') {
$query->where('sub_dealer_id', $user->id);
}
}
// Step 4: Apply search filters from request
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('pppoe_username', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
});
}
$customers = $query->paginate(25)->withQueryString();
return Inertia::render('Customers/Index', [
'customers' => $customers,
'filters' => $request->only(['search', 'status', 'area_id']),
]);
}
Controller: Showing a Single Customer (IDOR Prevention)¶
public function show(Customer $customer): Response
{
// Laravel route model binding resolved Customer::find($id)
// Now we must verify access:
$user = auth()->user();
// Check view permission
if (!$user->hasPermission('customers.view')) {
abort(403);
}
// Check dealer ownership (IDOR prevention)
if (!$user->isAdmin()) {
if ($user->role === 'dealer' && $customer->dealer_id !== $user->id) {
abort(403, 'This customer does not belong to your account.');
}
if ($user->role === 'sub_dealer' && $customer->sub_dealer_id !== $user->id) {
abort(403, 'This customer does not belong to your account.');
}
}
return Inertia::render('Customers/Show', [
'customer' => $customer->load(['package', 'invoices', 'payments']),
'canEdit' => $user->hasPermission('customers.edit'),
'canDelete'=> $user->hasPermission('customers.delete'),
]);
}
React: Conditional UI Rendering Based on Permissions¶
// resources/js/Pages/Customers/Index.jsx
import { Link, usePage } from '@inertiajs/react';
export default function CustomersIndex({ customers }) {
const { auth } = usePage().props;
// Determine what the current user can do
const canCreate = auth.isAdmin || auth.permissions?.['customers.create'];
const canEdit = auth.isAdmin || auth.permissions?.['customers.edit'];
const canDelete = auth.isAdmin || auth.permissions?.['customers.delete'];
const canImport = auth.isAdmin || auth.permissions?.['import.customers'];
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1>Customers</h1>
<div className="flex gap-2">
{canImport && (
<Link href={route('customers.import')}>
Import Excel
</Link>
)}
{canCreate && (
<Link href={route('customers.create')}>
Add Customer
</Link>
)}
</div>
</div>
<table>
<tbody>
{customers.data.map(customer => (
<tr key={customer.id}>
<td>{customer.name}</td>
<td>{customer.pppoe_username}</td>
<td>
{/* View is always shown if we're on the list page
(customers.view permission required to reach here) */}
<Link href={route('customers.show', customer.id)}>
View
</Link>
{canEdit && (
<Link href={route('customers.edit', customer.id)}>
Edit
</Link>
)}
{canDelete && (
<button onClick={() => handleDelete(customer.id)}>
Delete
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
React: Permission-Aware Sidebar Navigation¶
// resources/js/Components/Sidebar.jsx
import { usePage, Link } from '@inertiajs/react';
export default function Sidebar() {
const { auth } = usePage().props;
const { isAdmin, isSuperAdmin, permissions } = auth;
const can = (key) => isAdmin || permissions?.[key] === true;
return (
<nav>
<Link href={route('dashboard')}>Dashboard</Link>
{can('customers.view') && (
<Link href={route('customers.index')}>Customers</Link>
)}
{can('billing.invoices') && (
<Link href={route('invoices.index')}>Invoices</Link>
)}
{can('billing.payments') && (
<Link href={route('payments.index')}>Payments</Link>
)}
{can('reports.view') && (
<Link href={route('reports.index')}>Reports</Link>
)}
{isAdmin && (
<>
<Link href={route('packages.index')}>Packages</Link>
<Link href={route('nas.index')}>NAS Devices</Link>
<Link href={route('olts.index')}>OLTs</Link>
<Link href={route('users.index')}>Users</Link>
</>
)}
{isSuperAdmin && (
<>
<Link href={route('settings.index')}>Settings</Link>
<Link href={route('health.index')}>Health & Scheduler</Link>
</>
)}
</nav>
);
}
This document covers the complete authorization model. For role definitions and business context, see 04_User_Roles.md. For database schema of the permissions column, see 03_Database_Architecture.md.