diff --git a/documentation/auth/route-scope-audit.md b/documentation/auth/route-scope-audit.md index b60f7a64..9e826030 100644 --- a/documentation/auth/route-scope-audit.md +++ b/documentation/auth/route-scope-audit.md @@ -21,6 +21,7 @@ The scope set is defined in `services/nginx/app/classes/auth/scope.php` | `INVOICE_READ` | `invoice:read` | GET on invoices / invoicing period | | `INVOICE_WRITE` | `invoice:write` | POST/PUT/DELETE on invoices | | `SUPERUSER_READ` | `superuser:read` | GET on superuser-only resources (cron, replication, coolify, system status) | +| `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources (cron run, replication trigger, intimidation) | | `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources | ### Role → Scope mapping @@ -226,3 +227,19 @@ A missing scope produces a 403 with payload 3. Should `ScopeMiddleware::resolveGrantedScopes()` honour a future `X-Scopes` header for API key requests, or is the role-mapping always the source? TRU-149 picks role-mapping as a stop-gap. + +## Reference: scope constants + +For convenience during reviews, the canonical constant names that +appear in route handlers and middleware calls are: + +- `Scope::CUSTOMER_READ` / `Scope::CUSTOMER_WRITE` +- `Scope::BOOKING_READ` / `Scope::BOOKING_WRITE` +- `Scope::SUBUSER_READ` / `Scope::SUBUSER_WRITE` +- `Scope::INVOICE_READ` / `Scope::INVOICE_WRITE` +- `Scope::SUPERUSER_READ` / `Scope::SUPERUSER_WRITE` + +All ten constants are defined in `services/nginx/app/classes/auth/scope.php` +and exported via `Scope::all()`. Wildcard forms (`*`, `customer:*`) are +also accepted by `Scope::matches()` for grants, but the route handlers +should always reference the concrete constants above. diff --git a/services/nginx/app/routes/accountDeletionRoute.php b/services/nginx/app/routes/accountDeletionRoute.php index 3dd32011..5e5ca830 100644 --- a/services/nginx/app/routes/accountDeletionRoute.php +++ b/services/nginx/app/routes/accountDeletionRoute.php @@ -7,6 +7,9 @@ use classes\account_deletion_service; use Throwable; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; + class accountDeletionRoute { use route_t; @@ -14,6 +17,7 @@ class accountDeletionRoute public function run(): void { $this->get('/account/deletion', function () { + ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/account/deletion'); global $response; try { if (!account_deletion_service::apiEnabled()) { @@ -31,6 +35,7 @@ class accountDeletionRoute }); $this->post('/account/deletion', function () { + ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/deletion'); global $response; try { if (!account_deletion_service::apiEnabled()) { diff --git a/services/nginx/app/routes/attachmentsRoute.php b/services/nginx/app/routes/attachmentsRoute.php index e69c9b18..689e77e7 100644 --- a/services/nginx/app/routes/attachmentsRoute.php +++ b/services/nginx/app/routes/attachmentsRoute.php @@ -6,6 +6,9 @@ use classes\attachment_store; use classes\response; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; + class attachmentsRoute { use route_t; @@ -13,9 +16,11 @@ class attachmentsRoute public function run(): void { $this->get('/attachments/example', function (): never { + ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/attachments/example'); throw new \Exception('EXAMPLE ROUTE, SHOULD BE IMPLEMENTED IN THE INDIVIDUAL OBJECT ROUTES'); }); $this->post('/attachments/upload', function () { + ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/attachments/upload'); global $response; self::requireParameters(['base64_image']); $base64_image = self::getParameter('base64_image'); diff --git a/services/nginx/app/routes/birdControlPlaneRoute.php b/services/nginx/app/routes/birdControlPlaneRoute.php index 0b28414b..82d14662 100644 --- a/services/nginx/app/routes/birdControlPlaneRoute.php +++ b/services/nginx/app/routes/birdControlPlaneRoute.php @@ -28,6 +28,9 @@ use Throwable; use traits\bird_route_helpers_t; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; + /** * Narrow integration boundary between Bird and Pleno Control Plane. * @@ -41,6 +44,7 @@ class birdControlPlaneRoute public function run(): void { $this->get('/bird/health', function (): void { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/health'); global $response; $this->requirePermission('modules_bird_health_read'); diff --git a/services/nginx/app/routes/birdNumbersRoute.php b/services/nginx/app/routes/birdNumbersRoute.php index c7726080..0bab12c4 100644 --- a/services/nginx/app/routes/birdNumbersRoute.php +++ b/services/nginx/app/routes/birdNumbersRoute.php @@ -6,6 +6,9 @@ use classes\bird; use traits\bird_route_helpers_t; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; + class birdNumbersRoute { use route_t, bird_route_helpers_t; @@ -14,6 +17,7 @@ class birdNumbersRoute { // List owned numbers $this->get('/bird/numbers', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/numbers'); global $response; // Permission: list numbers via Bird $this->requirePermission('modules_bird_numbers_list'); diff --git a/services/nginx/app/routes/birdVoiceCallsRoute.php b/services/nginx/app/routes/birdVoiceCallsRoute.php index f894217d..31a03eca 100644 --- a/services/nginx/app/routes/birdVoiceCallsRoute.php +++ b/services/nginx/app/routes/birdVoiceCallsRoute.php @@ -23,6 +23,8 @@ use bird\helpers\bird_voice_recording_update_payload; use bird\helpers\bird_voice_recordings_create_payload; use bird\helpers\bird_voice_recordings_list_query_payload; use bird\helpers\bird_voice_say_payload; +use app\auth\Scope; +use app\auth\ScopeMiddleware; use bird\helpers\bird_voice_test_outbound_payload; use bird\helpers\bird_voice_update_call_payload; use classes\bird; @@ -37,7 +39,10 @@ class birdVoiceCallsRoute public function run(): void { // List workspace call log - $this->get('/bird/voice/calls/log', function () { + // TRU-149: scope check added to satisfy scope middleware contract + // (every protected route must have at least one requireScope call). + $this->get('/bird/voice/calls/log', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/calls/log'); global $response; $this->requirePermission('modules_bird_voice_calls_log_list'); diff --git a/services/nginx/app/routes/birdVoiceFlashCallsRoute.php b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php index ca3c08de..549653d9 100644 --- a/services/nginx/app/routes/birdVoiceFlashCallsRoute.php +++ b/services/nginx/app/routes/birdVoiceFlashCallsRoute.php @@ -16,6 +16,8 @@ use bird\helpers\bird_flash_list_query_payload; use bird\helpers\bird_request_schemas; use classes\bird; use traits\bird_route_helpers_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; use traits\bird_route_validation_t; use traits\route_t; @@ -27,6 +29,7 @@ class birdVoiceFlashCallsRoute { // Create a flash call $this->post('/bird/voice/flash-calls', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/flash-calls'); global $response; $this->requirePermission('modules_bird_voice_flash_calls_create'); diff --git a/services/nginx/app/routes/exampleRoute.php b/services/nginx/app/routes/exampleRoute.php index 95cb5dd1..012d2e1f 100644 --- a/services/nginx/app/routes/exampleRoute.php +++ b/services/nginx/app/routes/exampleRoute.php @@ -13,6 +13,8 @@ use objects\departments_o; use objects\order_items_o; use objects\orders_o; use objects\products_o; +use app\auth\Scope; +use app\auth\ScopeMiddleware; use objects\users_o; use traits\route_t; @@ -23,6 +25,7 @@ class exampleRoute public function run(): void { $this->get('/example', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/example'); global $response; $response->success(['message' => 'Hello World!']); }); diff --git a/services/nginx/app/routes/intimidateRoute.php b/services/nginx/app/routes/intimidateRoute.php index e858952e..31ae4057 100644 --- a/services/nginx/app/routes/intimidateRoute.php +++ b/services/nginx/app/routes/intimidateRoute.php @@ -7,6 +7,9 @@ use objects\logs_o; use objects\users_o; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; + class intimidateRoute { use route_t; @@ -14,6 +17,7 @@ class intimidateRoute public function run(): void { $this->post('/su/intimidate', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/su/intimidate'); // Get the post data global $response; // Make sure the user has the SUPERUSER_INTIMIDATE permission diff --git a/services/nginx/app/routes/limitedBackofficeRoute.php b/services/nginx/app/routes/limitedBackofficeRoute.php index 96d501e3..9881e87d 100644 --- a/services/nginx/app/routes/limitedBackofficeRoute.php +++ b/services/nginx/app/routes/limitedBackofficeRoute.php @@ -8,6 +8,9 @@ use classes\limited_backoffice_login_grant_service; use classes\limited_backoffice_service; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; + require_once WD . '/classes/limited_backoffice_login_grant_service.php'; class limitedBackofficeRoute @@ -17,6 +20,7 @@ class limitedBackofficeRoute public function run(): void { $this->get('/limited-backoffice/departments', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/limited-backoffice/departments'); $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); return $service->departmentsForUser($user); diff --git a/services/nginx/app/routes/machineButtonPressRoute.php b/services/nginx/app/routes/machineButtonPressRoute.php index b423eba7..2f227892 100644 --- a/services/nginx/app/routes/machineButtonPressRoute.php +++ b/services/nginx/app/routes/machineButtonPressRoute.php @@ -11,6 +11,9 @@ use objects\logs_o; use objects\plate_scanners_o; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; + class machineButtonPressRoute { use route_t; @@ -18,6 +21,7 @@ class machineButtonPressRoute public function run(): void { $handler = function () { + ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/relay/button/press/post'); global $response; self::requirePlateScannerAuth(); diff --git a/services/nginx/app/routes/moduleFxRatesAPIRoute.php b/services/nginx/app/routes/moduleFxRatesAPIRoute.php index 3638ab64..4f05d3de 100644 --- a/services/nginx/app/routes/moduleFxRatesAPIRoute.php +++ b/services/nginx/app/routes/moduleFxRatesAPIRoute.php @@ -9,6 +9,8 @@ use classes\router; use objects\currency_conversion_rates_o; use objects\logs_o; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; class moduleFxRatesAPIRoute { @@ -23,6 +25,7 @@ class moduleFxRatesAPIRoute /** Modules > FXRatesAPI > conversion rate > GET */ $this->get('/modules/fxratesapi/rate', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/fxratesapi/rate'); global $response; $this->requirePermission('modules_fxratesapi_rate'); $user = (new authentication())->get_user(); diff --git a/services/nginx/app/routes/moduleWeatherAPIRoute.php b/services/nginx/app/routes/moduleWeatherAPIRoute.php index 6550946e..27023592 100644 --- a/services/nginx/app/routes/moduleWeatherAPIRoute.php +++ b/services/nginx/app/routes/moduleWeatherAPIRoute.php @@ -19,6 +19,8 @@ use objects\departments_o; use objects\logs_o; use objects\orders_o; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; class moduleWeatherAPIRoute { @@ -46,6 +48,7 @@ class moduleWeatherAPIRoute $router, $response; $this->get('/modules/weatherapi/current', function () { + ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/weatherapi/current'); global $response; $this->requirePermission('modules_weatherapi_current'); $user = (new authentication())->get_user(); diff --git a/services/nginx/app/routes/statisticsRoute.php b/services/nginx/app/routes/statisticsRoute.php index 1f220c8d..5e2b3b51 100644 --- a/services/nginx/app/routes/statisticsRoute.php +++ b/services/nginx/app/routes/statisticsRoute.php @@ -7,6 +7,8 @@ use classes\response; use classes\statistics; use objects\logs_o; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; class statisticsRoute { @@ -16,6 +18,7 @@ class statisticsRoute { /** Statistics >> Bookings >> New bookings */ $this->get('/statistics/bookings/new', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/statistics/bookings/new'); // Require the user to be logged in global /** @var response $response */ diff --git a/services/nginx/app/routes/washCertificateDebugRoute.php b/services/nginx/app/routes/washCertificateDebugRoute.php index c650d29b..081ebe16 100644 --- a/services/nginx/app/routes/washCertificateDebugRoute.php +++ b/services/nginx/app/routes/washCertificateDebugRoute.php @@ -8,6 +8,8 @@ use objects\logs_o; use objects\order_bookings_o; use objects\users_o; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; /** * Debug route for diagnosing wash certificate delivery failures. @@ -31,6 +33,7 @@ class washCertificateDebugRoute public function run(): void { $this->get('/debug/wash-certificates/diagnose', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/debug/wash-certificates/diagnose'); global $response, $DEBUG; if (empty($DEBUG)) { diff --git a/services/nginx/app/routes/workerRoute.php b/services/nginx/app/routes/workerRoute.php index 10af1331..146ce669 100644 --- a/services/nginx/app/routes/workerRoute.php +++ b/services/nginx/app/routes/workerRoute.php @@ -18,6 +18,8 @@ use objects\product_options_o; use objects\products_o; use objects\users_o; use traits\route_t; +use app\auth\Scope; +use app\auth\ScopeMiddleware; class workerRoute { @@ -26,6 +28,7 @@ class workerRoute public function run(): void { $this->get('/worker/version', function () { + ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/worker/version'); global /** @var router $router */ $response, $router; $response->success(['version' => redis->get('worker_target_version') ?? 'unknown'] ); diff --git a/services/nginx/app/tests/Integration/Auth/RouteScopeTest.php b/services/nginx/app/tests/Integration/Auth/RouteScopeTest.php index aa55e3fc..6564a193 100644 --- a/services/nginx/app/tests/Integration/Auth/RouteScopeTest.php +++ b/services/nginx/app/tests/Integration/Auth/RouteScopeTest.php @@ -31,8 +31,8 @@ it('every protected route file imports the scope classes', function (): void { foreach ($files as $f) { $src = file_get_contents($f); - expect($src)->toContain('use app\\auth\\ScopeMiddleware;', "missing ScopeMiddleware import in $f"); - expect($src)->toContain('use app\\auth\\Scope;', "missing Scope import in $f"); + expect($src)->toContain('use app\\auth\\ScopeMiddleware;'); + expect($src)->toContain('use app\\auth\\Scope;'); } });