Skip to content

28 — Security & Audit

Overview

PyroRadius is a multi-tenant ISP billing system with real financial and network operations impact. Security is layered: authentication prevents unauthorized access, authorization (roles + JSONB permissions + dealer scoping) prevents privilege escalation within the system, audit logging tracks all significant actions, and specific technical controls mitigate known ISP billing attack vectors.


Authentication Security

Password Hashing

All user account passwords are hashed using bcrypt (Laravel's default, via Hash::make()). The bcrypt cost factor is Laravel's default (10). Raw passwords are never stored or logged.

CSRF Protection

All state-changing requests (POST, PUT, PATCH, DELETE) require a valid CSRF token. Laravel's VerifyCsrfToken middleware is active on all web routes.

On the frontend, Axios has the CSRF token set as a default header in resources/js/bootstrap.js:

axios.defaults.headers.common['X-CSRF-TOKEN'] =
    document.querySelector('meta[name="csrf-token"]').getAttribute('content');

Inertia.js also automatically includes the CSRF token in all Inertia form submissions via its built-in X-XSRF-TOKEN header (read from the XSRF-TOKEN cookie set by Laravel).

Session Regeneration

On successful login, Laravel calls $request->session()->regenerate() to issue a new session ID, preventing session fixation attacks.

Session Security Configuration

config/session.php relevant settings in production:

'secure'    => true,          // Cookies only sent over HTTPS
'http_only' => true,          // JavaScript cannot read session cookie
'same_site' => 'lax',         // CSRF protection for cross-site requests
'encrypt'   => true,          // Session data encrypted at rest
'lifetime'  => 120,           // 2-hour session timeout (minutes)

Brute Force Protection

Laravel's built-in ThrottleRequests middleware is applied to the login route:

Route::post('/login', [AuthController::class, 'store'])
    ->middleware('throttle:5,1');  // 5 attempts per minute

After 5 failed login attempts, the IP is locked out for 1 minute. Subsequent attempts return HTTP 429.


Authorization Model

Role Hierarchy

Role Description
super_admin Full access to all dealers, all settings, all data. Bypasses all permission checks.
admin Full access within their own dealer context. Can manage sub-dealers and staff.
staff Limited access within dealer context. Restricted by JSONB permissions.
dealer Same as admin for their own dealer. Cannot see other dealers' data.

The role column is on the users table (VARCHAR). The policy base class checks $user->role === 'super_admin' first and short-circuits to allow all.

JSONB Permissions

Fine-grained permissions are stored in users.permissions (PostgreSQL JSONB column). Structure:

{
  "customers.view": true,
  "customers.create": true,
  "customers.edit": true,
  "customers.delete": false,
  "customers.import": false,
  "payments.create": true,
  "payments.delete": false,
  "reports.view": true,
  "settings.view": false,
  "settings.edit": false
}

A helper method on the User model:

public function can(string $permission): bool
{
    if ($this->role === 'super_admin') return true;
    return (bool) ($this->permissions[$permission] ?? false);
}

Backend Policy Pattern

Each major resource has a corresponding Laravel Policy:

app/Policies/CustomerPolicy.php
app/Policies/PaymentPolicy.php
app/Policies/InvoicePolicy.php
app/Policies/NasPolicy.php
app/Policies/TicketPolicy.php

Policies check both role and JSONB permission:

public function create(User $user): bool
{
    return $user->role === 'super_admin'
        || ($user->dealer_id && ($user->permissions['customers.create'] ?? false));
}

public function delete(User $user, Customer $customer): bool
{
    if ($user->role !== 'super_admin' && $customer->dealer_id !== $user->dealer_id) {
        return false;  // Hard dealer scope check
    }
    return $user->role === 'super_admin'
        || ($user->permissions['customers.delete'] ?? false);
}

Controllers use $this->authorize('delete', $customer) which invokes the policy and returns 403 if denied.


Dealer Scoping (IDOR Prevention)

Every resource in PyroRadius belongs to a dealer (dealer_id foreign key). Dealer scoping is the primary defense against Insecure Direct Object Reference (IDOR) attacks.

The Scoping Rule

Every database query that retrieves user-owned resources must include a dealer_id filter. This is enforced in two places:

  1. Global Scopes / Controller base class: The DealerScopedController base class applies a where('dealer_id', Auth::user()->dealer_id) automatically to all queries in child controllers.

  2. Policy ownership check: Even if a route is accessed with a valid resource ID, the policy verifies $resource->dealer_id === $user->dealer_id before allowing the action.

Example: CustomerController

// CORRECT — dealer scoped
$customers = Customer::where('dealer_id', $user->dealer_id)
    ->where('status', 'active')
    ->get();

// ALSO CORRECT — policy enforces scope even if query is missed
$customer = Customer::findOrFail($id);
$this->authorize('view', $customer);  // policy checks dealer_id match

super_admin users bypass dealer scoping and can query all records.


Sensitive Operations with Extra Checks

Operation Extra Protection
Manual recharge payments.create permission required; amount validated > 0; customer must belong to user's dealer
Payment delete/void payments.delete required; only super_admin in production; AuditLogger records who deleted and when
Customer delete customers.delete required; soft delete only (no hard delete from UI); audit logged
Customer archive customers.edit required; audit logged with before/after snapshot
Settings edit settings.edit or super_admin; settings changes audit logged
RADIUS bulk sync super_admin only; dispatches queue jobs asynchronously to prevent API abuse
User password reset super_admin only; triggers audit log entry

PPPoE Password Storage

pppoe_password is stored in plain text in the radcheck table and customers table.

This is a documented, intentional design decision with the following rationale:

  • FreeRADIUS performs PAP/CHAP authentication by comparing the password sent by the PPPoE client directly against the stored value.
  • PAP sends the password in cleartext (though over an encrypted PPPoE tunnel). CHAP sends a challenge-response that requires the server to know the original plaintext password.
  • Hashing is fundamentally incompatible with CHAP authentication and makes PAP require a plaintext lookup anyway.
  • This is standard practice in FreeRADIUS deployments. The accepted mitigation is to use strong network-level controls (firewall the RADIUS port, encrypt the DB connection, restrict RADIUS DB access to localhost).

What is done to mitigate the risk:

  • The RADIUS database (radcheck table) is only accessible from localhost (PostgreSQL pg_hba.conf restricts radcheck to local connections).
  • pppoe_password is not included in any API response, export file, or log output.
  • The audit logger masks pppoe_password in change logs (shows [redacted] instead of the actual value).
  • Customers cannot view their own password via the portal (they must call support to reset it).

api_password Encryption on NAS Model

The api_password field on the Nas model stores the RouterOS API password for MikroTik devices.

This field uses Laravel's encrypted cast:

// app/Models/Nas.php
protected $casts = [
    'api_password' => 'encrypted',
];

Laravel's encrypted cast uses Crypt::encryptString() / Crypt::decryptString() transparently. The value stored in the database is ciphertext using AES-256-CBC with the app's APP_KEY as the encryption key.

This means even if the nas table is accessed directly in the database, api_password appears as a ciphertext string. The key required to decrypt it is the APP_KEY in .env, which is never committed to version control.


AuditLogger

File: app/Services/AuditLogger.php

What Is Logged

The AuditLogger::log() method is called throughout the application to record significant user actions.

Event Type Example
customer.created New customer added
customer.updated Any field on a customer changed
customer.deleted Customer soft-deleted
customer.archived Customer moved to archive
payment.created Payment recorded
payment.deleted Payment voided
invoice.created Invoice generated (manual or auto)
invoice.paid Invoice marked paid
recharge.applied Balance recharge applied to customer
user.login Successful login
user.login_failed Failed login attempt
settings.updated Any setting changed
nas.created NAS device added
nas.deleted NAS device removed
radius.sync Manual RADIUS sync triggered
cron_job.created Cron job added
cron_job.updated Cron job schedule changed

What Is Excluded

  • Read operations (GET requests, view events) are not logged to keep the audit table manageable.
  • Internal system operations triggered by artisan commands (scheduled tasks) do not log to audit_logs (they use command output logs instead).
  • pppoe_password values are masked as [redacted] in the changes column.

audit_logs Table Schema

CREATE TABLE audit_logs (
    id           BIGSERIAL PRIMARY KEY,
    user_id      BIGINT NULL REFERENCES users(id) ON DELETE SET NULL,
    dealer_id    BIGINT NULL REFERENCES users(id) ON DELETE SET NULL,
    event        VARCHAR(100) NOT NULL,
    auditable_type VARCHAR(255) NULL,
    auditable_id   BIGINT NULL,
    old_values   JSONB NULL,
    new_values   JSONB NULL,
    ip_address   VARCHAR(45) NULL,
    user_agent   TEXT NULL,
    created_at   TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_logs_user_id ON audit_logs (user_id);
CREATE INDEX idx_audit_logs_auditable ON audit_logs (auditable_type, auditable_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at DESC);

Retention Policy

The PruneAuditLogs artisan command deletes records older than 30 days from audit_logs. This runs daily at 03:00 via the DB-driven scheduler.


Permission Gate Pattern in React

The can() helper is available on every page via the shared Inertia props:

// resources/js/app.jsx or HandleInertiaRequests.php shares permissions
const { auth } = usePage().props;

// Usage in any component
{auth.can('customers.delete') && (
    <button onClick={handleDelete}>Delete</button>
)}

auth.can is a flat object of permission_key => boolean built from the current user's JSONB permissions and role. It is injected once per request in HandleInertiaRequests::share():

'auth' => [
    'user' => $request->user(),
    'can'  => $this->buildPermissions($request->user()),
],

This approach avoids making separate API calls to check permissions and keeps the permission logic on the server.


Mass Assignment Protection

All Eloquent models use the $fillable whitelist (never $guarded = []):

// app/Models/Customer.php
protected $fillable = [
    'full_name', 'mobile', 'pppoe_username', 'pppoe_password',
    'package_id', 'dealer_id', 'area_id', 'nas_id',
    'status', 'expiry_date', 'address', 'cnic', 'email', 'notes',
    'install_date', 'created_by',
];

Fields not in $fillable (e.g., customer_code, radius_synced_at, deleted_at) cannot be mass-assigned and must be set explicitly.


Raw SQL Usage

Some queries use raw SQL for performance or PostgreSQL-specific features. All raw queries in PyroRadius use parameterized bindings (never string interpolation):

// SAFE — parameterized
Customer::whereRaw('LOWER(pppoe_username) = ?', [strtolower($username)])->first();

// SAFE — parameterized with named bindings
DB::select('SELECT * FROM customers WHERE dealer_id = :dealer', ['dealer' => $dealerId]);

No user-controlled input is ever interpolated directly into a raw SQL string.


Priority Recommendation Rationale
High Add 2FA (TOTP) for super_admin accounts Protects against credential theft for highest-privilege accounts
High Move pppoe_password to a separate, more restricted table with column-level encryption where CHAP is not needed Reduces exposure surface for PAP-only connections
Medium Implement IP allowlist for the admin panel (Nginx config) Limits who can even reach the login page
Medium Add rate limiting to all API endpoints (not just login) Prevents enumeration and scraping
Medium Rotate APP_KEY on a schedule and re-encrypt NAS api_password values Key rotation best practice
Low Add Content-Security-Policy header Mitigates XSS impact
Low Enable Strict-Transport-Security header in Nginx Enforces HTTPS
Low Audit log retention currently 30 days — consider extending to 90 days for compliance Better incident investigation window