diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index c5c5e30e..646c6b6b 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -10606,10 +10606,10 @@ paths: tags: - Users summary: Get customer attributes - description: Get custom attributes for a customer + description: Get custom attributes for a customer. Authenticated customer accounts may read their own attributes without list_customer_attributes. operationId: getCustomerAttributes parameters: - - name: customer_id + - name: customer_number in: query schema: type: integer diff --git a/services/nginx/app/routes/customerAttributes.php b/services/nginx/app/routes/customerAttributes.php index a0db11a9..274892d8 100644 --- a/services/nginx/app/routes/customerAttributes.php +++ b/services/nginx/app/routes/customerAttributes.php @@ -16,11 +16,12 @@ class customerAttributes $this->get('/customer/attributes', function () { // Require the user to be logged in global $response; - $this->requirePermission('list_customer_attributes'); // Get the user object - $user = (new authentication())->get_user(); + $auth = new authentication(); + $user = $auth->get_user(); + $subuser = $auth->get_subuser(); // Check if the request was successful - if ($user) { + if ($user || $subuser) { // Get the query parameters from the URL $data = $_GET; // Check if the required fields are set @@ -32,14 +33,19 @@ class customerAttributes $response->error('Customer Number must be a number', 400); } // Check if the user exists - if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) { + $target_user = (new users_o())->automaticGetTargetUserFromRequest(); + if (!$target_user->exists()) { $response->error('Customer not found', 400); } + if (!$this->canListTargetCustomerAttributes($target_user)) { + $this->requirePermission('list_customer_attributes'); + } // Log the incident - (new logs_o())->add('customer_attributes', 'global', 1, $user->id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes'); + $actor_id = $user !== false ? (int)$user->id : (int)($subuser->id ?? 0); + (new logs_o())->add('customer_attributes', 'global', 1, $actor_id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes'); // Return the list of customer notes $response->success( - (new users_o())->automaticGetTargetUserFromRequest()->getUserAttributes() + $target_user->getUserAttributes() ); } else { // Log the incident @@ -49,7 +55,7 @@ class customerAttributes } }, [ - 'list_customer_attributes' => 'List all customer attributes' + 'list_customer_attributes' => 'List all customer attributes. Authenticated customer accounts may list their own customer attributes without this permission.' ] ); @@ -127,4 +133,29 @@ class customerAttributes ] ); } -} \ No newline at end of file + + private function canListTargetCustomerAttributes(users_o $target_user): bool + { + if (!$target_user->exists()) { + return false; + } + + $target_customer_number = (int)$target_user->customer_number->value(); + if ($target_customer_number <= 0) { + return false; + } + + $auth = new authentication(); + $user = $auth->get_user(); + if ( + $user !== false + && $user->exists() + && $this->hasPermission('user') + && (int)$user->customer_number->value() === $target_customer_number + ) { + return true; + } + + return $auth->get_subuser() !== false && $this->isOwnCustomerContext($target_customer_number); + } +} diff --git a/services/nginx/app/tests/Api/CustomerAttributesApiTest.php b/services/nginx/app/tests/Api/CustomerAttributesApiTest.php new file mode 100644 index 00000000..dc2b9d7e --- /dev/null +++ b/services/nginx/app/tests/Api/CustomerAttributesApiTest.php @@ -0,0 +1,74 @@ +createUserSession(['user']); + api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'onlyTankCleaning'); + + $response = api_client()->get( + '/customer/attributes?customer_number=' . (int)$session['user']['customer_number'], + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $attributes = array_map( + static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''), + is_array($response->data()) ? $response->data() : [] + ); + + expect($attributes)->toContain('onlyTankCleaning'); + expect($response->body)->not->toContain('list_customer_attributes'); +}); + +it('keeps customer attribute reads scoped to the authenticated customer', function (): void { + api_test_covers('GET /customer/attributes', 'customer-access'); + + $session = api_fixtures()->createUserSession(['user']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Attribute Customer']); + api_fixtures()->addCustomerAttribute((int)$otherCustomer['id'], 'onlyTankCleaning'); + + $response = api_client()->get( + '/customer/attributes?customer_number=' . (int)$otherCustomer['customer_number'], + $session['headers'] + ); + + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['list_customer_attributes']); +}); + +it('still lets attribute managers read another customer attributes', function (): void { + api_test_covers('GET /customer/attributes', 'permissions'); + + $session = api_fixtures()->createUserSession(['list_customer_attributes']); + $customer = api_fixtures()->createUser(['display_name' => 'Managed Attribute Customer']); + api_fixtures()->addCustomerAttribute((int)$customer['id'], 'onlyTankCleaning'); + + $response = api_client()->get( + '/customer/attributes?customer_number=' . (int)$customer['customer_number'], + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $attributes = array_map( + static fn(array $attribute): string => (string)($attribute['attribute'] ?? ''), + is_array($response->data()) ? $response->data() : [] + ); + + expect($attributes)->toContain('onlyTankCleaning'); +});