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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
145 changes: 145 additions & 0 deletions src/Glpi/Api/HL/Controller/SetupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -51,6 +52,8 @@ final class SetupController extends AbstractController
{
public static function getRawKnownSchemas(): array
{
global $DB;

return [
'LDAPDirectory' => [
'x-version-introduced' => '2.0',
Expand Down Expand Up @@ -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]];
},
],
],
];
}

Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
170 changes: 170 additions & 0 deletions tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
});
});
}
}
Loading