Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
// These keys can be used for performance testing in production
// Example: THROTTLE_UNLIMITED_API_KEYS=Bearer test_key_123,Bearer load_test_456
'unlimited_keys' => array_filter(explode(',', env('THROTTLE_UNLIMITED_API_KEYS', ''))),

// Count requests per API consumer for the admin "API consumers" view (needs Redis)
// Default: true (enabled). Administrators can also toggle this in the console.
// Example: THROTTLE_TRACK_CONSUMERS=false
'track_consumers' => env('THROTTLE_TRACK_CONSUMERS', true),

// Redis connection the consumer metrics are written to
// Default: cache
'metrics_connection' => env('THROTTLE_METRICS_REDIS_CONNECTION', 'cache'),
],

'cache' => [
Expand Down
3 changes: 2 additions & 1 deletion src/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht
return response()->error('Invalid XSRF token sent with request.', 419);

case 'ThrottleRequestsException':
return response()->error('Too many requests.', 429);
// Keep Retry-After and X-RateLimit-* so the throttled client knows when to retry.
return response()->error('Too many requests.', 429)->withHeaders($exception->getHeaders());

case 'AuthenticationException':
return response()->error('Unauthenticated.', 401);
Expand Down
117 changes: 117 additions & 0 deletions src/Http/Controllers/Internal/v1/RateLimitController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

namespace Fleetbase\Http\Controllers\Internal\v1;

use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Requests\AdminRequest;
use Fleetbase\Models\Company;
use Fleetbase\Support\ApiConsumerMetrics;
use Fleetbase\Support\ApiRateLimits;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;

/**
* System administration of API rate limiting: the limits themselves, per-organization
* overrides, and visibility into which consumers drive traffic or are being throttled.
*/
class RateLimitController extends Controller
{
/**
* The effective settings, the environment defaults beneath them, and the overrides
* with their organizations resolved for display.
*/
public function getSettings(AdminRequest $request): JsonResponse
{
return response()->json($this->settingsPayload(ApiRateLimits::settings()));
}

/**
* Save the administrator's settings; they apply from the next request.
*/
public function saveSettings(AdminRequest $request): JsonResponse
{
$validated = $request->validate([
'enabled' => ['required', 'boolean'],
'max_attempts' => ['required', 'integer', 'min:1', 'max:1000000'],
'decay_minutes' => ['required', 'integer', 'min:1', 'max:1440'],
'track_consumers' => ['sometimes', 'boolean'],
'overrides' => ['sometimes', 'array'],
'overrides.*.company_uuid' => ['required', 'string', 'distinct', Rule::exists('companies', 'uuid')],
'overrides.*.unlimited' => ['sometimes', 'boolean'],
'overrides.*.max_attempts' => ['nullable', 'integer', 'min:1', 'max:1000000'],
'overrides.*.note' => ['nullable', 'string', 'max:255'],
]);

return response()->json($this->settingsPayload(ApiRateLimits::store($validated)));
}

/**
* Discard the administrator's settings and fall back to the environment.
*/
public function resetSettings(AdminRequest $request): JsonResponse
{
return response()->json($this->settingsPayload(ApiRateLimits::reset()));
}

/**
* The busiest (or most throttled) API consumers over a recent window.
*/
public function consumers(AdminRequest $request): JsonResponse
{
$request->validate([
'window' => ['sometimes', 'integer', Rule::in(ApiConsumerMetrics::WINDOWS)],
'sort' => ['sometimes', Rule::in(['hits', 'throttled'])],
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
]);

$settings = ApiRateLimits::settings();
$metrics = ApiConsumerMetrics::top(
(int) $request->input('window', 15),
(string) $request->input('sort', 'hits'),
(int) $request->input('limit', 50)
);

$metrics['tracking'] = $settings['track_consumers'];
$metrics['default_limit'] = $settings['max_attempts'];
$metrics['decay_minutes'] = $settings['decay_minutes'];

return response()->json($metrics);
}

/**
* Clear a consumer's current limiter window so it can send requests again at once.
*/
public function resetConsumer(AdminRequest $request, string $signature): JsonResponse
{
if (!preg_match('/^[a-f0-9]{40}$/', $signature)) {
return response()->error('Invalid consumer.', 422);
}

RateLimiter::clear($signature);

return response()->json(['status' => 'OK']);
}

protected function settingsPayload(array $settings): array
{
$companies = Company::whereIn('uuid', array_column($settings['overrides'], 'company_uuid'))
->get(['uuid', 'public_id', 'name'])
->keyBy('uuid');

$settings['overrides'] = array_map(function (array $override) use ($companies) {
$company = $companies->get($override['company_uuid']);

return array_merge($override, [
'company_id' => $company?->public_id,
'company_name' => $company?->name,
]);
}, $settings['overrides']);

return [
'settings' => $settings,
'defaults' => ApiRateLimits::defaults(),
'unlimited_keys' => count(config('api.throttle.unlimited_keys', [])),
];
}
}
128 changes: 118 additions & 10 deletions src/Http/Middleware/ThrottleRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Fleetbase\Http\Middleware;

use Fleetbase\Support\ApiConsumerMetrics;
use Fleetbase\Support\ApiRateLimits;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Routing\Middleware\ThrottleRequests as ThrottleRequestsMiddleware;
use Illuminate\Support\Facades\Log;

Expand All @@ -10,10 +13,15 @@ class ThrottleRequests extends ThrottleRequestsMiddleware
/**
* Handle an incoming request.
*
* This middleware supports multiple bypass mechanisms:
* 1. Global disable via THROTTLE_ENABLED=false (for development/testing)
* Limits come from ApiRateLimits: the environment (config/api.php) supplies the
* defaults and a system administrator may override them, per organization too, from
* the console. Two bypasses remain for operators:
* 1. Global disable via THROTTLE_ENABLED=false or the admin setting
* 2. Unlimited API keys via THROTTLE_UNLIMITED_API_KEYS (for production testing)
*
* Every request is also counted per consumer (ApiConsumerMetrics) so administrators
* can see who is driving traffic and who is being throttled.
*
* @param \Illuminate\Http\Request $request
* @param int|string $maxAttempts
* @param float|int $decayMinutes
Expand All @@ -23,8 +31,10 @@ class ThrottleRequests extends ThrottleRequestsMiddleware
*/
public function handle($request, \Closure $next, $maxAttempts = null, $decayMinutes = null, $prefix = '')
{
// Check if throttling is globally disabled via configuration
if (config('api.throttle.enabled', true) === false) {
$settings = ApiRateLimits::settings();

// Check if throttling is globally disabled
if ($settings['enabled'] === false) {
// Log when throttling is disabled (for security monitoring)
if (app()->environment('production')) {
Log::warning('API throttling is DISABLED globally', [
Expand All @@ -35,7 +45,7 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu
]);
}

return $next($request);
return $this->passThrough($request, $next, $settings, null);
}

// Check if request is using an unlimited/test API key
Expand All @@ -49,14 +59,112 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu
'method' => $request->method(),
]);

return $next($request);
return $this->passThrough($request, $next, $settings, null);
}

// Organization overrides need to know whose credential this is; skip the lookup
// when there are none and consumers are not being tracked.
$consumer = ($settings['overrides'] || $settings['track_consumers']) ? $this->describeConsumer($request) : [];
$limit = ApiRateLimits::limitFor($consumer['company_uuid'] ?? null, $settings);

if ($limit === null) {
return $this->passThrough($request, $next, $settings, null, $consumer);
}

try {
$response = parent::handle($request, $next, $limit, $settings['decay_minutes'], $prefix);
} catch (ThrottleRequestsException $exception) {
$this->recordConsumer($request, $settings, $limit, $consumer, true);

throw $exception;
}

$this->recordConsumer($request, $settings, $limit, $consumer);

return $response;
}

/**
* Let a request through unthrottled, still counting it for the consumer view.
*/
protected function passThrough($request, \Closure $next, array $settings, ?int $limit, ?array $consumer = null)
{
$response = $next($request);

$this->recordConsumer($request, $settings, $limit, $consumer);

return $response;
}

/**
* Count the request against its consumer when consumer tracking is on.
*/
protected function recordConsumer($request, array $settings, ?int $limit, ?array $consumer = null, bool $throttled = false): void
{
if (!$settings['track_consumers']) {
return;
}

// Normal throttling: Get limits from configuration
$maxAttempts = config('api.throttle.max_attempts', 90);
$decayMinutes = config('api.throttle.decay_minutes', 1);
$consumer = $consumer ?: $this->describeConsumer($request);

ApiConsumerMetrics::record($this->resolveRequestSignature($request), array_merge($consumer, [
'scope' => $request->segment(1) ?? '',
'ip' => $request->ip(),
'limit' => $limit,
]), $throttled);
}

/**
* Who is making this request, for overrides and the consumer view.
*/
protected function describeConsumer($request): array
{
if ($credential = $this->extractApiKey($request)) {
return ApiRateLimits::identify($credential);
}

if ($user = $request->user()) {
return [
'type' => 'user',
'label' => $user->name ?? $user->email ?? (string) $user->getAuthIdentifier(),
'company_uuid' => $user->company_uuid ?? null,
];
}

return ['type' => 'ip', 'label' => $request->ip(), 'company_uuid' => null];
}

/**
* Resolve the limiter key from the consumer rather than the connection.
*
* This middleware runs ahead of API authentication, so Laravel's default signature
* (the authenticated user, else route domain + client IP) always fell through to the
* IP. Behind a load balancer or reverse proxy that IP is the proxy's, and no route has
* a domain, so every tenant, API key and console visitor shared one bucket: a single
* busy integration returned 429 to the entire platform.
*
* The presented credential identifies the consumer without a database lookup, so the
* key is the hashed credential. Only requests carrying no credential fall back to the
* user, then the IP. The first path segment ("v1", "int", ...) keeps the public API and
* the console's public routes in separate buckets.
*
* @param \Illuminate\Http\Request $request
*
* @return string
*/
protected function resolveRequestSignature($request)
{
$scope = 'fleetbase-throttle|' . ($request->segment(1) ?? '');

if ($credential = $this->extractApiKey($request)) {
return sha1($scope . '|credential|' . $credential);
}

if ($user = $request->user()) {
return sha1($scope . '|user|' . $user->getAuthIdentifier());
}

return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix);
return sha1($scope . '|ip|' . $request->ip());
}

/**
Expand Down
Loading