Skip to main content

Rate Limiting

All named rate limiters are registered in app/Providers/InfrastructureServiceProvider.php and app/Providers/AppServiceProvider.php. They are applied per route group via Laravel’s throttle middleware.
Limiter NameLimitKeyed ByApplied To
api60 req/minuser ID or IPAll API routes
login5 req/minemail + IPLogin endpoint
otp-request3 req/minIPOTP generation (SMS bombing prevention)
otp-verify5 req/minuser ID or IPOTP verification
registration3 req/hourIPNew account registration
api.otp-generation3 req/minIPAPI OTP generation
api.verification10 req/minuser ID or IPCoupon validation, identity checks
api.transactions20 req/minuser ID or IPOrder creation, payment submission
tenant-aware120 req/mintenant ID + IPPublic storefront and analytics endpoints
search30 req/minIPGlobal search
uploads10 req/minuser ID or IPFile upload endpoints
notifications30 req/minuser ID or IPNotification polling
Nginx enforces an additional network-level rate limit before requests reach PHP:
# 60 requests/minute to API routes
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m;

# 10 requests/minute to login/register/password routes
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=10r/m;

Tenant-Aware Rate Limiting

The tenant-aware limiter uses a composite key of tenant_id + IP to prevent botnet attacks targeting a specific tenant’s storefront:
RateLimiter::for('tenant-aware', function (Request $request) {
    $context = app(\App\Domain\Shared\Contracts\TenantContext::class);
    try {
        $tenantId = (string) $context->getTenantId();
    } catch (\RuntimeException) {
        $tenantId = (string) $request->input('tenant_id', '');
    }

    $key = $tenantId
        ? "tenant_id:{$tenantId}|ip:" . $request->ip()
        : 'ip:' . $request->ip();

    return Limit::perMinute(120)->by($key);
});

Idempotency

Write operations that must not be executed more than once — OTP generation, payment submissions, order creation — are protected by idempotency middleware. Clients send an Idempotency-Key header; the server records each key and replays the original response on duplicate requests instead of re-executing the handler. Key components:
  • app/Http/Middleware/EnsureIdempotencyKey.php — enforces the header is present on write routes
  • app/Http/Middleware/SmartIdempotencyMiddleware.php — checks for a prior result and short-circuits
  • app/Infrastructure/Common/Idempotency/AtomicIdempotencyService.php — stores keys atomically in Redis
  • app/Infrastructure/Common/Idempotency/RedisIdempotencyService.php — Redis-backed implementation
Idempotency keys are pruned daily at 03:30 by the auth:prune-idempotency --days=30 --chunk=1000 scheduled task.

Signed URLs

Signed URLs prevent unauthorized access to resources that must be shared with unauthenticated users. The following routes require a valid Laravel signature:
RoutePurpose
Guest appointment managementAllows customers to confirm, reschedule, or cancel appointments without logging in
Report / export downloadsLets users download generated PDFs and exports from notification emails
Email verificationStandard Laravel email verification link
Example — appointment management route in routes/public.php uses ->middleware('signed'):
Route::get('/appointments/{appointment}/manage', ...)
    ->middleware('signed');
Never expose raw file paths or S3 keys in URLs. Always generate signed URLs server-side with a short expiry. For media uploads, use pre-signed S3 upload URLs so files go directly to S3 without passing through the application server.

RBAC — Roles and Permissions

Vito Business OS uses Spatie Laravel Permission for fine-grained RBAC. Roles are seeded by database/seeders/RolesAndPermissionsSeeder.php.

Roles

RoleAccess LevelCapabilities
super_adminFull controlAll permissions across all resources
ventasRead-only tenantsView tenants, categories, cities — no deletes, no settings
soporteRetention opsView + edit tenants, impersonate users — no payment data or financial widgets
auditorRead everythingView tenants, payments, users, activity logs, financial widgets — no writes
panel_userTenant panelBusiness owners managing their own tenant — access controlled by policies and tenant scoping

Seed Roles

php artisan db:seed --class=RolesAndPermissionsSeeder

Permission Structure

Permissions follow the {action}_{resource} convention. Example resource permissions:
view_any_tenant  view_tenant  create_tenant  update_tenant  delete_tenant
view_any_payment  view_payment  approve_payment  reject_payment
view_any_user  impersonate_user
financial_stats_widget  pending_payments_widget
The super_admin role receives Permission::all() at seed time and is automatically synchronized whenever the seeder runs.

Tenant Ownership Middleware

The tenant.ownership middleware (app/Http/Middleware/EnsureTenantOwnership.php) is a perimeter guard applied to all tenant-scoped resource routes:
Route::middleware(['auth:sanctum', 'tenant.ownership', 'throttle:api'])->group(function () {
    Route::resource('products', ProductController::class);
    // ...
});
For every route-model-bound parameter with a tenant_id column, the middleware verifies the model belongs to the authenticated user’s tenant. On violation, it returns HTTP 404 — not 403 — to avoid disclosing whether the resource exists:
// Returns 404 to prevent information disclosure
// (Don't reveal if resource exists but is denied)
abort(404);
All violations are logged to the security log channel with event type TENANT_OWNERSHIP_VIOLATION and severity HIGH, including user ID, IP address, user agent, route name, and the model type and ID that was accessed.

Impersonation Audit Trail

Super admins can impersonate tenant users via the Filament admin panel. Every impersonation session leaves a forensic audit trail managed by app/Http/Controllers/Auth/ImpersonationController.php:
  1. Pre-condition check — Logs a warning if leave is called without an active impersonator_id session key.
  2. Audit log on exit — Emits an impersonation_ended log event containing original_admin_id, impersonated_user_id, IP address, user agent, and timestamp.
  3. Session rotation — Calls $request->session()->regenerate() after restoring the admin session to prevent session fixation attacks.
  4. Fallback safety — If the original admin account cannot be found, the session is fully invalidated and the user is redirected to /login.
// Forensic Audit Log (from ImpersonationController)
$this->logger->info('impersonation_ended', [
    'original_admin_id'    => $impersonatorId,
    'impersonated_user_id' => $impersonatedUserId,
    'ip_address'           => $request->ip(),
    'user_agent'           => $request->userAgent(),
    'timestamp'            => now()->toIso8601String(),
]);

Payment Webhook Verification

Incoming payment webhooks are verified using a shared secret before any processing occurs. The secret is injected into PaymentWebhookController via the service container, not read directly from config():
// AppServiceProvider — contextual binding
$this->app->when(PaymentWebhookController::class)
    ->needs('$webhookSecret')
    ->give(function () {
        $secret = config('services.payment.webhook_secret');
        if (!is_string($secret) || empty($secret)) {
            throw new \RuntimeException('Webhook secret is not configured.');
        }
        return $secret;
    });
Set the secret in .env:
PAYMENT_WEBHOOK_SECRET=whsec_your_production_secret
Do not commit a real PAYMENT_WEBHOOK_SECRET to version control. The .env.example ships with whsec_dummy_secret_for_local_dev_only — replace it before processing live webhooks.

Input Validation

All write operations are validated using Laravel FormRequest classes before reaching the controller or command handler. The CommandValidatorRegistry (registered in AppServiceProvider) maps commands to their validators:
  • RegisterUserCommandRegisterUserValidator
  • DeleteUserCommandDeleteUserCommandValidator
  • VerifyOtpCommandVerifyOtpCommandValidator
  • ReportPaymentCommandReportPaymentCommandValidator
  • RequestUpgradeCommandRequestUpgradeCommandValidator
Contributors must add a FormRequest or command validator for any new write route. Do not bypass validation by accepting raw $request->all() input in controllers.

Security Response Headers

HTTP security headers are applied by app/Http/Middleware/SecurityHeaders and supplemented by Nginx:
HeaderValue
X-Frame-OptionsSAMEORIGIN
X-Content-Type-Optionsnosniff
X-XSS-Protection1; mode=block
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=()
HSTS (Strict-Transport-Security) is present in nginx_app.conf but commented out. Enable it after confirming SSL is working correctly:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

Pre-Signed S3 Uploads

Media uploads use pre-signed S3 URLs generated server-side. The client uploads directly to S3 without the file passing through the application server. This means:
  • The web worker is never blocked on large binary transfers.
  • S3 access credentials are never exposed to the client.
  • Upload size limits are enforced by the S3 pre-signed URL policy, not Nginx.

Security Artisan Commands

CommandSignaturePurpose
security:attack-otpsecurity:attack-otp {identifier} {tenantId} {code}Simulates an OTP brute-force attack. Used in automated tests to verify the MaxOtpAttemptsExceededException lockout is triggered correctly.
auth:clear-otpsauth:clear-otpsDeletes expired OTP records older than 1 hour. Useful for clearing test data or responding to an OTP-related incident.

Production Security Checklist

  • APP_DEBUG=false and APP_ENV=production in .env
  • PAYMENT_WEBHOOK_SECRET set to a real secret
  • Database credentials are unique and strong
  • Redis password set if exposed beyond localhost
  • SSL certificate installed and HSTS header enabled
  • Firewall allows only ports 80, 443, and 22
  • SSH uses key-based authentication (password auth disabled)
  • .env file permissions: chmod 640 .env
  • storage/ and bootstrap/cache/ owned by www-data
  • Unattended security upgrades enabled: sudo dpkg-reconfigure unattended-upgrades

Build docs developers (and LLMs) love