Skip to content

PyroRadius — Authorization Model

Version: 1.0 (Documentation) Last Updated: 2026-07-13


Table of Contents

  1. Authentication Overview
  2. Login Flow
  3. Session Management
  4. Authorization Architecture
  5. The User::can() Permission Check
  6. Dealer Scope Enforcement
  7. IDOR Prevention Patterns
  8. Policy Files
  9. Middleware Chain
  10. How to Add a New Permission
  11. Permission Keys Reference Table
  12. 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-TOKEN header (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_token column on users table 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 loginSession::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 logoutAuth::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();

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 checks auth()->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.