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:
-
Global Scopes / Controller base class: The
DealerScopedControllerbase class applies awhere('dealer_id', Auth::user()->dealer_id)automatically to all queries in child controllers. -
Policy ownership check: Even if a route is accessed with a valid resource ID, the policy verifies
$resource->dealer_id === $user->dealer_idbefore 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 (
radchecktable) is only accessible fromlocalhost(PostgreSQLpg_hba.confrestrictsradcheckto local connections). pppoe_passwordis not included in any API response, export file, or log output.- The audit logger masks
pppoe_passwordin 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:
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_passwordvalues are masked as[redacted]in thechangescolumn.
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():
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.
Recommended Security Improvements¶
| 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 |