diff --git a/CHANGELOG.md b/CHANGELOG.md index 80527b8c1a5..64b014eacd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The present file will list all changes made to the project; according to the ## [11.0.2] unreleased ### Added +- High-Level API endpoints for configuration settings `/Setup/Config/{context}/{name}`. ### Changed - Added High-Level API version 2.1. Make sure you are pinning your requests to a specific version (Ex: `/api.php/v2.0`) if needed to exclude endpoints/properties added in later versions. See version pinning in the getting started documentation `/api.php/getting-started`. diff --git a/src/Glpi/Api/HL/Controller/SetupController.php b/src/Glpi/Api/HL/Controller/SetupController.php index dc55400a9cf..702510d8ada 100644 --- a/src/Glpi/Api/HL/Controller/SetupController.php +++ b/src/Glpi/Api/HL/Controller/SetupController.php @@ -37,6 +37,7 @@ use AuthLDAP; use CommonDBTM; +use Config; use Glpi\Api\HL\Doc as Doc; use Glpi\Api\HL\Middleware\ResultFormatterMiddleware; use Glpi\Api\HL\ResourceAccessor; @@ -51,6 +52,8 @@ final class SetupController extends AbstractController { public static function getRawKnownSchemas(): array { + global $DB; + return [ 'LDAPDirectory' => [ 'x-version-introduced' => '2.0', @@ -104,6 +107,44 @@ public static function getRawKnownSchemas(): array ], ], ], + 'Config' => [ + 'x-version-introduced' => '2.1', + 'x-itemtype' => Config::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'context' => ['type' => Doc\Schema::TYPE_STRING], + 'name' => ['type' => Doc\Schema::TYPE_STRING], + 'value' => ['type' => Doc\Schema::TYPE_STRING], + ], + 'x-rights-conditions' => [ + 'read' => static function () use ($DB) { + // Make a SQL request to get all config items so we can check which are undisclosed + // We are using safe IDs rather than undisclosed IDs to avoid issues with concurrent modifications + // We cannot reliably lock the table due to the fact that the DB connection here may differ from the one used to perform the actual read in the Search code + $disclosed_ids = []; + + $it = $DB->request([ + 'SELECT' => ['id', 'context', 'name'], + 'FROM' => 'glpi_configs', + ]); + $test_configs = []; + foreach ($it as $row) { + $test_configs[] = $row + ['value' => 'dummy']; + } + foreach ($test_configs as $f) { + if (!self::isUndisclosedConfig($f['context'], $f['name'])) { + $disclosed_ids[] = $f['id']; + } + } + return ['WHERE' => ['_.id' => $disclosed_ids]]; + }, + ], + ], ]; } @@ -118,6 +159,7 @@ public static function getSetupTypes(bool $types_only = true): array if ($types === null) { $types = [ 'LDAPDirectory' => AuthLDAP::getTypeName(1), + // Do not add Config here as it is handled specially ]; } return $types_only ? array_keys($types) : $types; @@ -212,4 +254,107 @@ public function deleteItem(Request $request): Response $itemtype = $request->getAttribute('itemtype'); return ResourceAccessor::deleteBySchema($this->getKnownSchema($itemtype, $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); } + + private static function isUndisclosedConfig(string $context, string $name): bool + { + $f = ['context' => $context, 'name' => $name, 'value' => 'dummy']; + Config::unsetUndisclosedFields($f); + return !array_key_exists('value', $f); + } + + #[Route(path: '/Config/{context}/{name}', methods: ['PATCH'], requirements: [ + 'context' => '\w+', + 'name' => '\w+', + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\UpdateRoute(schema_name: 'Config')] + public function setConfigValue(Request $request): Response + { + // Skip using ResourceAccessor given the particularities of Config + if (!Config::canUpdate()) { + return AbstractController::getAccessDeniedErrorResponse(); + } + $context = $request->getAttribute('context'); + $name = $request->getAttribute('name'); + $value = $request->getParameter('value'); + Config::setConfigurationValues($context, [$name => $value]); + // Return the updated config + if (self::isUndisclosedConfig($context, $name)) { + // If the field is undisclosed, only return a 204 to indicate success without revealing the value + return new JSONResponse(null, 204); + } + return new JSONResponse([ + 'context' => $context, + 'name' => $name, + 'value' => Config::getConfigurationValue($context, $name), + ]); + } + + #[Route(path: '/Config', methods: ['GET'], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\SearchRoute(schema_name: 'Config')] + public function searchConfigValues(Request $request): Response + { + return ResourceAccessor::searchBySchema($this->getKnownSchema('Config', $this->getAPIVersion($request)), $request->getParameters()); + } + + #[Route(path: '/Config/{context}', methods: ['GET'], requirements: [ + 'context' => '\w+', + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\SearchRoute(schema_name: 'Config')] + public function searchConfigValuesByContext(Request $request): Response + { + $filters = $request->hasParameter('filter') ? $request->getParameter('filter') : ''; + $filters .= ';context==' . $request->getAttribute('context'); + $request->setParameter('filter', $filters); + return ResourceAccessor::searchBySchema($this->getKnownSchema('Config', $this->getAPIVersion($request)), $request->getParameters()); + } + + #[Route(path: '/Config/{context}/{name}', methods: ['GET'], requirements: [ + 'context' => '\w+', + 'name' => '\w+', + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\GetRoute(schema_name: 'Config')] + public function getConfigValue(Request $request): Response + { + // Skip using ResourceAccessor given the particularities of Config + $context = $request->getAttribute('context'); + $name = $request->getAttribute('name'); + $config = new Config(); + if (!$config->getFromDBByCrit(['context' => $context, 'name' => $name,])) { + return AbstractController::getNotFoundErrorResponse(); + } + if (self::isUndisclosedConfig($context, $name) || !$config->can($config->getID(), READ)) { + return AbstractController::getAccessDeniedErrorResponse(); + } + return new JSONResponse([ + 'context' => $context, + 'name' => $name, + 'value' => Config::getConfigurationValue($context, $name), + ]); + } + + #[Route(path: '/Config/{context}/{name}', methods: ['DELETE'], requirements: [ + 'context' => '\w+', + 'name' => '\w+', + ])] + #[RouteVersion(introduced: '2.1')] + #[Doc\DeleteRoute(schema_name: 'Config')] + public function deleteConfigValue(Request $request): Response + { + // Skip using ResourceAccessor given the particularities of Config + if (!Config::canUpdate()) { + return AbstractController::getAccessDeniedErrorResponse(); + } + $context = $request->getAttribute('context'); + $name = $request->getAttribute('name'); + $config = new Config(); + if (!$config->getFromDBByCrit(['context' => $context, 'name' => $name])) { + return AbstractController::getNotFoundErrorResponse(); + } + Config::deleteConfigurationValues($context, [$name]); + return new JSONResponse(null, 204); + } } diff --git a/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php b/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php index 965034abfad..71cac9227fc 100644 --- a/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php +++ b/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php @@ -35,6 +35,7 @@ namespace tests\units\Glpi\Api\HL\Controller; use AuthLDAP; +use Config; use Glpi\Api\HL\Middleware\InternalAuthMiddleware; use Glpi\Http\Request; @@ -156,4 +157,173 @@ public function testCRUDNoRights() }); }); } + + public function testCRUDConfigValues() + { + $this->loginWeb(); + + $this->api->getRouter()->registerAuthMiddleware(new InternalAuthMiddleware()); + // Can get a config value + $this->api->call(new Request('GET', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEquals('priority_1', $content['name']); + $this->assertEquals('core', $content['context']); + $this->assertEquals('#fff2f2', $content['value']); + }); + }); + + // Get an undisclosable config value + Config::setConfigurationValues('core', ['smtp_passwd' => 'test']); + $this->api->call(new Request('GET', '/Setup/Config/core/smtp_passwd'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isAccessDenied(); + }); + + // Not existing config value + $this->api->call(new Request('GET', '/Setup/Config/core/notrealconfig'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + + // Can update a config value + $request = new Request('PATCH', '/Setup/Config/core/priority_1'); + $request->setParameter('value', '#ffffff'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEquals('priority_1', $content['name']); + $this->assertEquals('core', $content['context']); + $this->assertEquals('#ffffff', $content['value']); + }); + }); + $this->api->call(new Request('GET', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEquals('priority_1', $content['name']); + $this->assertEquals('core', $content['context']); + $this->assertEquals('#ffffff', $content['value']); + }); + }); + + // Can update an undisclosable config value + $request = new Request('PATCH', '/Setup/Config/core/smtp_passwd'); + $request->setParameter('value', 'newtest'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->status(static fn($status) => $status === 204); + }); + + // Can delete a config value + $this->api->call(new Request('DELETE', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->status(static fn($status) => $status === 204); + }); + $this->api->call(new Request('GET', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + + // Can delete an undisclosable config value + $this->api->call(new Request('DELETE', '/Setup/Config/core/smtp_passwd'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->status(static fn($status) => $status === 204); + }); + + // Can get a config value using GraphQL + $request = new Request('POST', '/GraphQL', [], 'query { Config(filter: "context==core;name==priority_2") { context, name, value } }'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertArrayHasKey('data', $content); + $this->assertArrayHasKey('Config', $content['data']); + $this->assertCount(1, $content['data']['Config']); + $config = $content['data']['Config'][0]; + $this->assertEquals('core', $config['context']); + $this->assertEquals('priority_2', $config['name']); + $this->assertEquals('#ffe0e0', $config['value']); + }); + }); + + // Cannot get an undisclosable config value using GraphQL + $request = new Request('POST', '/GraphQL', [], 'query { Config(filter: "context==core;name==smtp_passwd") { context, name, value } }'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertArrayHasKey('data', $content); + $this->assertArrayHasKey('Config', $content['data']); + $this->assertEmpty($content['data']['Config']); + }); + }); + + // Can search config values + $request = new Request('GET', '/Setup/Config'); + $request->setParameter('filter', 'name==priority_2'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertCount(1, $content); + $config = $content[0]; + $this->assertEquals('core', $config['context']); + $this->assertEquals('priority_2', $config['name']); + $this->assertEquals('#ffe0e0', $config['value']); + }); + }); + + // Cannot search undisclosable config values + $request = new Request('GET', '/Setup/Config'); + $request->setParameter('filter', 'name==smtp_passwd'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEmpty($content); + }); + }); + } + + public function testConfigNotIn2_0() + { + $this->login(); + + $v2_api = $this->api->withVersion('2.0.0'); + $v2_api->call(new Request('GET', '/Setup/Config/core/test'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + $v2_api->call(new Request('PATCH', '/Setup/Config/core/test'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + $v2_api->call(new Request('DELETE', '/Setup/Config/core/test'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + + $request = new Request('POST', '/GraphQL', [], 'query { Config(filter: "context==core;name==test") { context, name, value } }'); + $v2_api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertArrayHasKey('errors', $content); + }); + }); + } }