Skip to content
Open
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
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [unreleased] -
## [unreleased]

### Added

- Manual LDAP to GLPI inventory synchronization from a sync filter, with a dry-run preview and an execute mode (Computer itemtype)

### Fixed

- Read the correct `deref_option` field from the LDAP directory configuration
- Prevent JSON injection from LDAP attribute values when building the inventory payload
- Prevent mass assignment when creating an AuthLDAP / SyncFilter relation
68 changes: 68 additions & 0 deletions ajax/syncexecute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

/**
* -------------------------------------------------------------------------
* advancedldap plugin for GLPI
* -------------------------------------------------------------------------
*
* LICENSE
*
* This file is part of advancedldap.
*
* AdvancedLDAP is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* AdvancedLDAP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with AdvancedLDAP. If not, see <http://www.gnu.org/licenses/>.
* -------------------------------------------------------------------------
* @copyright Copyright (C) 2018-2023 by Teclib'.
* @license GPLv3+ https://www.gnu.org/licenses/gpl-3.0.html
* @link https://services.glpi-network.com
* -------------------------------------------------------------------------
*/

use Glpi\Exception\Http\BadRequestHttpException;
use GlpiPlugin\Advancedldap\Inventory\LdapSyncExecutor;
use GlpiPlugin\Advancedldap\SyncFilter;

use function Safe\json_encode;
use function Safe\session_write_close;

header('Content-Type: application/json');

Session::checkLoginUser();

$action = $_POST['action'] ?? null;
$raw_syncfilters_id = $_POST['syncfilters_id'] ?? 0;
$syncfilters_id = is_numeric($raw_syncfilters_id) ? (int) $raw_syncfilters_id : 0;

$syncfilter = new SyncFilter();
if ($syncfilters_id <= 0 || !$syncfilter->getFromDB($syncfilters_id)) {
throw new BadRequestHttpException('Invalid SyncFilter');
}

$required_right = ($action === 'execute') ? UPDATE : READ;
$syncfilter->check($syncfilters_id, $required_right);

$executor = new LdapSyncExecutor();

switch ($action) {
case 'execute':
session_write_close();
$results = $executor->executeSingleFilter($syncfilter);
echo json_encode(['success' => true, 'results' => $results]);
break;
case 'dry_run':
$preview = $executor->previewSyncFilter($syncfilter);
echo json_encode(['success' => true, 'preview' => $preview]);
break;
default:
throw new BadRequestHttpException('Unknown action');
}
4 changes: 1 addition & 3 deletions front/authldapsyncfilter.form.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@
if (isset($_POST["add"])) {
$input = $_POST;
$relation->check(-1, CREATE, $input); // @phpstan-ignore argument.type ($_POST keys are always strings)
if ($input !== null) {
$relation->add($input);
}
$relation->add($input); // @phpstan-ignore argument.type ($_POST keys are always strings)
}

Html::back();
3 changes: 3 additions & 0 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ function plugin_init_advancedldap(): void
/** @var array<string, array<string, string|array<int|string, string>>> $PLUGIN_HOOKS */
global $PLUGIN_HOOKS;

// Note: Hooks::CSRF_COMPLIANT is deprecated since GLPI 11.0 — CSRF is enforced
// automatically by the CheckCsrfListener middleware, so it is intentionally not declared.

$PLUGIN_HOOKS[Hooks::ITEM_PURGE]['advancedldap'] = [
AuthLDAP::class => 'plugin_advancedldap_item_purge',
SyncFilter::class => 'plugin_advancedldap_item_purge',
Expand Down
4 changes: 4 additions & 0 deletions src/AuthLdapSyncFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ public function prepareInputForAdd($input)
return false;
}

$authldap_fk = getForeignKeyFieldForItemType(AuthLDAP::class);
$syncfilter_fk = getForeignKeyFieldForItemType(SyncFilter::class);
$syncfilter_value = $input[$syncfilter_fk] ?? 0;
$syncfilters_id = is_numeric($syncfilter_value) ? (int) $syncfilter_value : 0;
Expand All @@ -380,6 +381,9 @@ public function prepareInputForAdd($input)
$this->deleteExistingLinkForSyncFilter($syncfilters_id);
}

// N'autoriser que les deux clés étrangères de la relation (anti mass-assignment)
$input = array_intersect_key($input, array_flip([$authldap_fk, $syncfilter_fk]));

return parent::prepareInputForAdd($input);
}

Expand Down
166 changes: 138 additions & 28 deletions src/Inventory/LdapSyncExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

namespace GlpiPlugin\Advancedldap\Inventory;

use Agent;
use Throwable;
use LDAP\Result;
use AuthLDAP;
Expand All @@ -50,14 +51,7 @@
use function Safe\preg_replace_callback;

/**
* Orchestrator for LDAP to GLPI inventory synchronization.
*
* Responsibilities:
* 1. Retrieve active SyncFilters for a given LDAP connection
* 2. Perform LDAP searches using filter criteria
* 3. Instantiate the appropriate InventoryBuilder based on itemtype
* 4. Inject built inventory JSON into Glpi\Inventory\Inventory
* 5. Log results
* Orchestrates LDAP-to-GLPI inventory synchronization for a SyncFilter.
*/
class LdapSyncExecutor
{
Expand Down Expand Up @@ -99,6 +93,96 @@ public function executeForConnection(AuthLDAP $authldap): array
return $this->results;
}

/**
* Execute synchronization for a single SyncFilter using its linked AuthLDAP.
*
* @param SyncFilter $syncfilter The sync filter to execute
*
* @return array{created: int, updated: int, errors: int, skipped: int} Sync results
*/
public function executeSingleFilter(SyncFilter $syncfilter): array
{
$this->resetResults();

$authldap = $syncfilter->getLinkedAuthLdap();
if (!$authldap instanceof AuthLDAP) {
Toolbox::logDebug(sprintf(
'AdvancedLDAP: SyncFilter %d has no linked AuthLDAP, nothing to synchronize',
$syncfilter->getID(),
));
$this->results['skipped']++;
return $this->results;
}

$this->executeSyncFilter($authldap, $syncfilter);

return $this->results;
}

/**
* Preview synchronization for a single SyncFilter without injecting data.
*
* @param SyncFilter $syncfilter The sync filter to preview
*
* @return array{first_entry: array<string, mixed>|null, would_create: int, would_update: int, total: int}
*/
public function previewSyncFilter(SyncFilter $syncfilter): array
{
$authldap = $syncfilter->getLinkedAuthLdap();
$result = ['first_entry' => null, 'would_create' => 0, 'would_update' => 0, 'total' => 0];

if (!$authldap instanceof AuthLDAP) {
Toolbox::logDebug(sprintf(
'AdvancedLDAP: SyncFilter %d has no linked AuthLDAP, cannot preview',
$syncfilter->getID(),
));
return $result;
}

$builder = $this->loadBuilderMapping($syncfilter);
if (!$builder instanceof AbstractBuilderMapping) {
Toolbox::logDebug(sprintf(
'AdvancedLDAP: SyncFilter %d has no BuilderMapping, cannot preview',
$syncfilter->getID(),
));
return $result;
}

$sections = $builder->getAllSections();
$ldap_attrs = $this->extractLdapAttributes($sections);

$ldap_entries = $this->performLdapSearch($authldap, $syncfilter, $ldap_attrs);

if (!is_array($ldap_entries) || $ldap_entries === []) {
return $result;
}

foreach ($ldap_entries as $index => $ldap_entry) {
$inventory_data = $this->buildInventoryJson($sections, $ldap_entry, $syncfilter);
if ($inventory_data === null) {
continue;
}

$result['total']++;

if ($index === 0) {
$result['first_entry'] = $inventory_data;
}

$deviceid = isset($inventory_data['deviceid']) && is_string($inventory_data['deviceid'])
? $inventory_data['deviceid'] : '';

$agent = new Agent();
if ($deviceid !== '' && $agent->getFromDBByCrit(['deviceid' => $deviceid])) {
$result['would_update']++;
} else {
$result['would_create']++;
}
}

return $result;
}

/**
* Execute synchronization for a single SyncFilter.
*
Expand Down Expand Up @@ -325,27 +409,48 @@ protected function removeEmptyKeys(array $data): array
*/
protected function replacePlaceholders(array $data, array $ldap_entry): array
{
$json_string = json_encode($data, JSON_UNESCAPED_UNICODE);

$json_string = preg_replace_callback(
self::PLACEHOLDER_PATTERN,
function ($matches) use ($ldap_entry) {
$attr_raw = $matches[1] ?? '';
$attr_name = strtolower(is_string($attr_raw) ? $attr_raw : '');
$value = $this->getLdapValue($ldap_entry, $attr_name);
// Escape JSON special characters to prevent invalid JSON
return addcslashes($value, "\"\\/\n\r\t");
},
$json_string,
);

$decoded = json_decode($json_string, true);

// Substitute directly within the PHP structure (string leaves only). LDAP values
// never touch the JSON-string layer raw, so they cannot break out of their context
// or inject arbitrary keys into the inventory payload.
/** @var array<string, mixed> $result */
$result = is_array($decoded) ? $decoded : [];
$result = $this->substitutePlaceholders($data, $ldap_entry);
return $result;
}

/**
* Recursively replace {{ ldap.xxx }} placeholders inside string leaves of a structure.
*
* @param mixed $value The value to process (array, string or scalar)
* @param array<string, mixed> $ldap_entry The LDAP entry data
*
* @return mixed The value with placeholders substituted
*/
private function substitutePlaceholders(mixed $value, array $ldap_entry): mixed
{
if (is_array($value)) {
$result = [];
foreach ($value as $key => $item) {
$result[$key] = $this->substitutePlaceholders($item, $ldap_entry);
}

return $result;
}

if (is_string($value)) {
return preg_replace_callback(
self::PLACEHOLDER_PATTERN,
function ($matches) use ($ldap_entry) {
$attr_raw = $matches[1] ?? '';
$attr_name = strtolower(is_string($attr_raw) ? $attr_raw : '');
return $this->getLdapValue($ldap_entry, $attr_name);
},
$value,
);
}

return $value;
}

/**
* Get a value from LDAP entry, handling the LDAP array structure.
*
Expand Down Expand Up @@ -572,11 +677,16 @@ private function injectInventory(array $inventory_data): void
return;
}

$agent = new Agent();
$agent_exists = $deviceid !== 'unknown' && $agent->getFromDBByCrit(['deviceid' => $deviceid]);

$inventory->doInventory();

// TODO: Determine if item was created or updated based on Inventory results
// For now, assume created
$this->results['created']++;
if ($agent_exists) {
$this->results['updated']++;
} else {
$this->results['created']++;
}
}

/**
Expand Down
Loading