Skip to content

Commit 9ca5446

Browse files
committed
expand hlapi permission checks
1 parent 6de69a3 commit 9ca5446

File tree

9 files changed

+325
-41
lines changed

9 files changed

+325
-41
lines changed

phpunit/functional/Glpi/Api/HL/Controller/GraphQLControllerTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
namespace tests\units\Glpi\Api\HL\Controller;
3636

37+
use Glpi\Api\HL\Middleware\InternalAuthMiddleware;
3738
use Glpi\Http\Request;
3839

3940
class GraphQLControllerTest extends \HLAPITestCase
@@ -230,4 +231,58 @@ public function testGetStateVisibilities()
230231
});
231232
});
232233
}
234+
235+
public function testGetDirectlyWithoutRight()
236+
{
237+
/** @var \DBmysql $DB */
238+
global $DB;
239+
240+
$this->assertTrue($DB->insert('glpi_tickets', [
241+
'name' => __FUNCTION__,
242+
'content' => __FUNCTION__,
243+
'entities_id' => $this->getTestRootEntity(true),
244+
]));
245+
$tickets_id = $DB->insertId();
246+
247+
$this->loginWeb();
248+
$this->api->getRouter()->registerAuthMiddleware(new InternalAuthMiddleware());
249+
250+
$_SESSION['glpi_use_mode'] = 2;
251+
252+
// Can see no tickets
253+
$_SESSION['glpiactiveprofile']['ticket'] = 0;
254+
$this->api->call(new Request('POST', '/GraphQL', [], 'query { Ticket { id name } }'), function ($call) {
255+
/** @var \HLAPICallAsserter $call */
256+
$call->response
257+
->status(fn($status) => $this->assertEquals(200, $status))
258+
->jsonContent(function ($content) {
259+
$this->assertEmpty($content['data']['Ticket']);
260+
});
261+
});
262+
263+
// Can only see my own tickets
264+
$_SESSION['glpiactiveprofile']['ticket'] = READ;
265+
266+
$this->api->call(new Request('POST', '/GraphQL', [], 'query { Ticket { id name } }'), function ($call) use ($tickets_id) {
267+
/** @var \HLAPICallAsserter $call */
268+
$call->response
269+
->status(fn($status) => $this->assertEquals(200, $status))
270+
->jsonContent(function ($content) use ($tickets_id) {
271+
$this->assertNotContains($tickets_id, array_column($content['data']['Ticket'], 'id'));
272+
});
273+
});
274+
$this->api->call(new Request('POST', '/GraphQL', [], 'query { Ticket(id: ' . $tickets_id . ') { id name } }'), function ($call) {
275+
/** @var \HLAPICallAsserter $call */
276+
$call->response
277+
->status(fn($status) => $this->assertEquals(200, $status))
278+
->jsonContent(function ($content) {
279+
$this->assertEmpty($content['data']['Ticket']);
280+
});
281+
});
282+
}
283+
284+
public function testGetTicketIndirectlyWithoutRight()
285+
{
286+
//TODO
287+
}
233288
}

src/Change.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,6 @@ class Change extends CommonITILObject
5959
public const IMPACT_MASK_FIELD = 'impact_mask';
6060
public const STATUS_MATRIX_FIELD = 'change_status';
6161

62-
63-
public const READMY = 1;
64-
public const READALL = 1024;
65-
6662
// Specific status for changes
6763
public const EVALUATION = 9;
6864
public const TEST = 11;

src/CommonITILObject.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ abstract class CommonITILObject extends CommonDBTM
105105
public const TIMELINE_ORDER_NATURAL = 'natural';
106106
public const TIMELINE_ORDER_REVERSE = 'reverse';
107107

108+
public const READMY = 1;
109+
public const READALL = 1024;
108110
public const SURVEY = 131072;
109111

110112
abstract public static function getTaskClass();

src/Glpi/Api/HL/Controller/ITILController.php

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
use Calendar;
3939
use Change;
4040
use ChangeTemplate;
41+
use ChangeValidation;
4142
use CommonDBTM;
43+
use CommonITILActor;
4244
use CommonITILObject;
4345
use Entity;
4446
use Glpi\Api\HL\Doc as Doc;
@@ -54,8 +56,10 @@
5456
use PlanningEventCategory;
5557
use PlanningExternalEventTemplate;
5658
use Problem;
59+
use Session;
5760
use Ticket;
5861
use TicketTemplate;
62+
use TicketValidation;
5963
use User;
6064

6165
#[Route(path: '/Assistance', requirements: [
@@ -201,6 +205,138 @@ public static function getRawKnownSchemas(): array
201205
foreach ($itil_types as $itil_type) {
202206
$schemas[$itil_type] = $base_schema;
203207
$schemas[$itil_type]['x-version-introduced'] = '2.0';
208+
209+
$schemas[$itil_type]['x-rights-conditions'] = [
210+
'read' => static function () use ($itil_type) {
211+
if (Session::haveRight($itil_type::$rightname, CommonITILObject::READALL)) {
212+
return true; // Can see all. No extra SQL conditions needed.
213+
}
214+
215+
if ($itil_type !== Ticket::class) {
216+
if (Session::haveRight($itil_type::$rightname, CommonITILObject::READMY)) {
217+
$item = new $itil_type();
218+
$group_table = $item->grouplinkclass::getTable();
219+
$user_table = $item->userlinkclass::getTable();
220+
$criteria = [
221+
'LEFT JOIN' => [
222+
$user_table => [
223+
'ON' => [
224+
$user_table => $itil_type::getForeignKeyField(),
225+
'_' => 'id',
226+
],
227+
],
228+
],
229+
'WHERE' => [
230+
'OR' => [
231+
'_.users_id_recipient' => Session::getLoginUserID(),
232+
$user_table . '.users_id' => Session::getLoginUserID(),
233+
],
234+
],
235+
];
236+
237+
if (!empty($_SESSION['glpigroups'])) {
238+
$criteria['LEFT JOIN'][$group_table] = [
239+
'ON' => [
240+
$group_table => $itil_type::getForeignKeyField(),
241+
'_' => 'id',
242+
],
243+
];
244+
$criteria['WHERE']['OR'][$group_table . '.groups_id'] = $_SESSION['glpigroups'];
245+
}
246+
return $criteria;
247+
}
248+
} else {
249+
// Tickets have expanded permissions
250+
$criteria = [
251+
'LEFT JOIN' => [
252+
'glpi_tickets_users' => [
253+
'ON' => [
254+
'glpi_tickets_users' => Ticket::getForeignKeyField(),
255+
'_' => 'id',
256+
],
257+
],
258+
'glpi_groups_tickets' => [
259+
'ON' => [
260+
'glpi_groups_tickets' => Ticket::getForeignKeyField(),
261+
'_' => 'id',
262+
],
263+
],
264+
],
265+
'WHERE' => ['OR' => []],
266+
];
267+
if (Session::haveRight(Ticket::$rightname, CommonITILObject::READMY)) {
268+
// Permission to see tickets as direct requester, observer or writer
269+
$criteria['WHERE']['OR'][] = [
270+
'_.users_id_recipient' => Session::getLoginUserID(),
271+
[
272+
'AND' => [
273+
'glpi_tickets_users' . '.users_id' => Session::getLoginUserID(),
274+
'glpi_tickets_users' . '.type' => [CommonITILActor::REQUESTER, CommonITILActor::OBSERVER],
275+
],
276+
],
277+
];
278+
}
279+
if (!empty($_SESSION['glpigroups']) && Session::haveRight(Ticket::$rightname, Ticket::READGROUP)) {
280+
// Permission to see tickets as requester or observer group member
281+
$criteria['WHERE']['OR'][] = [
282+
'AND' => [
283+
'glpi_groups_tickets.groups_id' => $_SESSION['glpigroups'],
284+
'glpi_groups_tickets.type' => [CommonITILActor::REQUESTER, CommonITILActor::OBSERVER],
285+
],
286+
];
287+
}
288+
289+
if (Session::haveRight(Ticket::$rightname, Ticket::OWN) || Session::haveRight(Ticket::$rightname, Ticket::READASSIGN)) {
290+
$criteria['WHERE']['OR'][] = [
291+
'AND' => [
292+
'glpi_tickets_users' . '.users_id' => Session::getLoginUserID(),
293+
'glpi_tickets_users' . '.type' => CommonITILActor::ASSIGN,
294+
],
295+
];
296+
}
297+
if (Session::haveRight(Ticket::$rightname, Ticket::READASSIGN)) {
298+
$criteria['WHERE']['OR'][] = [
299+
'AND' => [
300+
'glpi_groups_tickets.groups_id' => $_SESSION['glpigroups'],
301+
'glpi_groups_tickets.type' => CommonITILActor::ASSIGN,
302+
],
303+
];
304+
}
305+
if (Session::haveRight(Ticket::$rightname, Ticket::READNEWTICKET)) {
306+
$criteria['WHERE']['OR'][] = [
307+
'_.status' => CommonITILObject::INCOMING,
308+
];
309+
}
310+
311+
if (
312+
Session::haveRightsOr(
313+
'ticketvalidation',
314+
[\TicketValidation::VALIDATEINCIDENT,
315+
\TicketValidation::VALIDATEREQUEST,
316+
]
317+
)
318+
) {
319+
$criteria['OR'][] = [
320+
'AND' => [
321+
"glpi_ticketvalidations.itemtype_target" => User::class,
322+
"glpi_ticketvalidations.items_id_target" => Session::getLoginUserID(),
323+
],
324+
];
325+
if (count($_SESSION['glpigroups'])) {
326+
$criteria['OR'][] = [
327+
'AND' => [
328+
"glpi_ticketvalidations.itemtype_target" => Group::class,
329+
"glpi_ticketvalidations.items_id_target" => $_SESSION['glpigroups'],
330+
],
331+
];
332+
}
333+
}
334+
return empty($criteria['WHERE']['OR']) ? false : $criteria;
335+
}
336+
return false; // Cannot see anything.
337+
},
338+
];
339+
204340
if ($itil_type === Ticket::class) {
205341
$schemas[$itil_type]['properties']['type'] = [
206342
'type' => Doc\Schema::TYPE_INTEGER,
@@ -469,12 +605,36 @@ public static function getRawKnownSchemas(): array
469605

470606
$schemas['TicketValidation'] = $base_validation_schema;
471607
$schemas['TicketValidation']['x-version-introduced'] = '2.0';
472-
$schemas['TicketValidation']['x-itemtype'] = \TicketValidation::class;
608+
$schemas['TicketValidation']['x-itemtype'] = TicketValidation::class;
609+
$schemas['TicketValidation']['x-rights-conditions'] = [
610+
'read' => static function () {
611+
return Session::haveRightsOr(
612+
TicketValidation::$rightname,
613+
array_merge(
614+
TicketValidation::getCreateRights(),
615+
TicketValidation::getValidateRights(),
616+
TicketValidation::getPurgeRights()
617+
)
618+
);
619+
},
620+
];
473621
$schemas['TicketValidation']['properties'][Ticket::getForeignKeyField()] = ['type' => Doc\Schema::TYPE_INTEGER, 'format' => Doc\Schema::FORMAT_INTEGER_INT64];
474622

475623
$schemas['ChangeValidation'] = $base_validation_schema;
476624
$schemas['ChangeValidation']['x-version-introduced'] = '2.0';
477-
$schemas['ChangeValidation']['x-itemtype'] = \ChangeValidation::class;
625+
$schemas['ChangeValidation']['x-itemtype'] = ChangeValidation::class;
626+
$schemas['ChangeValidation']['x-rights-conditions'] = [
627+
'read' => static function () {
628+
return Session::haveRightsOr(
629+
ChangeValidation::$rightname,
630+
array_merge(
631+
ChangeValidation::getCreateRights(),
632+
ChangeValidation::getValidateRights(),
633+
ChangeValidation::getPurgeRights()
634+
)
635+
);
636+
},
637+
];
478638
$schemas['ChangeValidation']['properties'][Change::getForeignKeyField()] = ['type' => Doc\Schema::TYPE_INTEGER, 'format' => Doc\Schema::FORMAT_INTEGER_INT64];
479639

480640
$schemas['RecurringTicket'] = [

src/Glpi/Api/HL/GraphQL.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
namespace Glpi\Api\HL;
3737

3838
use Glpi\Http\Request;
39+
use GraphQL\Error\Error;
3940
use GraphQL\Type\Definition\ResolveInfo;
4041
use GraphQL\Utils\BuildSchema;
4142

@@ -55,6 +56,7 @@ public static function processRequest(Request $request): array
5556
$query = (string) $request->getBody();
5657
$generator = new GraphQLGenerator($api_version);
5758
$schema_str = $generator->getSchema();
59+
5860
try {
5961
$result = \GraphQL\GraphQL::executeQuery(
6062
schema: BuildSchema::build($schema_str),
@@ -75,10 +77,17 @@ public static function processRequest(Request $request): array
7577
$completed_schema = self::expandSchemaFromRequestedFields($schema, $field_selection, null, $api_version);
7678

7779
if (isset($args['id'])) {
78-
$result = json_decode(Search::getOneBySchema($completed_schema, ['id' => $args['id']], [])->getBody(), true);
79-
return [$result];
80+
$result = Search::getOneBySchema($completed_schema, ['id' => $args['id']], []);
81+
if ($result->getStatusCode() !== 200) {
82+
throw new Error($result->getBody());
83+
}
84+
return [json_decode($result->getBody(), true)];
85+
}
86+
$result = Search::searchBySchema($completed_schema, $args);
87+
if ($result->getStatusCode() !== 200) {
88+
throw new Error($result->getBody());
8089
}
81-
return json_decode(Search::searchBySchema($completed_schema, $args)->getBody(), true);
90+
return json_decode($result->getBody(), true);
8291
}
8392

8493
return $source[$info->fieldName] ?? null;
@@ -95,6 +104,10 @@ private static function expandSchemaFromRequestedFields(array $schema, array $fi
95104
{
96105
$is_schema_array = array_key_exists('items', $schema) && !array_key_exists('properties', $schema);
97106
$itemtype = self::getSchemaItemtype($schema, $api_version);
107+
if (is_subclass_of($itemtype, \CommonDBTM::class) && !$itemtype::canView()) {
108+
// Cannot view this itemtype so we shouldn't expand it further
109+
return $schema;
110+
}
98111
if ($is_schema_array) {
99112
$properties = $schema['items']['properties'];
100113
} else {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/**
4+
* ---------------------------------------------------------------------
5+
*
6+
* GLPI - Gestionnaire Libre de Parc Informatique
7+
*
8+
* http://glpi-project.org
9+
*
10+
* @copyright 2015-2025 Teclib' and contributors.
11+
* @licence https://www.gnu.org/licenses/gpl-3.0.html
12+
*
13+
* ---------------------------------------------------------------------
14+
*
15+
* LICENSE
16+
*
17+
* This file is part of GLPI.
18+
*
19+
* This program is free software: you can redistribute it and/or modify
20+
* it under the terms of the GNU General Public License as published by
21+
* the Free Software Foundation, either version 3 of the License, or
22+
* (at your option) any later version.
23+
*
24+
* This program is distributed in the hope that it will be useful,
25+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
26+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
27+
* GNU General Public License for more details.
28+
*
29+
* You should have received a copy of the GNU General Public License
30+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
31+
*
32+
* ---------------------------------------------------------------------
33+
*/
34+
35+
namespace Glpi\Api\HL;
36+
37+
/**
38+
* An exception thrown specifically when a right condition is known to be a failure without needing to perform any SQL query.
39+
* For example, if a user has no Ticket permission then we know they cannot read any Tickets.
40+
* Instead of performing a SQL query with a condition that always resolve to no records, we can fail-fast and use an empty iterator result.
41+
*/
42+
class RightConditionNotMetException extends APIException {}

0 commit comments

Comments
 (0)