From 69df6f488671aab5b25ef6e66b8749f533d11056 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 29 Sep 2026 14:57:36 +0800 Subject: [PATCH 1/3] fix(throttle): key the API rate limiter on the consumer, not the proxy IP ThrottleRequests runs before API authentication, so Laravel's default signature always fell back to route domain + client IP. Behind a load balancer that IP is the balancer's, and no route has a domain, so every tenant, API key and console visitor shared one bucket: one busy integration returned 429 to the whole platform. Key the limiter on the hashed credential (API key, Sanctum token, basic auth), falling back to the user and then the IP only when none is sent, and scope it by first path segment so /v1 and /int stay separate. Also keep Retry-After and X-RateLimit-* on the 429 response so throttled clients know when to retry. --- src/Exceptions/Handler.php | 3 +- src/Http/Middleware/ThrottleRequests.php | 33 +++++++++++ .../Unit/Exceptions/ExceptionHandlerTest.php | 16 +++++ tests/Unit/Http/MiddlewareContractsTest.php | 59 +++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index def3bcdf..4abc3167 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -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); diff --git a/src/Http/Middleware/ThrottleRequests.php b/src/Http/Middleware/ThrottleRequests.php index 3f32b83c..bb00885a 100644 --- a/src/Http/Middleware/ThrottleRequests.php +++ b/src/Http/Middleware/ThrottleRequests.php @@ -59,6 +59,39 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); } + /** + * 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 sha1($scope . '|ip|' . $request->ip()); + } + /** * Extract API key from the request. * diff --git a/tests/Unit/Exceptions/ExceptionHandlerTest.php b/tests/Unit/Exceptions/ExceptionHandlerTest.php index 68dfa51d..08c71d30 100644 --- a/tests/Unit/Exceptions/ExceptionHandlerTest.php +++ b/tests/Unit/Exceptions/ExceptionHandlerTest.php @@ -113,6 +113,22 @@ function exception_handler_subject(): Handler 'http not found' => [new NotFoundHttpException(), ['There is nothing to see here.'], 404], ]); + it('keeps the retry-after and rate limit headers on a throttled response', function () { + $handler = exception_handler_subject(); + $exception = new ThrottleRequestsException('Too Many Attempts.', null, [ + 'Retry-After' => 42, + 'X-RateLimit-Limit' => 120, + 'X-RateLimit-Remaining' => 0, + ]); + + $response = $handler->render(Request::create('/v1/orders', 'POST'), $exception); + + expect($response->getStatusCode())->toBe(429) + ->and($response->headers->get('Retry-After'))->toBe('42') + ->and($response->headers->get('X-RateLimit-Limit'))->toBe('120') + ->and($response->headers->get('X-RateLimit-Remaining'))->toBe('0'); + }); + it('returns a resource-specific model not found json response when the model is known', function () { $handler = exception_handler_subject(); $exception = (new ModelNotFoundException())->setModel(User::class); diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 96027c7b..952a0f49 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -1023,6 +1023,65 @@ public function info(string $message, array $context = []): void ->and($isUnlimitedApiKey->invoke($middleware, 'Bearer other-key'))->toBeFalse(); }); + test('throttle requests keys the limiter on the presented credential rather than the proxy ip', function () { + middleware_contracts_fixture([ + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 2, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.unlimited_keys' => [], + ]); + + $middleware = middleware_contracts_throttle(); + // Every request arrives from the same load balancer address, as in production. + $send = function (string $uri, ?string $credential = null) use ($middleware) { + $server = ['REMOTE_ADDR' => '10.0.0.5']; + if ($credential) { + $server['HTTP_AUTHORIZATION'] = 'Bearer ' . $credential; + } + $request = Request::create($uri, 'GET', [], [], [], $server); + $request->setRouteResolver(fn () => new Illuminate\Routing\Route(['GET'], ltrim($uri, '/'), fn () => null)); + + try { + return $middleware->handle($request, fn () => new JsonResponse(['ok' => true]))->getStatusCode(); + } catch (Illuminate\Http\Exceptions\ThrottleRequestsException $exception) { + return $exception->getStatusCode(); + } + }; + + $noisy = [$send('/v1/orders', 'flb_live_noisy'), $send('/v1/orders', 'flb_live_noisy'), $send('/v1/orders', 'flb_live_noisy')]; + + expect($noisy)->toBe([200, 200, 429]) + ->and($send('/v1/orders', 'flb_live_quiet'))->toBe(200) + ->and($send('/int/v1/auth/login'))->toBe(200) + ->and($send('/int/v1/lookup/countries'))->toBe(200) + ->and($send('/int/v1/settings/branding'))->toBe(429) + ->and($send('/v1/orders'))->toBe(200); + }); + + test('throttle requests falls back to the authenticated user before the ip when no credential is sent', function () { + middleware_contracts_fixture([ + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 1, + 'api.throttle.unlimited_keys' => [], + ]); + + $middleware = middleware_contracts_throttle(); + $signature = new ReflectionMethod($middleware, 'resolveRequestSignature'); + $signature->setAccessible(true); + $request = function (?string $userId, string $ip) { + $request = Request::create('/v1/orders', 'GET', [], [], [], ['REMOTE_ADDR' => $ip]); + $request->setUserResolver(fn () => $userId ? new Illuminate\Auth\GenericUser(['id' => $userId]) : null); + + return $request; + }; + + expect($signature->invoke($middleware, $request('user-1', '10.0.0.5'))) + ->toBe($signature->invoke($middleware, $request('user-1', '10.0.0.6'))) + ->not->toBe($signature->invoke($middleware, $request('user-2', '10.0.0.5'))) + ->and($signature->invoke($middleware, $request(null, '10.0.0.5'))) + ->not->toBe($signature->invoke($middleware, $request(null, '10.0.0.6'))); + }); + test('basic auth middleware rejects requests without bearer credentials before continuing', function () { middleware_contracts_fixture(); From 76b8e5535482d4f89cf65d0e31728bfdda332d5b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 29 Sep 2026 15:48:24 +0800 Subject: [PATCH 2/3] feat(throttle): admin-managed rate limits, org overrides and API consumer metrics - ApiRateLimits: effective limits from env defaults plus a system.rate-limits setting an admin can change at runtime; per-organization overrides (custom limit or unlimited); credential -> organization lookup cached per key. - ApiConsumerMetrics: per-consumer request and 429 counts in Redis (minute buckets for 2h, hour buckets for 8 days), recorded by the throttle middleware so throttled requests are visible too. - RateLimitController + int/v1/rate-limits routes (admin only): get/save/reset settings, top consumers by window, clear a consumer's limiter window. - ThrottleRequests applies overrides and records every request. --- config/api.php | 9 + .../Internal/v1/RateLimitController.php | 117 +++++ src/Http/Middleware/ThrottleRequests.php | 95 ++++- src/Support/ApiConsumerMetrics.php | 251 +++++++++++ src/Support/ApiRateLimits.php | 247 +++++++++++ src/routes.php | 7 + tests/Fixtures/Support/RedisMetricsFake.php | 132 ++++++ tests/Unit/Http/RateLimitControllerTest.php | 247 +++++++++++ .../Http/ThrottleRequestsConsumersTest.php | 213 +++++++++ tests/Unit/RoutesContractTest.php | 21 + tests/Unit/Support/ApiConsumerMetricsTest.php | 256 +++++++++++ tests/Unit/Support/ApiRateLimitsTest.php | 403 ++++++++++++++++++ 12 files changed, 1988 insertions(+), 10 deletions(-) create mode 100644 src/Http/Controllers/Internal/v1/RateLimitController.php create mode 100644 src/Support/ApiConsumerMetrics.php create mode 100644 src/Support/ApiRateLimits.php create mode 100644 tests/Fixtures/Support/RedisMetricsFake.php create mode 100644 tests/Unit/Http/RateLimitControllerTest.php create mode 100644 tests/Unit/Http/ThrottleRequestsConsumersTest.php create mode 100644 tests/Unit/Support/ApiConsumerMetricsTest.php create mode 100644 tests/Unit/Support/ApiRateLimitsTest.php diff --git a/config/api.php b/config/api.php index 6a1c0b5c..826cc4cc 100644 --- a/config/api.php +++ b/config/api.php @@ -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' => [ diff --git a/src/Http/Controllers/Internal/v1/RateLimitController.php b/src/Http/Controllers/Internal/v1/RateLimitController.php new file mode 100644 index 00000000..24e20a2e --- /dev/null +++ b/src/Http/Controllers/Internal/v1/RateLimitController.php @@ -0,0 +1,117 @@ +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', [])), + ]; + } +} diff --git a/src/Http/Middleware/ThrottleRequests.php b/src/Http/Middleware/ThrottleRequests.php index bb00885a..597e4fc5 100644 --- a/src/Http/Middleware/ThrottleRequests.php +++ b/src/Http/Middleware/ThrottleRequests.php @@ -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; @@ -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 @@ -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', [ @@ -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 @@ -49,14 +59,79 @@ 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 parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); + return ['type' => 'ip', 'label' => $request->ip(), 'company_uuid' => null]; } /** diff --git a/src/Support/ApiConsumerMetrics.php b/src/Support/ApiConsumerMetrics.php new file mode 100644 index 00000000..8bafeb12 --- /dev/null +++ b/src/Support/ApiConsumerMetrics.php @@ -0,0 +1,251 @@ +toIso8601String(); + + try { + static::connection()->pipeline(function ($pipe) use ($signature, $consumer, $throttled, $minute, $hour) { + $metrics = $throttled ? ['hits', 'throttled'] : ['hits']; + foreach ($metrics as $metric) { + $pipe->zincrby(static::key('m', $minute, $metric), 1, $signature); + $pipe->expire(static::key('m', $minute, $metric), static::MINUTE_RETENTION); + $pipe->zincrby(static::key('h', $hour, $metric), 1, $signature); + $pipe->expire(static::key('h', $hour, $metric), static::HOUR_RETENTION); + } + + $pipe->setex(static::PREFIX . ':consumer:' . $signature, static::HOUR_RETENTION, json_encode($consumer)); + }); + } catch (\Throwable $e) { + // Metrics are best-effort; never fail the request over them. + } + } + + /** + * The busiest consumers over a window, with platform totals and a request timeline. + * + * @param int $windowMinutes one of WINDOWS + * @param string $sort "hits" or "throttled" + */ + public static function top(int $windowMinutes = 15, string $sort = 'hits', int $limit = 50, ?Carbon $now = null): array + { + $now ??= Carbon::now(); + $sort = $sort === 'throttled' ? 'throttled' : 'hits'; + + try { + $redis = static::connection(); + [$granularity, $buckets] = static::buckets($windowMinutes, $now); + + $results = $redis->pipeline(function ($pipe) use ($buckets, $granularity) { + foreach ($buckets as $bucket) { + $pipe->zrangebyscore(static::key($granularity, $bucket, 'hits'), '-inf', '+inf', ['withscores' => true]); + $pipe->zrangebyscore(static::key($granularity, $bucket, 'throttled'), '-inf', '+inf', ['withscores' => true]); + } + }); + } catch (\Throwable $e) { + return static::unavailable($windowMinutes, $sort); + } + + $consumers = []; + $series = []; + foreach ($buckets as $index => $bucket) { + $hits = static::scores($results[$index * 2] ?? []); + $throttled = static::scores($results[$index * 2 + 1] ?? []); + + $series[] = [ + 'bucket' => static::bucketTime($granularity, $bucket)->toIso8601String(), + 'hits' => array_sum($hits), + 'throttled' => array_sum($throttled), + ]; + + foreach (['hits' => $hits, 'throttled' => $throttled] as $metric => $scores) { + foreach ($scores as $signature => $count) { + $consumers[$signature] ??= ['signature' => $signature, 'hits' => 0, 'throttled' => 0, 'peak_per_minute' => 0]; + $consumers[$signature][$metric] += $count; + if ($metric === 'hits' && $granularity === 'm') { + $consumers[$signature]['peak_per_minute'] = max($consumers[$signature]['peak_per_minute'], $count); + } + } + } + } + + $totalHits = array_sum(array_column($consumers, 'hits')); + $totalThrottled = array_sum(array_column($consumers, 'throttled')); + + uasort($consumers, fn ($a, $b) => [$b[$sort], $b['hits']] <=> [$a[$sort], $a['hits']]); + $top = array_slice(array_values($consumers), 0, max(1, $limit)); + + $details = static::details(array_column($top, 'signature')); + $top = array_map(function (array $row) use ($details, $totalHits, $windowMinutes) { + $row = array_merge($details[$row['signature']] ?? [], $row); + + $row['share'] = $totalHits > 0 ? round($row['hits'] / $totalHits * 100, 2) : 0; + $row['avg_per_minute'] = round($row['hits'] / max(1, $windowMinutes), 2); + $row['peak_per_minute'] = $row['peak_per_minute'] ?: null; + + return $row; + }, $top); + + return [ + 'available' => true, + 'window' => $windowMinutes, + 'granularity' => $granularity === 'm' ? 'minute' : 'hour', + 'sort' => $sort, + 'total_requests' => $totalHits, + 'total_throttled'=> $totalThrottled, + 'consumer_count' => count($consumers), + 'consumers' => $top, + 'series' => $series, + ]; + } + + /** + * Stored descriptions for a set of consumer signatures. + */ + protected static function details(array $signatures): array + { + if (empty($signatures)) { + return []; + } + + try { + $values = static::connection()->mget(array_map(fn ($signature) => static::PREFIX . ':consumer:' . $signature, $signatures)); + } catch (\Throwable $e) { + return []; + } + + $details = []; + foreach (array_values($signatures) as $index => $signature) { + $decoded = is_string($values[$index] ?? null) ? json_decode($values[$index], true) : null; + $details[$signature] = is_array($decoded) ? $decoded : []; + } + + return $details; + } + + /** + * Minute buckets for windows up to two hours, hour buckets beyond, oldest first. + */ + protected static function buckets(int $windowMinutes, Carbon $now): array + { + $windowMinutes = max(1, $windowMinutes); + + if ($windowMinutes <= 120) { + $buckets = []; + for ($i = $windowMinutes - 1; $i >= 0; $i--) { + $buckets[] = static::minuteBucket($now->copy()->subMinutes($i)); + } + + return ['m', $buckets]; + } + + $hours = (int) ceil($windowMinutes / 60); + $buckets = []; + for ($i = $hours - 1; $i >= 0; $i--) { + $buckets[] = static::hourBucket($now->copy()->subHours($i)); + } + + return ['h', $buckets]; + } + + /** + * Normalize a zrangebyscore WITHSCORES reply to [member => int]. + */ + protected static function scores($reply): array + { + if (!is_array($reply)) { + return []; + } + + // Some clients reply with a flat [member, score, member, score] list. + if (!empty($reply) && array_keys($reply) === range(0, count($reply) - 1) && count($reply) % 2 === 0 && !is_numeric($reply[0])) { + $pairs = []; + for ($i = 0; $i < count($reply); $i += 2) { + $pairs[$reply[$i]] = $reply[$i + 1]; + } + $reply = $pairs; + } + + return array_map(fn ($score) => (int) $score, $reply); + } + + protected static function unavailable(int $windowMinutes, string $sort): array + { + return [ + 'available' => false, + 'window' => $windowMinutes, + 'granularity' => $windowMinutes <= 120 ? 'minute' : 'hour', + 'sort' => $sort, + 'total_requests' => 0, + 'total_throttled' => 0, + 'consumer_count' => 0, + 'consumers' => [], + 'series' => [], + ]; + } + + protected static function key(string $granularity, string $bucket, string $metric): string + { + return static::PREFIX . ':' . $granularity . ':' . $bucket . ':' . $metric; + } + + protected static function minuteBucket(Carbon $at): string + { + return $at->copy()->utc()->format('YmdHi'); + } + + protected static function hourBucket(Carbon $at): string + { + return $at->copy()->utc()->format('YmdH'); + } + + protected static function bucketTime(string $granularity, string $bucket): Carbon + { + return Carbon::createFromFormat($granularity === 'm' ? '!YmdHi' : '!YmdH', $bucket, 'UTC'); + } + + protected static function connection() + { + return Redis::connection(config('api.throttle.metrics_connection', 'cache')); + } +} diff --git a/src/Support/ApiRateLimits.php b/src/Support/ApiRateLimits.php new file mode 100644 index 00000000..4ded2f56 --- /dev/null +++ b/src/Support/ApiRateLimits.php @@ -0,0 +1,247 @@ + config('api.throttle.enabled', true) !== false, + 'max_attempts' => (int) config('api.throttle.max_attempts', 120), + 'decay_minutes' => (int) config('api.throttle.decay_minutes', 1), + 'track_consumers' => config('api.throttle.track_consumers', true) !== false, + 'overrides' => [], + ]; + } + + /** + * The effective settings: the environment defaults with the stored override applied. + * + * Read on every throttled request, so it is cached; saving through store() clears it. + * A missing database or cache (unit tests, a broken install) falls back to the defaults + * rather than failing the request. + */ + public static function settings(): array + { + try { + $stored = Cache::remember(static::SETTINGS_CACHE_KEY, 60, function () { + return Setting::where('key', 'system.' . static::SETTING_KEY)->value('value') ?? []; + }); + } catch (\Throwable $e) { + $stored = []; + } + + return static::normalize(array_merge(static::defaults(), is_array($stored) ? $stored : [])); + } + + /** + * Persist administrator settings and make them effective immediately. + */ + public static function store(array $settings): array + { + $settings = static::normalize(array_merge(static::defaults(), $settings)); + + Setting::configureSystem(static::SETTING_KEY, $settings); + Cache::forget(static::SETTINGS_CACHE_KEY); + + return $settings; + } + + /** + * Drop the stored override so the environment defaults apply again. + */ + public static function reset(): array + { + Setting::where('key', 'system.' . static::SETTING_KEY)->delete(); + Cache::forget(static::SETTINGS_CACHE_KEY); + + return static::settings(); + } + + /** + * Coerce settings into their canonical shape. + */ + public static function normalize(array $settings): array + { + $overrides = []; + foreach ((array) ($settings['overrides'] ?? []) as $override) { + $companyUuid = data_get($override, 'company_uuid'); + if (!is_string($companyUuid) || $companyUuid === '') { + continue; + } + + $maxAttempts = data_get($override, 'max_attempts'); + $overrides[$companyUuid] = [ + 'company_uuid' => $companyUuid, + 'unlimited' => (bool) data_get($override, 'unlimited', false), + 'max_attempts' => is_numeric($maxAttempts) ? max(1, (int) $maxAttempts) : null, + 'note' => (string) data_get($override, 'note', ''), + ]; + } + + return [ + 'enabled' => (bool) ($settings['enabled'] ?? true), + 'max_attempts' => max(1, (int) ($settings['max_attempts'] ?? 120)), + 'decay_minutes' => max(1, (int) ($settings['decay_minutes'] ?? 1)), + 'track_consumers' => (bool) ($settings['track_consumers'] ?? true), + 'overrides' => array_values($overrides), + ]; + } + + /** + * The limit that applies to a consumer, or null when it is unlimited. + */ + public static function limitFor(?string $companyUuid, ?array $settings = null): ?int + { + $settings ??= static::settings(); + + if ($companyUuid) { + foreach ($settings['overrides'] as $override) { + if ($override['company_uuid'] !== $companyUuid) { + continue; + } + + if ($override['unlimited']) { + return null; + } + + return $override['max_attempts'] ?? $settings['max_attempts']; + } + } + + return $settings['max_attempts']; + } + + /** + * Identify who a credential belongs to, cached so the lookup is at most one query per + * credential per cache period. Unknown credentials are cached too, so a caller sending + * a bad key does not reach the database more often than one sending a good one. + * + * @param string $credential the raw credential as extracted by the throttle middleware + */ + public static function identify(string $credential): array + { + try { + return Cache::remember(static::CONSUMER_CACHE_PREFIX . sha1($credential), static::CONSUMER_CACHE_TTL, function () use ($credential) { + return static::lookupConsumer($credential); + }); + } catch (\Throwable $e) { + return static::unknownConsumer($credential); + } + } + + /** + * Resolve a credential to its owning organization without authenticating it. + */ + protected static function lookupConsumer(string $credential): array + { + $token = trim(Str::startsWith($credential, 'Bearer ') ? Str::after($credential, 'Bearer ') : $credential); + + // Sanctum personal access tokens are "|". + if (Str::contains($token, '|')) { + $accessToken = PersonalAccessToken::findToken($token); + $user = $accessToken?->tokenable; + if ($user) { + return static::describe('token', $user->name ?? $user->email ?? 'Access token', $user->company_uuid ?? null, null, $accessToken->name ?? null); + } + + return static::unknownConsumer($credential); + } + + // Secret keys ("$...") carry no mode prefix, so a live miss is retried on sandbox, + // the same as AuthenticateOnceWithBasicAuth does. + $connections = Str::startsWith($token, 'flb_test_') ? ['sandbox'] : (Str::startsWith($token, '$') ? ['mysql', 'sandbox'] : ['mysql']); + $apiKey = null; + foreach ($connections as $connection) { + $apiKey = ApiCredential::on($connection) + ->withoutGlobalScopes() + ->where(function ($query) use ($token) { + $query->where('key', $token)->orWhere('secret', $token); + }) + ->first(['uuid', 'name', 'key', 'company_uuid', 'test_mode']); + + if ($apiKey) { + break; + } + } + + if ($apiKey) { + return static::describe('api_key', $apiKey->name ?: 'API key', $apiKey->company_uuid, $apiKey->uuid, static::mask($apiKey->key), (bool) $apiKey->test_mode); + } + + return static::unknownConsumer($credential); + } + + protected static function describe(string $type, string $label, ?string $companyUuid, ?string $credentialUuid = null, ?string $detail = null, bool $testMode = false): array + { + $company = $companyUuid ? Company::where('uuid', $companyUuid)->first(['uuid', 'public_id', 'name']) : null; + + return [ + 'type' => $type, + 'label' => $label, + 'detail' => $detail, + 'test_mode' => $testMode, + 'credential_uuid' => $credentialUuid, + 'company_uuid' => $company?->uuid ?? $companyUuid, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + ]; + } + + protected static function unknownConsumer(string $credential): array + { + $token = trim(Str::after($credential, ' ')); + + return [ + 'type' => 'unknown', + 'label' => 'Unrecognized credential', + 'detail' => static::mask($token ?: $credential), + 'test_mode' => false, + 'credential_uuid' => null, + 'company_uuid' => null, + 'company_id' => null, + 'company_name' => null, + ]; + } + + /** + * Enough of a key to recognize it, never enough to use it. + */ + public static function mask(?string $value): ?string + { + if (!$value) { + return null; + } + + return mb_substr($value, 0, min(12, (int) floor(mb_strlen($value) / 2))) . '…'; + } +} diff --git a/src/routes.php b/src/routes.php index e60690fc..fe76c767 100644 --- a/src/routes.php +++ b/src/routes.php @@ -254,6 +254,13 @@ function ($router, $controller) { $router->post('test-notification-channels-config', $controller('testNotificationChannelsConfig')); } ); + $router->group(['prefix' => 'rate-limits'], function ($router) { + $router->get('settings', 'RateLimitController@getSettings'); + $router->post('settings', 'RateLimitController@saveSettings'); + $router->delete('settings', 'RateLimitController@resetSettings'); + $router->get('consumers', 'RateLimitController@consumers'); + $router->post('consumers/{signature}/reset', 'RateLimitController@resetConsumer'); + }); $router->fleetbaseRoutes('schedule-monitor', null, [], function ($router, $controller) { $router->get('tasks', $controller('tasks')); $router->get('{id}/logs', $controller('logs')); diff --git a/tests/Fixtures/Support/RedisMetricsFake.php b/tests/Fixtures/Support/RedisMetricsFake.php new file mode 100644 index 00000000..e0f895d6 --- /dev/null +++ b/tests/Fixtures/Support/RedisMetricsFake.php @@ -0,0 +1,132 @@ +<?php + +namespace Fleetbase\Tests\Fixtures\Support; + +/** + * An in-memory stand-in for the Redis manager and connection used by ApiConsumerMetrics. + * + * Bound as the container's `redis` so that Redis::connection($name) returns this object. + * Only the sorted-set, string and pipeline commands the metrics use are implemented, and + * every command is recorded in $commands for assertions. + */ +class RedisMetricsFake +{ + public array $commands = []; + public array $connections = []; + public array $sortedSets = []; + public array $strings = []; + public array $ttls = []; + + /** + * Replace the pipeline reply entirely (e.g. to simulate a client's flat WITHSCORES replies). + */ + public ?array $pipelineReply = null; + + /** + * Reply to zrangebyscore with a flat [member, score, ...] list instead of [member => score]. + */ + public bool $flatScores = false; + + public bool $pipelineThrows = false; + + public bool $mgetThrows = false; + + public function connection(?string $name = null): self + { + $this->connections[] = $name; + + return $this; + } + + public function pipeline(callable $callback): array + { + if ($this->pipelineThrows) { + throw new \RuntimeException('redis unavailable'); + } + + $pipe = new class { + public array $queued = []; + + public function __call(string $method, array $arguments): self + { + $this->queued[] = [$method, $arguments]; + + return $this; + } + }; + + $callback($pipe); + + $replies = []; + foreach ($pipe->queued as [$method, $arguments]) { + $replies[] = $this->{$method}(...$arguments); + } + + return $this->pipelineReply ?? $replies; + } + + public function zincrby(string $key, int|float $increment, string $member): float + { + $this->commands[] = ['zincrby', $key, $increment, $member]; + + $this->sortedSets[$key][$member] = ($this->sortedSets[$key][$member] ?? 0) + $increment; + + return (float) $this->sortedSets[$key][$member]; + } + + public function expire(string $key, int $seconds): bool + { + $this->commands[] = ['expire', $key, $seconds]; + $this->ttls[$key] = $seconds; + + return true; + } + + public function setex(string $key, int $seconds, string $value): bool + { + $this->commands[] = ['setex', $key, $seconds, $value]; + $this->strings[$key] = $value; + $this->ttls[$key] = $seconds; + + return true; + } + + public function zrangebyscore(string $key, string $min, string $max, array $options = []): array + { + $this->commands[] = ['zrangebyscore', $key, $min, $max, $options]; + + $set = $this->sortedSets[$key] ?? []; + asort($set); + + // Redis replies with scores as strings. + $set = array_map(fn ($score) => (string) $score, $set); + + if (!$this->flatScores) { + return $set; + } + + $flat = []; + foreach ($set as $member => $score) { + $flat[] = (string) $member; + $flat[] = $score; + } + + return $flat; + } + + public function mget(array $keys): array + { + $this->commands[] = ['mget', $keys]; + + if ($this->mgetThrows) { + throw new \RuntimeException('redis unavailable'); + } + + return array_map(fn ($key) => $this->strings[$key] ?? null, $keys); + } + + public function commandNames(): array + { + return array_column($this->commands, 0); + } +} diff --git a/tests/Unit/Http/RateLimitControllerTest.php b/tests/Unit/Http/RateLimitControllerTest.php new file mode 100644 index 00000000..841cf96a --- /dev/null +++ b/tests/Unit/Http/RateLimitControllerTest.php @@ -0,0 +1,247 @@ +<?php + +use Fleetbase\Http\Controllers\Internal\v1\RateLimitController; +use Fleetbase\Http\Requests\AdminRequest; +use Fleetbase\Support\ApiConsumerMetrics; +use Fleetbase\Support\ApiRateLimits; +use Fleetbase\Tests\Fixtures\Support\RedisMetricsFake; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Events\Dispatcher; +use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Facade; +use Illuminate\Translation\ArrayLoader; +use Illuminate\Translation\Translator; +use Illuminate\Validation\DatabasePresenceVerifier; +use Illuminate\Validation\Factory as ValidationFactory; +use Illuminate\Validation\ValidationException; + +function rate_limit_controller_fixture(array $config = []): RedisMetricsFake +{ + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'api.cache.enabled' => false, + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 120, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.track_consumers' => true, + 'api.throttle.unlimited_keys' => ['Bearer load-test-1', 'Bearer load-test-2'], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + + $cache = new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance(RateLimiter::class, new RateLimiter($cache)); + $redis = new RedisMetricsFake(); + $container->instance('redis', $redis); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ['uuid' => 'company-globex', 'public_id' => 'company_globex', 'name' => 'Globex'], + ]); + + // Mirrors the `validate` request macro the framework's FoundationServiceProvider registers. + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + $validation->setPresenceVerifier(new DatabasePresenceVerifier($capsule->getDatabaseManager())); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return $redis; +} + +function rate_limit_controller_request(string $method = 'GET', array $input = [], string $uri = '/int/v1/rate-limits/settings'): AdminRequest +{ + return AdminRequest::create($uri, $method, $input); +} + +afterEach(function () { + Carbon::setTestNow(); + + $macros = new ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['track_consumers' => true, 'unlimited_keys' => true])]); + app()->forgetInstance('redis'); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('rate limit controller returns the effective settings with the environment defaults beneath them', function () { + rate_limit_controller_fixture(); + ApiRateLimits::store(['max_attempts' => 60, 'overrides' => [ + ['company_uuid' => 'company-acme', 'unlimited' => true, 'note' => 'Partner'], + ['company_uuid' => 'company-deleted', 'max_attempts' => 5], + ]]); + + $response = (new RateLimitController())->getSettings(rate_limit_controller_request()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toBe([ + 'settings' => [ + 'enabled' => true, + 'max_attempts' => 60, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [ + ['company_uuid' => 'company-acme', 'unlimited' => true, 'max_attempts' => null, 'note' => 'Partner', 'company_id' => 'company_acme', 'company_name' => 'Acme'], + ['company_uuid' => 'company-deleted', 'unlimited' => false, 'max_attempts' => 5, 'note' => '', 'company_id' => null, 'company_name' => null], + ], + ], + 'defaults' => [ + 'enabled' => true, + 'max_attempts' => 120, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [], + ], + 'unlimited_keys' => 2, + ]); +}); + +test('rate limit controller saves validated settings and applies them from the next request', function () { + rate_limit_controller_fixture(); + + $response = (new RateLimitController())->saveSettings(rate_limit_controller_request('POST', [ + 'enabled' => true, + 'max_attempts' => 250, + 'decay_minutes' => 2, + 'track_consumers' => false, + 'overrides' => [ + ['company_uuid' => 'company-globex', 'max_attempts' => 1000, 'note' => 'Bulk importer'], + ], + ])); + + expect($response->getData(true)['settings'])->toBe([ + 'enabled' => true, + 'max_attempts' => 250, + 'decay_minutes' => 2, + 'track_consumers' => false, + 'overrides' => [ + ['company_uuid' => 'company-globex', 'unlimited' => false, 'max_attempts' => 1000, 'note' => 'Bulk importer', 'company_id' => 'company_globex', 'company_name' => 'Globex'], + ], + ]) + ->and(ApiRateLimits::settings()['max_attempts'])->toBe(250) + ->and(ApiRateLimits::limitFor('company-globex'))->toBe(1000); +}); + +test('rate limit controller rejects invalid settings and overrides for unknown organizations', function (array $input) { + rate_limit_controller_fixture(); + + expect(fn () => (new RateLimitController())->saveSettings(rate_limit_controller_request('POST', $input))) + ->toThrow(ValidationException::class); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); +})->with([ + 'missing limit' => [['enabled' => true, 'decay_minutes' => 1]], + 'zero limit' => [['enabled' => true, 'max_attempts' => 0, 'decay_minutes' => 1]], + 'decay over a day' => [['enabled' => true, 'max_attempts' => 10, 'decay_minutes' => 1441]], + 'unknown organization' => [['enabled' => true, 'max_attempts' => 10, 'decay_minutes' => 1, 'overrides' => [['company_uuid' => 'company-missing']]]], + 'duplicate override' => [['enabled' => true, 'max_attempts' => 10, 'decay_minutes' => 1, 'overrides' => [['company_uuid' => 'company-acme'], ['company_uuid' => 'company-acme']]]], +]); + +test('rate limit controller reset discards the administrator settings', function () { + rate_limit_controller_fixture(); + ApiRateLimits::store(['max_attempts' => 5, 'overrides' => [['company_uuid' => 'company-acme', 'unlimited' => true]]]); + + $response = (new RateLimitController())->resetSettings(rate_limit_controller_request('DELETE')); + + expect($response->getData(true)['settings'])->toBe([ + 'enabled' => true, + 'max_attempts' => 120, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [], + ]) + ->and(ApiRateLimits::settings()['max_attempts'])->toBe(120); +}); + +test('rate limit controller lists the busiest consumers with the limits that apply', function () { + Carbon::setTestNow(Carbon::parse('2026-07-18 12:30:00', 'UTC')); + rate_limit_controller_fixture(); + ApiRateLimits::store(['max_attempts' => 90, 'decay_minutes' => 3, 'track_consumers' => false]); + + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy']); + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy'], true); + ApiConsumerMetrics::record('sig-quiet', ['label' => 'Quiet']); + + $defaults = (new RateLimitController())->consumers(rate_limit_controller_request('GET', [], '/int/v1/rate-limits/consumers'))->getData(true); + $sorted = (new RateLimitController())->consumers(rate_limit_controller_request('GET', ['window' => 60, 'sort' => 'throttled', 'limit' => 1], '/int/v1/rate-limits/consumers'))->getData(true); + + expect($defaults)->toMatchArray([ + 'available' => true, + 'window' => 15, + 'sort' => 'hits', + 'total_requests' => 3, + 'consumer_count' => 2, + 'tracking' => false, + 'default_limit' => 90, + 'decay_minutes' => 3, + ]) + ->and(array_column($defaults['consumers'], 'label'))->toBe(['Busy', 'Quiet']) + ->and($sorted)->toMatchArray(['window' => 60, 'sort' => 'throttled']) + ->and(array_column($sorted['consumers'], 'signature'))->toBe(['sig-busy']); +}); + +test('rate limit controller rejects consumer windows the view does not offer', function () { + rate_limit_controller_fixture(); + + expect(fn () => (new RateLimitController())->consumers(rate_limit_controller_request('GET', ['window' => 7], '/int/v1/rate-limits/consumers'))) + ->toThrow(ValidationException::class); +}); + +test('rate limit controller resets a consumer limiter and refuses malformed signatures', function () { + rate_limit_controller_fixture(); + $signature = sha1('fleetbase-throttle|v1|credential|Bearer flb_live_noisy'); + $limiter = app(RateLimiter::class); + $limiter->hit($signature, 60); + $limiter->hit($signature, 60); + + $invalid = (new RateLimitController())->resetConsumer(rate_limit_controller_request('POST'), 'not-a-signature'); + + expect($invalid->getStatusCode())->toBe(422) + ->and($invalid->getData(true))->toBe(['errors' => ['Invalid consumer.']]) + ->and($limiter->attempts($signature))->toBe(2); + + $reset = (new RateLimitController())->resetConsumer(rate_limit_controller_request('POST'), $signature); + + expect($reset->getData(true))->toBe(['status' => 'OK']) + ->and($limiter->attempts($signature))->toBe(0); +}); diff --git a/tests/Unit/Http/ThrottleRequestsConsumersTest.php b/tests/Unit/Http/ThrottleRequestsConsumersTest.php new file mode 100644 index 00000000..490f4c67 --- /dev/null +++ b/tests/Unit/Http/ThrottleRequestsConsumersTest.php @@ -0,0 +1,213 @@ +<?php + +use Fleetbase\Http\Middleware\ThrottleRequests; +use Fleetbase\Support\ApiRateLimits; +use Fleetbase\Tests\Fixtures\Support\RedisMetricsFake; +use Illuminate\Auth\GenericUser; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Http\Exceptions\ThrottleRequestsException; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Facade; + +/** + * Binds a real array cache (seeded with the given effective settings) and an in-memory + * Redis, so the throttle reads administrator settings and records consumer metrics. + */ +function throttle_consumers_fixture(array $settings = [], array $config = []): RedisMetricsFake +{ + $container = bind_test_container(array_merge([ + 'app.env' => 'testing', + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 120, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.track_consumers' => true, + 'api.throttle.unlimited_keys' => [], + ], $config)); + + $container->instance('cache', new CacheRepository(new ArrayStore())); + $redis = new RedisMetricsFake(); + $container->instance('redis', $redis); + Facade::clearResolvedInstances(); + + if ($settings !== []) { + cache()->put(ApiRateLimits::SETTINGS_CACHE_KEY, $settings, 60); + } + + return $redis; +} + +function throttle_consumers_identify(string $credential, array $consumer): void +{ + cache()->put(ApiRateLimits::CONSUMER_CACHE_PREFIX . sha1($credential), $consumer, 600); +} + +function throttle_consumers_request(string $uri = '/v1/orders', array $server = [], ?object $user = null): Request +{ + $request = Request::create($uri, 'GET', [], [], [], array_merge(['REMOTE_ADDR' => '10.0.0.5'], $server)); + $request->setRouteResolver(fn () => new Illuminate\Routing\Route(['GET'], ltrim($uri, '/'), fn () => null)); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function throttle_consumers_send(ThrottleRequests $middleware, Request $request): int +{ + try { + return $middleware->handle($request, fn () => new JsonResponse(['ok' => true]))->getStatusCode(); + } catch (ThrottleRequestsException $exception) { + return $exception->getStatusCode(); + } +} + +function throttle_consumers_middleware(): ThrottleRequests +{ + return new ThrottleRequests(new RateLimiter(new CacheRepository(new ArrayStore()))); +} + +/** + * The consumer descriptions the middleware stored, keyed by limiter signature. + */ +function throttle_consumers_recorded(RedisMetricsFake $redis): array +{ + $recorded = []; + foreach ($redis->strings as $key => $value) { + $recorded[substr($key, strlen('api_consumer_metrics:consumer:'))] = json_decode($value, true); + } + + return $recorded; +} + +function throttle_consumers_count(RedisMetricsFake $redis, string $metric): array +{ + return $redis->sortedSets['api_consumer_metrics:m:202607181230:' . $metric] ?? []; +} + +beforeEach(function () { + Carbon::setTestNow(Carbon::parse('2026-07-18 12:30:00', 'UTC')); +}); + +afterEach(function () { + Carbon::setTestNow(); + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['track_consumers' => true])]); + app()->forgetInstance('redis'); + Facade::clearResolvedInstances(); +}); + +test('throttle requests counts every request and every rejection per consumer', function () { + $redis = throttle_consumers_fixture(['max_attempts' => 2]); + $middleware = throttle_consumers_middleware(); + throttle_consumers_identify('Bearer flb_live_noisy', ['type' => 'api_key', 'label' => 'Noisy', 'company_uuid' => 'company-acme']); + + $statuses = []; + foreach (range(1, 3) as $i) { + $statuses[] = throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_noisy'])); + } + + $signature = sha1('fleetbase-throttle|v1|credential|Bearer flb_live_noisy'); + + expect($statuses)->toBe([200, 200, 429]) + ->and(throttle_consumers_count($redis, 'hits'))->toBe([$signature => 3]) + ->and(throttle_consumers_count($redis, 'throttled'))->toBe([$signature => 1]) + ->and(throttle_consumers_recorded($redis)[$signature])->toBe([ + 'type' => 'api_key', + 'label' => 'Noisy', + 'company_uuid' => 'company-acme', + 'scope' => 'v1', + 'ip' => '10.0.0.5', + 'limit' => 2, + 'last_seen_at' => '2026-07-18T12:30:00+00:00', + ]); +}); + +test('throttle requests applies organization overrides to the consumer behind a credential', function () { + $redis = throttle_consumers_fixture(ApiRateLimits::normalize([ + 'max_attempts' => 1, + 'overrides' => [ + ['company_uuid' => 'company-partner', 'max_attempts' => 3], + ['company_uuid' => 'company-internal', 'unlimited' => true], + ], + ])); + $middleware = throttle_consumers_middleware(); + throttle_consumers_identify('Bearer flb_live_partner', ['type' => 'api_key', 'label' => 'Partner', 'company_uuid' => 'company-partner']); + throttle_consumers_identify('Bearer flb_live_internal', ['type' => 'api_key', 'label' => 'Internal', 'company_uuid' => 'company-internal']); + + $send = fn (string $credential) => throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => $credential])); + + $partner = array_map(fn () => $send('Bearer flb_live_partner'), range(1, 4)); + $internal = array_map(fn () => $send('Bearer flb_live_internal'), range(1, 5)); + $other = array_map(fn () => $send('Bearer flb_live_other'), range(1, 2)); + + $recorded = throttle_consumers_recorded($redis); + + expect($partner)->toBe([200, 200, 200, 429]) + ->and($internal)->toBe([200, 200, 200, 200, 200]) + ->and($other)->toBe([200, 429]) + ->and($recorded[sha1('fleetbase-throttle|v1|credential|Bearer flb_live_partner')]['limit'])->toBe(3) + ->and($recorded[sha1('fleetbase-throttle|v1|credential|Bearer flb_live_internal')])->toMatchArray(['label' => 'Internal', 'limit' => null]) + // No cached identity and no database: the credential is reported as unrecognized. + ->and($recorded[sha1('fleetbase-throttle|v1|credential|Bearer flb_live_other')])->toMatchArray(['type' => 'unknown', 'limit' => 1]); +}); + +test('throttle requests still counts consumers when throttling is disabled or the key is unlimited', function () { + $redis = throttle_consumers_fixture(['enabled' => false]); + $middleware = throttle_consumers_middleware(); + + $disabled = throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', [], new GenericUser(['id' => 'user-1', 'name' => 'Ada', 'company_uuid' => 'company-acme']))); + + expect($disabled)->toBe(200) + ->and(throttle_consumers_recorded($redis)[sha1('fleetbase-throttle|v1|user|user-1')])->toMatchArray([ + 'type' => 'user', + 'label' => 'Ada', + 'company_uuid' => 'company-acme', + 'limit' => null, + ]); + + $redis = throttle_consumers_fixture([], ['api.throttle.unlimited_keys' => ['Bearer load-test']]); + + $unlimited = throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer load-test'])); + + expect($unlimited)->toBe(200) + ->and(throttle_consumers_recorded($redis)[sha1('fleetbase-throttle|v1|credential|Bearer load-test')])->toMatchArray([ + 'type' => 'unknown', + 'limit' => null, + ]); +}); + +test('throttle requests describes users without a name and anonymous callers by ip', function () { + $redis = throttle_consumers_fixture(); + $middleware = throttle_consumers_middleware(); + + throttle_consumers_send($middleware, throttle_consumers_request('/int/v1/orders', [], new GenericUser(['id' => 'user-2', 'email' => 'grace@example.test']))); + throttle_consumers_send($middleware, throttle_consumers_request('/int/v1/orders', [], new GenericUser(['id' => 'user-3']))); + throttle_consumers_send($middleware, throttle_consumers_request('/', ['REMOTE_ADDR' => '203.0.113.9'])); + + $recorded = throttle_consumers_recorded($redis); + + expect($recorded[sha1('fleetbase-throttle|int|user|user-2')])->toMatchArray(['type' => 'user', 'label' => 'grace@example.test', 'company_uuid' => null, 'scope' => 'int', 'limit' => 120]) + ->and($recorded[sha1('fleetbase-throttle|int|user|user-3')])->toMatchArray(['type' => 'user', 'label' => 'user-3']) + ->and($recorded[sha1('fleetbase-throttle||ip|203.0.113.9')])->toMatchArray([ + 'type' => 'ip', + 'label' => '203.0.113.9', + 'company_uuid' => null, + 'scope' => '', + 'ip' => '203.0.113.9', + ]); +}); + +test('throttle requests skips consumer lookups and metrics when tracking is off and there are no overrides', function () { + $redis = throttle_consumers_fixture(['track_consumers' => false, 'max_attempts' => 1]); + $middleware = throttle_consumers_middleware(); + throttle_consumers_identify('Bearer flb_live_key', ['type' => 'api_key', 'label' => 'Key', 'company_uuid' => 'company-acme']); + + $statuses = [ + throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_key'])), + throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_key'])), + ]; + + expect($statuses)->toBe([200, 429]) + ->and($redis->commands)->toBe([]); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index d2da21f9..f7e8fe2e 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -364,4 +364,25 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and(routes_contract_find($routes, 'GET', 'int/v1/notifications/registry')['action']) ->toBe('Fleetbase\Http\Controllers\Internal\v1\NotificationController@registry'); }); + + test('route file exposes api rate limit administration as protected routes', function () { + $routes = routes_contract_rows(routes_contract_router()); + $controller = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\RateLimitController'; + + $expected = [ + ['GET', 'int/v1/rate-limits/settings', 'getSettings'], + ['POST', 'int/v1/rate-limits/settings', 'saveSettings'], + ['DELETE', 'int/v1/rate-limits/settings', 'resetSettings'], + ['GET', 'int/v1/rate-limits/consumers', 'consumers'], + ['POST', 'int/v1/rate-limits/consumers/{signature}/reset', 'resetConsumer'], + ]; + + foreach ($expected as [$method, $uri, $action]) { + $route = routes_contract_find($routes, $method, $uri); + + expect($route)->not->toBeNull() + ->and($route['action'])->toBe($controller . '@' . $action) + ->and($route['middleware'])->toContain('fleetbase.protected'); + } + }); } diff --git a/tests/Unit/Support/ApiConsumerMetricsTest.php b/tests/Unit/Support/ApiConsumerMetricsTest.php new file mode 100644 index 00000000..6bbedde7 --- /dev/null +++ b/tests/Unit/Support/ApiConsumerMetricsTest.php @@ -0,0 +1,256 @@ +<?php + +use Fleetbase\Support\ApiConsumerMetrics; +use Fleetbase\Tests\Fixtures\Support\RedisMetricsFake; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Facade; + +function api_consumer_metrics_redis(array $config = []): RedisMetricsFake +{ + $container = bind_test_container($config); + $redis = new RedisMetricsFake(); + $container->instance('redis', $redis); + Facade::clearResolvedInstance('redis'); + + return $redis; +} + +beforeEach(function () { + Carbon::setTestNow(Carbon::parse('2026-07-18 12:30:45', 'UTC')); +}); + +afterEach(function () { + Carbon::setTestNow(); + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['metrics_connection' => true])]); + app()->forgetInstance('redis'); + Facade::clearResolvedInstance('redis'); +}); + +test('api consumer metrics record counts a request into minute and hour buckets and stores the consumer', function () { + $redis = api_consumer_metrics_redis(['api.throttle.metrics_connection' => 'metrics']); + + ApiConsumerMetrics::record('sig-a', ['type' => 'api_key', 'label' => 'Live key']); + + $consumer = json_decode($redis->strings['api_consumer_metrics:consumer:sig-a'], true); + + expect($redis->connections)->toBe(['metrics']) + ->and($redis->sortedSets)->toBe([ + 'api_consumer_metrics:m:202607181230:hits' => ['sig-a' => 1], + 'api_consumer_metrics:h:2026071812:hits' => ['sig-a' => 1], + ]) + ->and($redis->ttls)->toBe([ + 'api_consumer_metrics:m:202607181230:hits' => ApiConsumerMetrics::MINUTE_RETENTION, + 'api_consumer_metrics:h:2026071812:hits' => ApiConsumerMetrics::HOUR_RETENTION, + 'api_consumer_metrics:consumer:sig-a' => ApiConsumerMetrics::HOUR_RETENTION, + ]) + ->and($consumer)->toBe([ + 'type' => 'api_key', + 'label' => 'Live key', + 'last_seen_at' => '2026-07-18T12:30:45+00:00', + ]); +}); + +test('api consumer metrics record counts throttled requests as hits and throttles at an explicit time', function () { + $redis = api_consumer_metrics_redis(); + + ApiConsumerMetrics::record('sig-a', [], true, Carbon::parse('2026-07-18 09:05:00', 'UTC')); + ApiConsumerMetrics::record('sig-a', [], true, Carbon::parse('2026-07-18 09:05:30', 'UTC')); + + expect($redis->connections)->toBe(['cache', 'cache']) + ->and($redis->sortedSets)->toBe([ + 'api_consumer_metrics:m:202607180905:hits' => ['sig-a' => 2], + 'api_consumer_metrics:h:2026071809:hits' => ['sig-a' => 2], + 'api_consumer_metrics:m:202607180905:throttled' => ['sig-a' => 2], + 'api_consumer_metrics:h:2026071809:throttled' => ['sig-a' => 2], + ]); +}); + +test('api consumer metrics record never fails the request when redis is unavailable', function () { + bind_test_container(); + app()->forgetInstance('redis'); + Facade::clearResolvedInstance('redis'); + + ApiConsumerMetrics::record('sig-a', ['type' => 'ip']); + + $redis = api_consumer_metrics_redis(); + $redis->pipelineThrows = true; + + ApiConsumerMetrics::record('sig-a', ['type' => 'ip']); + + expect($redis->commands)->toBe([]); +}); + +test('api consumer metrics top aggregates minute buckets into ranked consumers with details and a timeline', function () { + $redis = api_consumer_metrics_redis(); + $now = Carbon::now(); + + foreach (range(1, 3) as $i) { + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy key'], false, $now->copy()->subMinutes(2)); + } + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy key'], false, $now); + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy key'], true, $now); + ApiConsumerMetrics::record('sig-quiet', ['label' => 'Quiet key'], false, $now); + // Outside a five-minute window. + ApiConsumerMetrics::record('sig-old', ['label' => 'Old key'], false, $now->copy()->subMinutes(10)); + + $top = ApiConsumerMetrics::top(5, 'hits', 50); + + expect($top)->toMatchArray([ + 'available' => true, + 'window' => 5, + 'granularity' => 'minute', + 'sort' => 'hits', + 'total_requests' => 6, + 'total_throttled' => 1, + 'consumer_count' => 2, + ]) + ->and($top['consumers'])->toHaveCount(2) + ->and($top['consumers'][0])->toMatchArray([ + 'label' => 'Busy key', + 'last_seen_at' => '2026-07-18T12:30:45+00:00', + 'signature' => 'sig-busy', + 'hits' => 5, + 'throttled' => 1, + 'peak_per_minute' => 3, + 'share' => 83.33, + 'avg_per_minute' => 1.0, + ]) + ->and($top['consumers'][1])->toMatchArray([ + 'label' => 'Quiet key', + 'signature' => 'sig-quiet', + 'hits' => 1, + 'throttled' => 0, + 'peak_per_minute' => 1, + 'share' => 16.67, + 'avg_per_minute' => 0.2, + ]) + ->and($top['series'])->toBe([ + ['bucket' => '2026-07-18T12:26:00+00:00', 'hits' => 0, 'throttled' => 0], + ['bucket' => '2026-07-18T12:27:00+00:00', 'hits' => 0, 'throttled' => 0], + ['bucket' => '2026-07-18T12:28:00+00:00', 'hits' => 3, 'throttled' => 0], + ['bucket' => '2026-07-18T12:29:00+00:00', 'hits' => 0, 'throttled' => 0], + ['bucket' => '2026-07-18T12:30:00+00:00', 'hits' => 3, 'throttled' => 1], + ]); +}); + +test('api consumer metrics top sorts by throttled counts and honours the limit', function () { + $redis = api_consumer_metrics_redis(); + + ApiConsumerMetrics::record('sig-busy', [], false); + ApiConsumerMetrics::record('sig-busy', [], false); + ApiConsumerMetrics::record('sig-blocked', [], true); + + $byThrottled = ApiConsumerMetrics::top(15, 'throttled', 1); + $byUnknown = ApiConsumerMetrics::top(15, 'bogus', 0); + + expect($byThrottled['sort'])->toBe('throttled') + ->and($byThrottled['consumer_count'])->toBe(2) + ->and(array_column($byThrottled['consumers'], 'signature'))->toBe(['sig-blocked']) + ->and($byUnknown['sort'])->toBe('hits') + ->and(array_column($byUnknown['consumers'], 'signature'))->toBe(['sig-busy']); +}); + +test('api consumer metrics top uses hour buckets for long windows and omits the per minute peak', function () { + $redis = api_consumer_metrics_redis(); + $now = Carbon::now(); + + ApiConsumerMetrics::record('sig-a', [], false, $now->copy()->subHours(5)); + ApiConsumerMetrics::record('sig-a', [], false, $now); + // Older than the six-hour window. + ApiConsumerMetrics::record('sig-a', [], false, $now->copy()->subHours(7)); + + $top = ApiConsumerMetrics::top(360, 'hits', 10, $now); + + expect($top['granularity'])->toBe('hour') + ->and($top['total_requests'])->toBe(2) + ->and($top['series'])->toHaveCount(6) + ->and($top['series'][0])->toBe(['bucket' => '2026-07-18T07:00:00+00:00', 'hits' => 1, 'throttled' => 0]) + ->and($top['series'][5])->toBe(['bucket' => '2026-07-18T12:00:00+00:00', 'hits' => 1, 'throttled' => 0]) + ->and($top['consumers'][0])->toMatchArray([ + 'signature' => 'sig-a', + 'hits' => 2, + 'peak_per_minute' => null, + 'avg_per_minute' => 0.01, + ]); +}); + +test('api consumer metrics top reads flat withscores replies and ignores malformed ones', function () { + $redis = api_consumer_metrics_redis(); + $redis->flatScores = true; + + ApiConsumerMetrics::record('sig-a', ['label' => 'A']); + ApiConsumerMetrics::record('sig-a', ['label' => 'A']); + ApiConsumerMetrics::record('sig-b', ['label' => 'B'], true); + + $flat = ApiConsumerMetrics::top(1); + + $redis->pipelineReply = [false, 'not-a-reply']; + $malformed = ApiConsumerMetrics::top(1); + + expect($flat['total_requests'])->toBe(3) + ->and($flat['total_throttled'])->toBe(1) + ->and(array_column($flat['consumers'], 'hits', 'signature'))->toBe(['sig-a' => 2, 'sig-b' => 1]) + ->and($malformed)->toMatchArray([ + 'available' => true, + 'total_requests' => 0, + 'consumer_count' => 0, + 'consumers' => [], + 'series' => [['bucket' => '2026-07-18T12:30:00+00:00', 'hits' => 0, 'throttled' => 0]], + ]); +}); + +test('api consumer metrics top keeps counts without details when stored details are missing or unreadable', function () { + $redis = api_consumer_metrics_redis(); + + ApiConsumerMetrics::record('sig-a', ['label' => 'A']); + ApiConsumerMetrics::record('sig-b', ['label' => 'B'], true); + $redis->strings['api_consumer_metrics:consumer:sig-a'] = '"not an object"'; + unset($redis->strings['api_consumer_metrics:consumer:sig-b']); + + $withoutDetails = ApiConsumerMetrics::top(1); + + $redis->mgetThrows = true; + $detailsDown = ApiConsumerMetrics::top(1); + + expect($withoutDetails['consumers'][0])->not->toHaveKey('label') + ->and($withoutDetails['consumers'][1])->not->toHaveKey('label') + ->and($withoutDetails['consumers'][1])->toMatchArray(['signature' => 'sig-b', 'hits' => 1, 'throttled' => 1, 'share' => 50.0]) + ->and($detailsDown['consumers'])->toHaveCount(2) + ->and($detailsDown['consumers'][0])->not->toHaveKey('label'); +}); + +test('api consumer metrics top reports throttles without hits as a zero share', function () { + $redis = api_consumer_metrics_redis(); + $redis->pipelineReply = [[], ['sig-a' => '2']]; + + $top = ApiConsumerMetrics::top(0); + + expect($top['window'])->toBe(0) + ->and($top['series'])->toHaveCount(1) + ->and($top['consumers'][0])->toMatchArray([ + 'signature' => 'sig-a', + 'hits' => 0, + 'throttled' => 2, + 'share' => 0, + 'avg_per_minute' => 0.0, + 'peak_per_minute' => null, + ]); +}); + +test('api consumer metrics top reports metrics as unavailable when redis fails', function () { + $redis = api_consumer_metrics_redis(); + $redis->pipelineThrows = true; + + expect(ApiConsumerMetrics::top(15, 'throttled'))->toBe([ + 'available' => false, + 'window' => 15, + 'granularity' => 'minute', + 'sort' => 'throttled', + 'total_requests' => 0, + 'total_throttled' => 0, + 'consumer_count' => 0, + 'consumers' => [], + 'series' => [], + ]) + ->and(ApiConsumerMetrics::top(1440)['granularity'])->toBe('hour'); +}); diff --git a/tests/Unit/Support/ApiRateLimitsTest.php b/tests/Unit/Support/ApiRateLimitsTest.php new file mode 100644 index 00000000..024be780 --- /dev/null +++ b/tests/Unit/Support/ApiRateLimitsTest.php @@ -0,0 +1,403 @@ +<?php + +use Fleetbase\Models\Setting; +use Fleetbase\Models\User; +use Fleetbase\Support\ApiRateLimits; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Events\Dispatcher; +use Illuminate\Support\Facades\Facade; + +function api_rate_limits_database(array $config = []): Capsule +{ + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'api.cache.enabled' => false, + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 120, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.track_consumers'=> true, + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + $container->instance('cache', new CacheRepository(new ArrayStore())); + app()->forgetInstance('redis'); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->addConnection($connection, 'sandbox'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type'); + $table->string('tokenable_id'); + $table->string('name')->nullable(); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + foreach (['mysql', 'sandbox'] as $name) { + $capsule->getConnection($name)->getSchemaBuilder()->create('api_credentials', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('key')->nullable(); + $table->string('secret')->nullable(); + $table->boolean('test_mode')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + $db = $capsule->getConnection('mysql'); + $db->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ['uuid' => 'company-sandbox', 'public_id' => 'company_sandbox', 'name' => 'Sandbox Org'], + ]); + $db->table('users')->insert([ + ['uuid' => 'user-ada', 'company_uuid' => 'company-acme', 'name' => 'Ada Lovelace', 'email' => 'ada@example.test'], + ['uuid' => 'user-nameless', 'company_uuid' => null, 'name' => null, 'email' => 'nameless@example.test'], + ]); + $db->table('personal_access_tokens')->insert([ + ['id' => 1, 'tokenable_type' => User::class, 'tokenable_id' => 'user-ada', 'name' => 'console', 'token' => hash('sha256', 'ada-plain-token'), 'abilities' => '["*"]'], + ['id' => 2, 'tokenable_type' => User::class, 'tokenable_id' => 'user-nameless', 'name' => 'cli', 'token' => hash('sha256', 'nameless-plain-token'), 'abilities' => '["*"]'], + ['id' => 3, 'tokenable_type' => User::class, 'tokenable_id' => 'user-missing', 'name' => 'orphan', 'token' => hash('sha256', 'orphan-plain-token'), 'abilities' => '["*"]'], + ]); + $db->table('api_credentials')->insert([ + ['uuid' => 'credential-live', 'company_uuid' => 'company-acme', 'name' => 'Production', 'key' => 'flb_live_abcdefghijklmnop', 'secret' => '$live_secret_value', 'test_mode' => 0], + ['uuid' => 'credential-unnamed', 'company_uuid' => 'company-gone', 'name' => '', 'key' => 'flb_live_unnamed_key', 'secret' => '$unnamed_secret', 'test_mode' => 0], + ]); + $capsule->getConnection('sandbox')->table('api_credentials')->insert([ + ['uuid' => 'credential-test', 'company_uuid' => 'company-sandbox', 'name' => 'Sandbox', 'key' => 'flb_test_abcdefghijklmnop', 'secret' => '$test_secret_value', 'test_mode' => 1], + ]); + + return $capsule; +} + +afterEach(function () { + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['track_consumers' => true])]); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('api rate limits defaults come from the environment configuration', function () { + bind_test_container([ + 'api.throttle.enabled' => false, + 'api.throttle.max_attempts' => '300', + 'api.throttle.decay_minutes' => '5', + 'api.throttle.track_consumers' => false, + ]); + + expect(ApiRateLimits::defaults())->toBe([ + 'enabled' => false, + 'max_attempts' => 300, + 'decay_minutes' => 5, + 'track_consumers' => false, + 'overrides' => [], + ]); +}); + +test('api rate limits settings fall back to the defaults when the cache or database is unavailable', function () { + // The minimal container cache has no remember(), as on a broken install. + bind_test_container([ + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 60, + 'api.throttle.decay_minutes' => 2, + 'api.throttle.track_consumers' => true, + ]); + Facade::clearResolvedInstance('cache'); + + expect(ApiRateLimits::settings())->toBe([ + 'enabled' => true, + 'max_attempts' => 60, + 'decay_minutes' => 2, + 'track_consumers' => true, + 'overrides' => [], + ]); +}); + +test('api rate limits settings apply the stored administrator override and cache it', function () { + $capsule = api_rate_limits_database(); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); + + // Cached: a row written behind the cache's back is not seen until the cache is cleared. + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'system.rate-limits', + 'value' => json_encode(['max_attempts' => 30, 'overrides' => [['company_uuid' => 'company-acme', 'unlimited' => true]]]), + ]); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); + + cache()->forget(ApiRateLimits::SETTINGS_CACHE_KEY); + + expect(ApiRateLimits::settings())->toBe([ + 'enabled' => true, + 'max_attempts' => 30, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [[ + 'company_uuid' => 'company-acme', + 'unlimited' => true, + 'max_attempts' => null, + 'note' => '', + ]], + ]); +}); + +test('api rate limits settings ignore a stored value that is not an object', function () { + $capsule = api_rate_limits_database(); + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'system.rate-limits', + 'value' => json_encode('garbage'), + ]); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); +}); + +test('api rate limits store persists normalized settings and makes them effective immediately', function () { + api_rate_limits_database(); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); + + $stored = ApiRateLimits::store([ + 'max_attempts' => 45, + 'decay_minutes' => 0, + 'overrides' => [['company_uuid' => 'company-acme', 'max_attempts' => '500', 'note' => 'Partner']], + ]); + + $expected = [ + 'enabled' => true, + 'max_attempts' => 45, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [[ + 'company_uuid' => 'company-acme', + 'unlimited' => false, + 'max_attempts' => 500, + 'note' => 'Partner', + ]], + ]; + + expect($stored)->toBe($expected) + ->and(Setting::where('key', 'system.rate-limits')->value('value'))->toBe($expected) + ->and(ApiRateLimits::settings())->toBe($expected); +}); + +test('api rate limits reset drops the override and returns the environment defaults', function () { + api_rate_limits_database(); + ApiRateLimits::store(['max_attempts' => 10, 'enabled' => false]); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(10); + + $reset = ApiRateLimits::reset(); + + expect($reset['max_attempts'])->toBe(120) + ->and($reset['enabled'])->toBeTrue() + ->and(Setting::where('key', 'system.rate-limits')->exists())->toBeFalse(); +}); + +test('api rate limits normalize coerces values and drops overrides without an organization', function () { + expect(ApiRateLimits::normalize([ + 'enabled' => 0, + 'max_attempts' => '-5', + 'decay_minutes' => '3', + 'track_consumers' => '', + 'overrides' => [ + ['company_uuid' => 'company-a', 'max_attempts' => 'lots', 'unlimited' => 1, 'note' => null], + ['company_uuid' => ''], + ['company_uuid' => 42], + ['max_attempts' => 5], + ['company_uuid' => 'company-b', 'max_attempts' => 0], + // A later override for the same organization replaces the earlier one. + ['company_uuid' => 'company-a', 'max_attempts' => 7], + ], + ]))->toBe([ + 'enabled' => false, + 'max_attempts' => 1, + 'decay_minutes' => 3, + 'track_consumers' => false, + 'overrides' => [ + ['company_uuid' => 'company-a', 'unlimited' => false, 'max_attempts' => 7, 'note' => ''], + ['company_uuid' => 'company-b', 'unlimited' => false, 'max_attempts' => 1, 'note' => ''], + ], + ]) + ->and(ApiRateLimits::normalize([]))->toBe([ + 'enabled' => true, + 'max_attempts' => 120, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [], + ]); +}); + +test('api rate limits limit for applies organization overrides and lifts unlimited ones', function () { + $settings = ApiRateLimits::normalize([ + 'max_attempts' => 100, + 'overrides' => [ + ['company_uuid' => 'company-raised', 'max_attempts' => 1000], + ['company_uuid' => 'company-default'], + ['company_uuid' => 'company-unlimited', 'unlimited' => true, 'max_attempts' => 5], + ], + ]); + + expect(ApiRateLimits::limitFor(null, $settings))->toBe(100) + ->and(ApiRateLimits::limitFor('company-other', $settings))->toBe(100) + ->and(ApiRateLimits::limitFor('company-raised', $settings))->toBe(1000) + ->and(ApiRateLimits::limitFor('company-default', $settings))->toBe(100) + ->and(ApiRateLimits::limitFor('company-unlimited', $settings))->toBeNull(); +}); + +test('api rate limits limit for reads the effective settings when none are given', function () { + api_rate_limits_database(); + ApiRateLimits::store(['max_attempts' => 80, 'overrides' => [['company_uuid' => 'company-acme', 'unlimited' => true]]]); + + expect(ApiRateLimits::limitFor(null))->toBe(80) + ->and(ApiRateLimits::limitFor('company-acme'))->toBeNull(); +}); + +test('api rate limits identify resolves live and sandbox api keys by key or secret', function () { + api_rate_limits_database(); + + expect(ApiRateLimits::identify('Bearer flb_live_abcdefghijklmnop'))->toBe([ + 'type' => 'api_key', + 'label' => 'Production', + 'detail' => 'flb_live_abc…', + 'test_mode' => false, + 'credential_uuid' => 'credential-live', + 'company_uuid' => 'company-acme', + 'company_id' => 'company_acme', + 'company_name' => 'Acme', + ]) + ->and(ApiRateLimits::identify('Bearer $live_secret_value')['credential_uuid'])->toBe('credential-live') + // A secret carries no mode prefix, so a live miss falls through to the sandbox. + ->and(ApiRateLimits::identify('Bearer $test_secret_value')['credential_uuid'])->toBe('credential-test') + ->and(ApiRateLimits::identify('Bearer flb_test_abcdefghijklmnop'))->toBe([ + 'type' => 'api_key', + 'label' => 'Sandbox', + 'detail' => 'flb_test_abc…', + 'test_mode' => true, + 'credential_uuid' => 'credential-test', + 'company_uuid' => 'company-sandbox', + 'company_id' => 'company_sandbox', + 'company_name' => 'Sandbox Org', + ]) + // An unnamed key whose organization no longer exists keeps the raw organization id. + ->and(ApiRateLimits::identify('flb_live_unnamed_key'))->toMatchArray([ + 'label' => 'API key', + 'company_uuid' => 'company-gone', + 'company_id' => null, + 'company_name' => null, + ]); +}); + +test('api rate limits identify resolves sanctum personal access tokens to their user', function () { + api_rate_limits_database(); + + expect(ApiRateLimits::identify('Bearer 1|ada-plain-token'))->toBe([ + 'type' => 'token', + 'label' => 'Ada Lovelace', + 'detail' => 'console', + 'test_mode' => false, + 'credential_uuid' => null, + 'company_uuid' => 'company-acme', + 'company_id' => 'company_acme', + 'company_name' => 'Acme', + ]) + ->and(ApiRateLimits::identify('Bearer 2|nameless-plain-token'))->toMatchArray([ + 'type' => 'token', + 'label' => 'nameless@example.test', + 'detail' => 'cli', + 'company_uuid' => null, + 'company_id' => null, + ]) + ->and(ApiRateLimits::identify('Bearer 3|orphan-plain-token')['type'])->toBe('unknown') + ->and(ApiRateLimits::identify('Bearer 1|wrong-plain-token')['type'])->toBe('unknown'); +}); + +test('api rate limits identify describes unknown credentials without revealing them and caches the answer', function () { + $capsule = api_rate_limits_database(); + + $unknown = ApiRateLimits::identify('Bearer flb_live_unknown_credential'); + + expect($unknown)->toBe([ + 'type' => 'unknown', + 'label' => 'Unrecognized credential', + 'detail' => 'flb_live_unk…', + 'test_mode' => false, + 'credential_uuid' => null, + 'company_uuid' => null, + 'company_id' => null, + 'company_name' => null, + ]) + ->and(ApiRateLimits::identify('Basic:someone')['detail'])->toBe('Basic:…'); + + // A credential created after its first lookup stays unknown until the cache expires. + $capsule->getConnection('mysql')->table('api_credentials')->insert([ + 'uuid' => 'credential-late', 'company_uuid' => 'company-acme', 'name' => 'Late', 'key' => 'flb_live_unknown_credential', 'secret' => '$late', 'test_mode' => 0, + ]); + + expect(ApiRateLimits::identify('Bearer flb_live_unknown_credential')['type'])->toBe('unknown'); + + cache()->forget(ApiRateLimits::CONSUMER_CACHE_PREFIX . sha1('Bearer flb_live_unknown_credential')); + + expect(ApiRateLimits::identify('Bearer flb_live_unknown_credential')['label'])->toBe('Late'); +}); + +test('api rate limits identify reports an unknown consumer when the lookup fails', function () { + bind_test_container(); + Facade::clearResolvedInstance('cache'); + + expect(ApiRateLimits::identify('Bearer flb_live_abcdefghijklmnop'))->toMatchArray([ + 'type' => 'unknown', + 'detail' => 'flb_live_abc…', + ]) + ->and(ApiRateLimits::identify('Bearer ')['detail'])->toBe('Bea…'); +}); + +test('api rate limits mask keeps at most half of a value and never more than twelve characters', function () { + expect(ApiRateLimits::mask(null))->toBeNull() + ->and(ApiRateLimits::mask(''))->toBeNull() + ->and(ApiRateLimits::mask('abcd'))->toBe('ab…') + ->and(ApiRateLimits::mask('flb_live_abcdefghijklmnop'))->toBe('flb_live_abc…'); +}); From 2f008a88b07af7752db6df1d766969e3fcdfbfcd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" <me@ron.dev> Date: Tue, 29 Sep 2026 16:28:32 +0800 Subject: [PATCH 3/3] release: v1.6.66 --- RELEASE.md | 43 ++++++------------------------------------- composer.json | 2 +- 2 files changed, 7 insertions(+), 38 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 43a03b15..87bbbdc8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,42 +1,11 @@ -# v1.6.65 — Authenticator-app 2FA, sign-in hardening and IAM permission fixes - -## Improvements - -- Organization administration supports company and owner identity search, country/timezone/owner-IP and registration/update-date filters, and sorting by current user count. Admins can open organizations outside their own memberships, inspect organization-scoped usage totals, and see members' 2FA methods and linked OAuth providers without exposing authentication secrets. -- Sign in with an authenticator app (TOTP, RFC 6238), next to email and SMS 2FA (#163). It works with Authy, Google Authenticator, Microsoft Authenticator and 1Password. - - New `users/two-fa/authenticator` endpoints to set up, confirm, disable and inspect the app. Setup, disable and regenerating recovery codes need the current password. - - Confirming the app returns 8 single-use recovery codes. `two-fa/verify` accepts an app code (±1 step for clock drift, each code once) or a recovery code. - - `two-fa/resend` falls back to an emailed (or SMS) code and returns the new `method`. - - The secret is encrypted with the app key, and recovery codes are stored as keyed hashes. - - New `Fleetbase\Support\Barcode` helper with a compact `qrCodeSvg()`. -- Record the app, APNs environment and last-seen time on user devices. New nullable `user_devices` columns: `app_identifier` (indexed), `environment` and `last_seen_at`. -- New organization setting to let users change their own password (default on), at `GET/POST companies/auth-settings`. `GET users/password-policy` returns `{can_change_password}`. +# v1.6.66 — Per-consumer API rate limiting, admin rate-limit controls and API consumer metrics ## Fixes -- Settings → Notifications applies when notifications are sent from the queue or a console command (#262). `NotificationRegistry` takes the company from the notification's subject instead of the session. `notifyUsingDefinitionName()` reads the company-scoped settings instead of a global key nothing writes. New `Setting::lookupForCompany()`. -- Invited users can set their first password, and non-admins can change their own (#263). The password endpoints no longer fall through to `iam create user`. -- FCM order notifications are sent with Android `priority: high`, so drivers' phones in Doze get them immediately (#268). - -## Security +- One API consumer can no longer rate-limit the whole platform. `ThrottleRequests` ran before API authentication, so Laravel keyed every bucket on the client IP. Behind a load balancer that is the balancer's IP, so every tenant, API key and console visitor shared a single 120/min bucket, and one busy integration returned 429 to everyone. The limiter now keys on the presented credential (API key, Sanctum token or basic auth), falling back to the user and then the IP only when none is sent. The first path segment keeps `/v1` and `/int` in separate buckets. (#280) +- 429 responses keep `Retry-After` and `X-RateLimit-*`. The exception handler used to drop them, so throttled clients could not back off correctly. (#280) -- A 2FA session starts only after the password is checked. `GET two-fa/check` used to start one from the identity alone, so an email plus the emailed code was enough to sign in. It now always returns `{twoFaSession: null, isTwoFaEnabled: false}` and no longer reveals whether an account uses 2FA. -- 2FA sessions expire after 10 minutes; they used to live about 56 years. The 5th wrong code deletes the session, and resending doesn't reset the count. Codes are compared in constant time and generated with `random_int`. -- `users/change-password` requires `current_password` in the same request, and `iam change-password` is enforced. `users/set-password` works only once, within 24 hours of accepting an invite. `validate-password` and `change-password` are limited to 10 requests per minute. -- Close authorization gaps where `AuthorizationGuard` resolved to permission names that don't exist: - - Updating the organization and its 2FA policy is limited to the owner, the Administrator role and system admins. Non-admin updates ignore `owner_uuid`, Stripe ids, `plan`, `status`, `trial_ends_at` and `type`. - - `POST two-fa/config` (system 2FA policy) and admin platform metrics are limited to system admins. - - IAM and developer metrics need `iam list user` / `developers list api-key`. - - Reports use the `iam` service: `iam execute report` for direct queries, `iam export report` for exports. - - API credentials, webhooks, API events and request logs check the `api-key`, `webhook`, `event` and `log` permissions. -- Password, auth-setting and authenticator changes are written to the `auth` activity log. - -## Behaviour changes - -- Consoles need the companion fleetbase/fleetbase changes: the 2FA sign-in flow (`fix/2fa-login-hardening`), `current_password` on change-password (fleetbase/fleetbase#685) and the authenticator-app UI (fleetbase/fleetbase#686). With an older console, users with 2FA can't sign in and self-service password changes fail. -- Users need `iam … report` permissions to use reports, and `developers …` permissions for API keys, webhooks, events and logs. Fleet-Ops' report screens check the same names in fleetbase/fleetops#345. -- A user who accepted an invite before this release but never set a password should use **Forgot password**. - -Run the migrations for the new `user_devices` columns. Run `composer update` to install `pragmarx/google2fa`. +## Improvements -Changes: [#272](https://github.com/fleetbase/core-api/pull/272), [#273](https://github.com/fleetbase/core-api/pull/273), [#274](https://github.com/fleetbase/core-api/pull/274), [#275](https://github.com/fleetbase/core-api/pull/275), [#276](https://github.com/fleetbase/core-api/pull/276), [#277](https://github.com/fleetbase/core-api/pull/277), [#278](https://github.com/fleetbase/core-api/pull/278). +- System admins can manage API rate limits at runtime. The limits (`THROTTLE_*` environment defaults) can be overridden from the console and stored as the `system.rate-limits` setting: enable/disable, requests per window, and window length. **Per-organization overrides** give an organization a custom limit or none. New admin-only endpoints `GET/POST/DELETE int/v1/rate-limits/settings`. (#281) +- API consumer metrics. The throttle middleware counts every request and every 429 per consumer in Redis: minute buckets are kept for 2 hours, hour buckets for 8 days, at one pipelined round trip per request. `GET int/v1/rate-limits/consumers?window=&sort=&limit=` lists the busiest or most throttled consumers with their organization, masked key, scope, IP, avg and peak per minute, and share. `POST int/v1/rate-limits/consumers/{signature}/reset` clears one consumer's window. Tracking can be disabled with `THROTTLE_TRACK_CONSUMERS=false`; `THROTTLE_METRICS_REDIS_CONNECTION` picks the Redis connection (default `cache`). (#281) diff --git a/composer.json b/composer.json index 0978d23f..2cf39cdb 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.65", + "version": "1.6.66", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase",