From 1ff8778f042bfce3624f06f4f2fec9a4a90d8ec5 Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Fri, 16 Oct 2026 07:41:32 -0500 Subject: [PATCH 1/2] Add directorygroups plugin, a renamed port of ou Namespace FOG\Plugins\DirectoryGroups, node and permission 'directorygroups', tables directoryOUs / directoryOUAssoc with do*/doa* columns plus a nullable doDomainID. The host select is posted as 'directorygroups' instead of 'ou'. The ou plugin is untouched. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- directorygroups/config/plugin.config.php | 32 ++ directorygroups/js/fog.directorygroups.add.js | 3 + .../js/fog.directorygroups.edit.js | 42 ++ .../js/fog.directorygroups.export.js | 9 + .../js/fog.directorygroups.host.edit.js | 11 + .../js/fog.directorygroups.import.js | 3 + .../js/fog.directorygroups.list.js | 18 + .../js/fog.directorygroups.report.file.js | 17 + .../src/Hooks/AddDirectoryGroupsAPI.php | 79 +++ .../src/Hooks/AddDirectoryGroupsHost.php | 467 ++++++++++++++++++ .../src/Hooks/AddDirectoryGroupsJS.php | 80 +++ .../src/Hooks/AddDirectoryGroupsMenuItem.php | 154 ++++++ .../src/Hooks/AddDirectoryGroupsType.php | 65 +++ .../src/Hooks/DirectoryGroupsChangeItems.php | 106 ++++ .../Hooks/DirectoryGroupsDeleteMassItems.php | 89 ++++ directorygroups/src/Items/DirectoryGroups.php | 134 +++++ .../src/Items/DirectoryGroupsAssociation.php | 96 ++++ .../DirectoryGroupsAssociationManager.php | 84 ++++ .../src/Managers/DirectoryGroupsManager.php | 169 +++++++ .../src/Pages/DirectoryGroupsManagement.php | 425 ++++++++++++++++ .../src/Reports/DirectoryGroups_Report.php | 80 +++ tests/fk-writes-are-null-not-zero.test.php | 3 + .../foreign-keys-applied-per-plugin.test.php | 1 + tests/group-push-became-mass-edit.test.php | 10 + .../plugin-id-columns-are-classified.test.php | 3 + 26 files changed, 2182 insertions(+), 1 deletion(-) create mode 100644 directorygroups/config/plugin.config.php create mode 100644 directorygroups/js/fog.directorygroups.add.js create mode 100644 directorygroups/js/fog.directorygroups.edit.js create mode 100644 directorygroups/js/fog.directorygroups.export.js create mode 100644 directorygroups/js/fog.directorygroups.host.edit.js create mode 100644 directorygroups/js/fog.directorygroups.import.js create mode 100644 directorygroups/js/fog.directorygroups.list.js create mode 100644 directorygroups/js/fog.directorygroups.report.file.js create mode 100644 directorygroups/src/Hooks/AddDirectoryGroupsAPI.php create mode 100644 directorygroups/src/Hooks/AddDirectoryGroupsHost.php create mode 100644 directorygroups/src/Hooks/AddDirectoryGroupsJS.php create mode 100644 directorygroups/src/Hooks/AddDirectoryGroupsMenuItem.php create mode 100644 directorygroups/src/Hooks/AddDirectoryGroupsType.php create mode 100644 directorygroups/src/Hooks/DirectoryGroupsChangeItems.php create mode 100644 directorygroups/src/Hooks/DirectoryGroupsDeleteMassItems.php create mode 100644 directorygroups/src/Items/DirectoryGroups.php create mode 100644 directorygroups/src/Items/DirectoryGroupsAssociation.php create mode 100644 directorygroups/src/Managers/DirectoryGroupsAssociationManager.php create mode 100644 directorygroups/src/Managers/DirectoryGroupsManager.php create mode 100644 directorygroups/src/Pages/DirectoryGroupsManagement.php create mode 100644 directorygroups/src/Reports/DirectoryGroups_Report.php diff --git a/README.md b/README.md index 4dbd3808..aaca60ac 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The plugins that ship with [FOG Project](https://github.com/FOGProject/fogproject). One repository, one release, per-plugin versions in each manifest. -This is the source of truth for the 15 bundled plugins. The FOG build pins a +This is the source of truth for the 16 bundled plugins. The FOG build pins a revision of this repository and lays its contents into `packages/web/lib/plugins/` in the tarball. @@ -87,6 +87,7 @@ the rest. | Plugin | Version | What it does | |---|---|---| | `capone` | 1.6.0 | Automation plugin — image a host straight from PXE without registering it | +| `directorygroups` | 1.6.0 | Group hosts by Active Directory OU, across one or more domains | | `helloworld` | 1.6.0 | Skeleton example: config, model, manager, page, hooks, JS | | `ldap` | 1.6.0 | Authenticate FOG users against an LDAP or AD directory | | `location` | 1.6.0 | Serve images from the storage node nearest a host — multi-site installs | diff --git a/directorygroups/config/plugin.config.php b/directorygroups/config/plugin.config.php new file mode 100644 index 00000000..27bc6bda --- /dev/null +++ b/directorygroups/config/plugin.config.php @@ -0,0 +1,32 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +/** + * Plugin configuration file. + * + * @category Config + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +$fog_plugin = []; +$fog_plugin['name'] = 'directorygroups'; +$fog_plugin['description'] = _( + 'Directory Groups: group hosts by Active Directory OU, across one or ' + . 'more domains.' +); +$fog_plugin['menuicon'] = 'fas fa-folder-tree fa-fw'; +$fog_plugin['version'] = '1.6.0'; +$fog_plugin['fog_min'] = '1.6.0'; +$fog_plugin['author'] = 'Tom Elliott'; +$fog_plugin['homepage'] = 'https://fogproject.org'; diff --git a/directorygroups/js/fog.directorygroups.add.js b/directorygroups/js/fog.directorygroups.add.js new file mode 100644 index 00000000..fcc72683 --- /dev/null +++ b/directorygroups/js/fog.directorygroups.add.js @@ -0,0 +1,3 @@ +(function($) { + $('#directorygroups-create-form').wireCreateForm(); +})(jQuery); diff --git a/directorygroups/js/fog.directorygroups.edit.js b/directorygroups/js/fog.directorygroups.edit.js new file mode 100644 index 00000000..07e0681c --- /dev/null +++ b/directorygroups/js/fog.directorygroups.edit.js @@ -0,0 +1,42 @@ +$(function() { + // --------------------------------------------------------------- + // GENERAL TAB + $.registerGeneralTab({ + nameInputSel: '#ou', + formSel: '#directorygroups-general-form' + }); + + // --------------------------------------------------------------- + // HOST TAB + var directorygroupsHostsTable = $.registerAssociationTab({ + slug: 'directorygroups-host', + item: 'host', + sub: 'getHostsList', + columns: [ + {data: 'name'}, + {data: 'association'} + ], + columnDefs: [ + { + responsivePriority: -1, + // Aisle 097: this tab supplies its own column-0 render, so it + // bypasses the server-side escape in FOGController's mainLink + // formatter and concatenated the name in raw. Mitigated today + // only by isHostnameSafe() on the write side -- do not rely on + // that staying the sole gate. + render: function(data, type, row) { + return '' + + $.escapeHtml(data) + + ''; + }, + targets: 0 + } + ] + }); + + if (Common.search && Common.search.length > 0) { + directorygroupsHostsTable.search(Common.search).draw(); + } +}); diff --git a/directorygroups/js/fog.directorygroups.export.js b/directorygroups/js/fog.directorygroups.export.js new file mode 100644 index 00000000..7d328cfa --- /dev/null +++ b/directorygroups/js/fog.directorygroups.export.js @@ -0,0 +1,9 @@ +(function($) { + $('#directorygroups-export-table').registerExportTable([ + {data: 'name'}, + {data: 'description', visible: false}, + {data: 'createdBy', visible: false}, + {data: 'createdTime', visible: false}, + {data: 'ou'} + ]); +})(jQuery); diff --git a/directorygroups/js/fog.directorygroups.host.edit.js b/directorygroups/js/fog.directorygroups.host.edit.js new file mode 100644 index 00000000..16eb8e87 --- /dev/null +++ b/directorygroups/js/fog.directorygroups.host.edit.js @@ -0,0 +1,11 @@ +(function($) { + // The whole tab -- Update wiring plus the create-and-associate button and + // modal -- is one shared helper (fog.common.js). These nine plugin tabs are + // the same card with a different noun, and each used to carry its own copy + // of the submit wiring. + $.registerSelectTab({ + slug: 'host-directorygroups', + send: 'directorygroups-send', + node: 'directorygroups' + }); +})(jQuery); diff --git a/directorygroups/js/fog.directorygroups.import.js b/directorygroups/js/fog.directorygroups.import.js new file mode 100644 index 00000000..1051675f --- /dev/null +++ b/directorygroups/js/fog.directorygroups.import.js @@ -0,0 +1,3 @@ +// Import form wiring is shared: wireImportForm() in fog.common.js activates for +// any page rendering #import-form/#import-send. Kept as a stub so the JS loader +// serves this file rather than falling back to fog.ou.list.js. diff --git a/directorygroups/js/fog.directorygroups.list.js b/directorygroups/js/fog.directorygroups.list.js new file mode 100644 index 00000000..bce25411 --- /dev/null +++ b/directorygroups/js/fog.directorygroups.list.js @@ -0,0 +1,18 @@ +(function($) { + $.registerListPage({ + order: [ + [0, 'asc'] + ], + columns: [ + {data: 'mainlink'}, + {data: 'ou'} + ], + rowId: 'id', + columnDefs: [ + { + responsivePriority: -1, + targets: 0 + } + ] + }); +})(jQuery); diff --git a/directorygroups/js/fog.directorygroups.report.file.js b/directorygroups/js/fog.directorygroups.report.file.js new file mode 100644 index 00000000..dd9f1517 --- /dev/null +++ b/directorygroups/js/fog.directorygroups.report.file.js @@ -0,0 +1,17 @@ +(function($) { + var reportString = window.atob(Common.f); + + // Every plugin's report JS loads on any Reports page, so guard on the + // report name and only act on this plugin's own report. + switch (reportString) { + case 'directorygroups report': + $('#directorygroups-report-table').registerReportTable([ + {data: 'name'}, + {data: 'description', visible: false}, + {data: 'createdBy', visible: false}, + {data: 'createdTime', visible: false}, + {data: 'ou'} + ], {fullExport: true}); + break; + } +})(jQuery); diff --git a/directorygroups/src/Hooks/AddDirectoryGroupsAPI.php b/directorygroups/src/Hooks/AddDirectoryGroupsAPI.php new file mode 100644 index 00000000..fd248ada --- /dev/null +++ b/directorygroups/src/Hooks/AddDirectoryGroupsAPI.php @@ -0,0 +1,79 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Hooks; + +/** + * Injects ou stuff into the api system. + * + * @category AddDirectoryGroupsAPI + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class AddDirectoryGroupsAPI extends \FOG\Base\Hook +{ + /** + * The name of the hook. + * + * @var string + */ + public $name = 'AddDirectoryGroupsAPI'; + /** + * The description. + * + * @var string + */ + public $description = 'Add OU stuff into the api system.'; + /** + * For posterity. + * + * @var bool + */ + public $active = true; + /** + * The node the hook works with. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initialize object. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + $this->registerInstalled([ + ['API_VALID_CLASSES', 'injectAPIElements'], + ]); + } + /** + * This function injects ou elements for + * api access. + * + * @param mixed $arguments The arguments to modify. + * + * @return void + */ + public function injectAPIElements($arguments) + { + array_push( + $arguments['validClasses'], + $this->node, + 'directorygroupsassociation' + ); + } +} diff --git a/directorygroups/src/Hooks/AddDirectoryGroupsHost.php b/directorygroups/src/Hooks/AddDirectoryGroupsHost.php new file mode 100644 index 00000000..807db6a0 --- /dev/null +++ b/directorygroups/src/Hooks/AddDirectoryGroupsHost.php @@ -0,0 +1,467 @@ + + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Hooks; + +/** + * Adds the ou choice to host. + * + * @category AddDirectoryGroupsHost + * @package FOGProject + * @author Tom Elliott + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class AddDirectoryGroupsHost extends \FOG\Base\Hook +{ + /** + * The name of this hook. + * + * @var string + */ + public $name = 'AddDirectoryGroupsHost'; + /** + * The description of this hook. + * + * @var string + */ + public $description = 'Add OU to Hosts'; + /** + * The active flag (always true but for posterity) + * + * @var bool + */ + public $active = true; + /** + * THe node this hook enacts with. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initialize object. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + $this->registerInstalled([ + ['PLUGINS_INJECT_TABDATA', 'hostTabData'], + ['HOST_EDIT_SUCCESS', 'hostAddDirectoryGroupsEdit'], + ['HOST_MASSEDIT_FIELDS', 'massEditFields'], + ['HOST_MASSEDIT_APPLY', 'massEditApply'], + ['HOST_ADD_FIELDS', 'hostAddDirectoryGroupsField'], + ['HOST_REGISTER', 'hostAddDirectoryGroupsRegister'], + ]); + } + /** + * The host tab data. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function hostTabData($arguments) + { + global $node; + if ($node != 'host') { + return; + } + $obj = $arguments['obj']; + + $arguments['pluginsTabData'][] = [ + 'name' => _('Directory Group'), + 'id' => 'host-directorygroups', + 'generator' => function () use ($obj) { + $this->hostDirectoryGroups($obj); + } + ]; + } + /** + * The host ou display + * + * @param object $obj The host object we're working with. + * + * @return void + */ + public function hostDirectoryGroups($obj) + { + $items = \FOG\Router\Route::getList('directorygroupsassociation'); + $ou = 0; + foreach ($items as &$item) { + if ($item->hostID == $obj->get('id')) { + $ou = $item->directorygroupsID; + unset($item); + break; + } + unset($item); + } + $directorygroupsID = ( + (int)filter_input(INPUT_POST, 'directorygroups') ?: + $ou + ); + $ouSelector = (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsManager()) + ->buildSelectBox($directorygroupsID, 'directorygroups'); + + $fields = [ + \FOG\Base\FOGPage::makeLabel( + 'col-sm-3 col-form-label', + 'directorygroups', + _('Host OU') + ) => $ouSelector + ]; + + $buttons = \FOG\Base\FOGPage::makeButton( + 'directorygroups-send', + _('Update'), + 'btn btn-primary float-end' + ); + // Create-and-associate, the same button and modal the core association + // tabs get. Added before the *_FIELDS event so a listener can still see + // it, and immediately after Update so Update stays the row's rightmost + // (primary) button with this one to its left. The modal it returns is + // echoed after the form -- see below for why it cannot go inside. + $createModal = \FOG\Base\FOGPage::renderAssocCreate( + 'host-directorygroups', + 'directorygroups', + $buttons, + $obj->get('id'), + _('OU') + ); + + self::$HookManager->processEvent( + 'HOST_DIRECTORYGROUPS_FIELDS', + [ + 'fields' => &$fields, + 'buttons' => &$buttons, + 'Host' => &$obj + ] + ); + $rendered = \FOG\Base\FOGPage::formFields($fields); + unset($fields); + + echo \FOG\Base\FOGPage::makeFormTag( + '', + 'host-directorygroups-form', + \FOG\Base\FOGPage::makeTabUpdateURL( + 'host-directorygroups', + $obj->get('id') + ), + 'post', + 'application/x-www-form-urlencoded', + true + ); + echo '
'; + echo '
'; + echo '

'; + echo _('OU'); + echo '

'; + echo '
'; + echo '
'; + echo $rendered; + echo '
'; + echo ''; + echo '
'; + echo ''; + // Outside the form, deliberately. The modal holds the fetched create + // form, and a
inside another is invalid markup: the + // browser drops the inner one and the create would post nothing. + echo $createModal; + } + /** + * The ou updater element. + * + * @param object $obj The object we're working with. + * + * @return void + */ + public function hostDirectoryGroupsPost($obj) + { + self::checkAuthAndCSRF(); + $directorygroupsID = trim( + (int)filter_input(INPUT_POST, 'directorygroups') + ); + $insert_fields = ['hostID', 'directorygroupsID']; + $insert_values = []; + $hosts = [$obj->get('id')]; + if (count($hosts ?: [])) { + \FOG\Router\Route::deletemass( + 'directorygroupsassociation', + ['hostID' => $hosts] + ); + if ($directorygroupsID > 0) { + foreach ((array)$hosts as $ind => &$hostID) { + $insert_values[] = [$hostID, $directorygroupsID]; + unset($hostID); + } + } + } + if (count($insert_values) > 0) { + (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsAssociationManager()) + ->insertBatch( + $insert_fields, + $insert_values + ); + } + } + /** + * Associates the OU posted during host (auto) registration. + * + * The Registration class decodes $_POST in place before firing + * HOST_REGISTER, so the posted 'directorygroups' is already a plain id here. + * + * @param mixed $arguments The hook arguments (contains Host). + * + * @return void + */ + public function hostAddDirectoryGroupsRegister($arguments) + { + $obj = $arguments['Host']; + if (!$obj->isValid()) { + return; + } + $directorygroupsID = (int)($_POST['directorygroups'] ?? 0); + if ($directorygroupsID < 1) { + return; + } + (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsAssociationManager()) + ->insertBatch( + ['hostID', 'directorygroupsID'], + [[$obj->get('id'), $directorygroupsID]] + ); + } + /** + * The host ou selector. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function hostAddDirectoryGroupsEdit($arguments) + { + self::checkAuthAndCSRF(); + global $tab; + global $node; + if ($node != 'host') { + return; + } + $obj = $arguments['Host']; + try { + switch ($tab) { + case 'host-directorygroups': + $this->hostDirectoryGroupsPost($obj); + break; + default: + return; + } + $arguments['code'] = \FOG\Router\HTTPResponseCodes::HTTP_ACCEPTED; + $arguments['hook'] = 'HOST_EDIT_DIRECTORYGROUPS_SUCCESS'; + $arguments['msg'] = json_encode( + [ + 'msg' => _('Host OU Updated!'), + 'title' => _('Host OU Update Success') + ] + ); + } catch (\Exception $e) { + $arguments['code'] = ( + $arguments['serverFault'] ? + \FOG\Router\HTTPResponseCodes::HTTP_INTERNAL_SERVER_ERROR : + \FOG\Router\HTTPResponseCodes::HTTP_BAD_REQUEST + ); + $arguments['hook'] = 'HOST_EDIT_DIRECTORYGROUPS_FAIL'; + $arguments['msg'] = json_encode( + [ + 'error' => $e->getMessage(), + 'title' => _('Host Update OU Fail') + ] + ); + } + } + /** + * The host ou field for function add. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function hostAddDirectoryGroupsField($arguments) + { + global $node; + if ($node != 'host') { + return; + } + $directorygroupsID = (int)filter_input(INPUT_POST, 'directorygroups'); + $ouSelector = (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsManager()) + ->buildSelectBox($directorygroupsID, 'directorygroups'); + + $arguments['fields'][ + \FOG\Base\FOGPage::makeLabel( + 'col-sm-3 col-form-label', + 'directorygroups', + _('Host OU') + ) + ] = $ouSelector; + } + /** + * Contributes the ou to the host list's Mass Edit form. + * + * ADR 0038 decision 13. This replaces AddOUGroup, whose only job was + * to set one ou across a group's members. That hook was a copy, not a + * grant: it read the membership at the instant the button was pressed and + * wrote a row per member, so a host added to the group afterward got + * nothing and a host removed kept what it had. Worse, it had no way to + * express "leave this host alone" -- every save deleted the association + * for every member first, so setting one host's ou through the group + * wiped it from all of them. + * + * A ou is single-valued per host: the write path has always been + * delete-then-insert-one. That is why this is a mass edit rather than one + * of ADR 0038's grant tables -- a grant is a SET that several groups can + * union into, and unioning two ous means nothing. + * + * Core draws the three-state action control around this field, which is + * what makes "leave alone" expressible at all. The value control is + * rendered EMPTY on purpose: there is no honest value to pre-fill it with + * when the selection disagrees. What the hosts hold is stated in the hint + * instead, where "(varies)" is sayable. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function massEditFields($arguments) + { + $hostIDs = (array)$arguments['hostIDs']; + + $arguments['fields']['directorygroups'] = [ + 'label' => _('Host OU'), + 'input' => (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsManager())->buildSelectBox( + '', + 'value[directorygroups]', + 'name', + '', + false, + 'id', + 'massedit-directorygroups' + ), + 'hint' => \FOG\Util\SharedHostValues::hint( + $this->_sharedDirectoryGroups($hostIDs) + ), + ]; + } + /** + * What the selected hosts hold, as a name rather than an id. + * + * SharedHostValues::forHostRows() answers in the column's own terms, so a + * uniform selection comes back as the ou id. Rendering that would put + * a bare number in front of the admin where every other hint shows the + * value they set. The lookup only happens when the answer is uniform and + * non-empty, which is the only case where there is one name to show. + * + * forHostRows() is also the reason this is not a hand-rolled query: a + * host with no ou has no row at all, so counting rows would call + * three hosts out of five "in agreement". It compares the row count to + * the selection size for exactly that reason. + * + * @param array $hostIDs the selection + * + * @return array a SharedHostValues info array + */ + private function _sharedDirectoryGroups(array $hostIDs) + { + $info = \FOG\Util\SharedHostValues::forHostRows( + $hostIDs, + 'directoryOUAssoc', + 'doaHostID', + ['directorygroups' => 'doaOUID'] + )['directorygroups']; + + if (!empty($info['uniform']) && '' !== (string)$info['value']) { + $item = new \FOG\Plugins\DirectoryGroups\Items\DirectoryGroups((int)$info['value']); + if ($item->isValid()) { + $info['value'] = $item->get('name'); + } + } + + return $info; + } + /** + * Applies a resolved ou action across the selection. + * + * The action arrives already reduced to leave/set/clear, so there is no + * sentinel to parse and no way for an empty control to be mistaken for + * "clear it" -- which is the failure the three-state model exists to stop + * and the one AddOUGroup could not avoid. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function massEditApply($arguments) + { + if (!isset($arguments['actions']['directorygroups'])) { + return; + } + $instruction = $arguments['actions']['directorygroups']; + $action = $instruction['action']; + if ('leave' === $action) { + return; + } + $hostIDs = array_values( + array_filter( + array_map('intval', (array)$arguments['hostIDs']), + function ($id) { + return $id > 0; + } + ) + ); + if (count($hostIDs) < 1) { + return; + } + + $itemID = 0; + if ('set' === $action) { + $itemID = (int)$instruction['value']; + // A set naming a ou that does not exist is a bad request, not + // an instruction to clear: silently turning it into one would + // strip the ou off every selected host. + if ($itemID < 1 + || !(new \FOG\Plugins\DirectoryGroups\Items\DirectoryGroups($itemID))->isValid() + ) { + throw new \Exception(_('Invalid OU selected')); + } + } + + // Both branches clear first. The association is single-valued per + // host, so a set is a replace -- and doing it in one deletemass + // rather than per host keeps it to one statement. + \FOG\Router\Route::deletemass( + 'directorygroupsassociation', + ['hostID' => $hostIDs] + ); + if ($itemID < 1) { + return; + } + $insert_values = []; + foreach ($hostIDs as $hostID) { + $insert_values[] = [$hostID, $itemID]; + } + (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsAssociationManager()) + ->insertBatch(['hostID', 'directorygroupsID'], $insert_values); + } +} diff --git a/directorygroups/src/Hooks/AddDirectoryGroupsJS.php b/directorygroups/src/Hooks/AddDirectoryGroupsJS.php new file mode 100644 index 00000000..a4ba8974 --- /dev/null +++ b/directorygroups/src/Hooks/AddDirectoryGroupsJS.php @@ -0,0 +1,80 @@ + + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Hooks; + +/** + * Sets the javascript files up for this plugin. + * + * @category AddSiteJS + * @package FOGProject + * @author Tom Elliott + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class AddDirectoryGroupsJS extends \FOG\Base\Hook +{ + /** + * The name of this hook. + * + * @var string + */ + public $name = 'AddDirectoryGroupsJS'; + /** + * The description. + * + * @var string + */ + public $description = 'Add OU JS files.'; + /** + * For posterity. + * + * @var bool + */ + public $active = true; + /** + * What plugin this works against. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initialize object. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + $this->registerInstalled([ + ['PAGE_JS_FILES', 'injectJSFiles'], + ]); + } + /** + * The files we need to inject. + * + * @param mixed $arguments The arguments to modify. + * + * @return void + */ + public function injectJSFiles($arguments) + { + $this->injectPluginJS($arguments, [ + 'directorygroups' => ['fallback' => true], + 'report' => ['secondary' => true, 'fallback' => true], + 'host' => ['secondary' => true], + ]); + } +} diff --git a/directorygroups/src/Hooks/AddDirectoryGroupsMenuItem.php b/directorygroups/src/Hooks/AddDirectoryGroupsMenuItem.php new file mode 100644 index 00000000..54e9ed78 --- /dev/null +++ b/directorygroups/src/Hooks/AddDirectoryGroupsMenuItem.php @@ -0,0 +1,154 @@ + + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Hooks; + +/** + * Adds the ou menu item. + * + * @category AddDirectoryGroupsMenuItem + * @package FOGProject + * @author Tom Elliott + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class AddDirectoryGroupsMenuItem extends \FOG\Base\Hook +{ + /** + * The name of this hook. + * + * @var string + */ + public $name = 'AddDirectoryGroupsMenuItem'; + /** + * The description of this hook. + * + * @var string + */ + public $description = 'Add menu item for ou'; + /** + * The active flag. + * + * @var bool + */ + public $active = true; + /** + * The node this hook enacts with. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initialize object. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + $this->registerInstalled([ + ['MAIN_MENU_DATA', 'menuData'], + ['SEARCH_PAGES', 'addSearch'], + ['PAGES_WITH_OBJECTS', 'addPageWithObject'], + ['PERMISSION_REGISTRY_DATA', 'permData'], + ['SUB_MENULINK_DATA', 'menuUpdate'], + ['REPORT_TITLE_DATA', 'reportTitle'], + ]); + } + /** + * Names this plugin's report in the Reports menu. + * + * Without this the sidebar shows ucwords() of the FILE name -- "Ou Report" + * -- while the page it opens is headed "Export OUs". Two names for one + * screen, and the file name is the half nobody chose. + * + * Keyed the way the menu and the base64 `f` parameter are: the file + * name with underscores as spaces, lower case. The report reads the + * same map back through reportTitle() for its heading, so the two + * cannot drift apart again. + * + * @param mixed $arguments The titles to modify. + * + * @return void + */ + public function reportTitle($arguments) + { + $arguments['titles']['directorygroups report'] = _('Export OUs'); + } + /** + * Add the new items beyond list/create. + * + * @param mixed $arguments The items to modify. + * + * @return void + */ + public function menuUpdate($arguments) + { + if ($arguments['node'] != $this->node) { + return; + } + $arguments['menu']['export'] = _('Export OUs'); + $arguments['menu']['import'] = _('Import OUs'); + } + /** + * The menu data to change. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function menuData($arguments) + { + $arguments['hook_main'][$this->node] + = [_('Directory Groups'), 'fas fa-folder-tree']; + } + /** + * Adds the ou page to search elements. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function addSearch($arguments) + { + $arguments['searchPages'][] = $this->node; + } + /** + * Adds the ou page to objects elements. + * + * @param mixed $arguments The arguments to change. + * + * @return void + */ + public function addPageWithObject($arguments) + { + $arguments['PagesWithObjects'][] = $this->node; + } + + /** + * Registers this plugin's permission node and actions so its pages + * are gated by RBAC and shown in the role permission matrix. + * + * @param mixed $arguments The permission registry to modify. + * + * @return void + */ + public function permData($arguments) + { + $arguments['registry'][$this->node] = [ + 'view', 'create', 'edit', 'delete' + ]; + } +} diff --git a/directorygroups/src/Hooks/AddDirectoryGroupsType.php b/directorygroups/src/Hooks/AddDirectoryGroupsType.php new file mode 100644 index 00000000..fe5204d8 --- /dev/null +++ b/directorygroups/src/Hooks/AddDirectoryGroupsType.php @@ -0,0 +1,65 @@ + + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Hooks; + +/** + * Adds the ou report type. + * + * @category AddDirectoryGroupsType + * @package FOGProject + * @author Tom Elliott + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class AddDirectoryGroupsType extends \FOG\Base\Hook +{ + /** + * The name of this hook. + * + * @var string + */ + public $name = 'AddDirectoryGroupsType'; + /** + * The description of this hook. + * + * @var string + */ + public $description = 'Add Report Management Type'; + /** + * The active flag. + * + * @var bool + */ + public $active = true; + /** + * The node this hook enacts with. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initialize object. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + $this->registerInstalled([ + ['REPORT_TYPES', 'reportTypes'], + ]); + } +} diff --git a/directorygroups/src/Hooks/DirectoryGroupsChangeItems.php b/directorygroups/src/Hooks/DirectoryGroupsChangeItems.php new file mode 100644 index 00000000..2d923f0e --- /dev/null +++ b/directorygroups/src/Hooks/DirectoryGroupsChangeItems.php @@ -0,0 +1,106 @@ + + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Hooks; + +/** + * Changes the elements we need. + * + * @category ChangeItems + * @package FOGProject + * @author Tom Elliott + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +/** + * Named for its plugin -- see LocationChangeItems, which shared this hook's + * old changeitems.hook.php filename and therefore its class name. Only one of + * the two ever loaded per request, decided by readdir order. Namespacing + * closed that off structurally (fogproject ADR 0035); the distinct names stay + * because they are what the log shows. + */ +class DirectoryGroupsChangeItems extends \FOG\Base\Hook +{ + /** + * The name of this hook. + * + * @var string + */ + public $name = 'DirectoryGroupsChangeItems'; + /** + * The description of this hook. + * + * @var string + */ + public $description = 'Add OU During client checkin'; + /** + * The active flag. + * + * @var bool + */ + public $active = true; + /** + * The node this hook enacts with. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initialize object. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + $this->registerInstalled([ + ['HOSTNAME_CHANGER_CLIENT', 'changeADItems'], + ]); + } + /** + * Sets up host for the new OU + * + * @param mixed $arguments The items to change. + * + * @return void + */ + public function changeADItems($arguments) + { + if (!$arguments['Host']->isValid()) { + return; + } + $OUAssocs = \FOG\Router\Route::getList( + 'directorygroupsassociation', + ['hostID' => $arguments['Host']->get('id')] + ); + // listem() returns the paginated envelope, so the rows are under + // ->data. Iterating the envelope itself walked its scalar counters + // instead: every client check-in warned "Attempt to read property + // directorygroupsID on int" and ADOU was never set. LocationChangeItems, which is + // the same shape, has always had this right. + foreach ($OUAssocs as $OUAssoc) { + // getClass() rather than Route::indiv(): indiv() answers a missing + // row with sendResponse(404), and that exits. An ouassociation + // left behind by a deleted OU would end the whole check-in with a + // 404 the client reads as a transport failure, rather than skip + // one association. + $OU = new \FOG\Plugins\DirectoryGroups\Items\DirectoryGroups($OUAssoc->directorygroupsID); + if (!$OU->isValid()) { + continue; + } + $arguments['val']['ADOU'] = $OU->get('ou'); + } + } +} diff --git a/directorygroups/src/Hooks/DirectoryGroupsDeleteMassItems.php b/directorygroups/src/Hooks/DirectoryGroupsDeleteMassItems.php new file mode 100644 index 00000000..d5bd6965 --- /dev/null +++ b/directorygroups/src/Hooks/DirectoryGroupsDeleteMassItems.php @@ -0,0 +1,89 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Hooks; + +/** + * Deletes the OU the elements en-mass. + * + * @category DirectoryGroupsDeleteMassItems + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class DirectoryGroupsDeleteMassItems extends \FOG\Base\Hook +{ + /** + * The name of this hook. + * + * @var string + */ + public $name = 'DirectoryGroupsDeleteMassItems'; + /** + * The description of this hook. + * + * @var string + */ + public $description = 'Delete En-mass Route altering for OU'; + /** + * The active flag. + * + * @var bool + */ + public $active = true; + /** + * The node this hook enacts with. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initialize object. + * + * @return void + */ + public function __construct() + { + parent::__construct(); + $this->registerInstalled([ + ['DELETEMASS_API', 'deletemassitems'], + ]); + } + /** + * Prepares to clean up associations + * + * @param mixed $arguments The items to change. + * + * @return void + */ + public function deletemassitems($arguments) + { + switch ($arguments['classname']) { + case 'host': + $arguments['removeItems']['directorygroupsassociation'] = [ + 'hostID' => $arguments['itemIDs'] + ]; + break; + case 'directorygroups': + // Only treat itemIDs as ouIDs when an OU itself is deleted. + // A bare default would mis-fire for every other entity now that + // all delete paths funnel through DELETEMASS_API, deleting + // ouassociation rows whose directorygroupsID collides with the deleted id. + $arguments['removeItems']['directorygroupsassociation'] = [ + 'directorygroupsID' => $arguments['itemIDs'] + ]; + break; + } + } +} diff --git a/directorygroups/src/Items/DirectoryGroups.php b/directorygroups/src/Items/DirectoryGroups.php new file mode 100644 index 00000000..1982088a --- /dev/null +++ b/directorygroups/src/Items/DirectoryGroups.php @@ -0,0 +1,134 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Items; + +/** + * The OU class. + * + * @category OU + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class DirectoryGroups extends \FOG\Base\FOGController +{ + /** + * The location table + * + * @var string + */ + protected $databaseTable = 'directoryOUs'; + /** + * The location table fields and common names + * + * @var array + */ + protected $databaseFields = [ + 'id' => 'doID', + 'name' => 'doName', + 'description' => 'doDesc', + 'createdBy' => 'doCreatedBy', + 'createdTime' => 'doCreatedTime', + 'ou' => 'doDN', + 'domainID' => 'doDomainID' + ]; + /** + * The required fields + * + * @var array + */ + protected $databaseFieldsRequired = [ + 'name', + 'ou' + ]; + /** + * Additional fields. + * + * @var array + */ + protected $additionalFields = [ + 'hosts' + ]; + /** + * Destroy this particular object. + * + * @param string $key the key to destroy for match + * + * @return bool + */ + public function destroy($key = 'id') + { + \FOG\Router\Route::deletemass( + 'directorygroupsassociation', + ['directorygroupsID' => $this->get('id')] + ); + return parent::destroy($key); + } + /** + * Stores the item in the DB either stored or updated. + * + * @return object + */ + public function save() + { + parent::save(); + return $this + ->assocSetter('DirectoryGroups', 'host') + ->load(); + } + /** + * Add host to the location. + * + * @param array $addArray the items to add. + * + * @return object + */ + public function addHost($addArray) + { + return $this->addRemItem( + 'hosts', + (array)$addArray, + 'merge' + ); + } + /** + * Remove host from the location. + * + * @param array $removeArray the items to remove. + * + * @return object + */ + public function removeHost($removeArray) + { + return $this->addRemItem( + 'hosts', + (array)$removeArray, + 'diff' + ); + } + /** + * Loads the locations hosts. + * + * @return void + */ + protected function loadHosts() + { + $this->_loadHostIds( + 'directorygroupsassociation', + ['directorygroupsID' => $this->get('id')], + 'hostID' + ); + } +} diff --git a/directorygroups/src/Items/DirectoryGroupsAssociation.php b/directorygroups/src/Items/DirectoryGroupsAssociation.php new file mode 100644 index 00000000..3ea73690 --- /dev/null +++ b/directorygroups/src/Items/DirectoryGroupsAssociation.php @@ -0,0 +1,96 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Items; + +/** + * The association between hosts and ous. + * + * @category DirectoryGroupsAssociation + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class DirectoryGroupsAssociation extends \FOG\Base\FOGController +{ + /** + * The association table. + * + * @var string + */ + protected $databaseTable = 'directoryOUAssoc'; + /** + * The association fields and common names. + * + * @var array + */ + protected $databaseFields = [ + 'id' => 'doaID', + 'directorygroupsID' => 'doaOUID', + 'hostID' => 'doaHostID' + ]; + /** + * The required fields + * + * @var array + */ + protected $databaseFieldsRequired = [ + 'directorygroupsID', + 'hostID' + ]; + /** + * Additional fields + * + * @var array + */ + protected $additionalFields = [ + 'host', + 'directorygroups' + ]; + /** + * Database -> Class field relationships + * + * @var array + */ + protected $databaseFieldClassRelationships = [ + 'Host' => [ + 'id', + 'hostID', + 'host' + ], + 'DirectoryGroups' => [ + 'id', + 'directorygroupsID', + 'directorygroups' + ] + ]; + /** + * Return the associated directory group. + * + * @return object + */ + public function getDirectoryGroups() + { + return $this->get('directorygroups'); + } + /** + * Return the associated host. + * + * @return host + */ + public function getHost() + { + return $this->get('host'); + } +} diff --git a/directorygroups/src/Managers/DirectoryGroupsAssociationManager.php b/directorygroups/src/Managers/DirectoryGroupsAssociationManager.php new file mode 100644 index 00000000..cd9146fd --- /dev/null +++ b/directorygroups/src/Managers/DirectoryGroupsAssociationManager.php @@ -0,0 +1,84 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Managers; + +/** + * OU association manager class. + * + * @category DirectoryGroupsAssociationManager + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class DirectoryGroupsAssociationManager extends \FOG\Base\FOGManagerController +{ + /** + * The base table name. + * + * @var string + */ + public $tablename = 'directoryOUAssoc'; + /** + * Returns the CREATE TABLE (IF NOT EXISTS) statement for this table. + * + * Non-destructive and safe to re-run. Used as a step in DirectoryGroupsManager::schema(). + * + * @return string + */ + public function createSql() + { + return $this->createTableSql( + $this->tablename, + true, + [ + 'doaID', + 'doaOUID', + 'doaHostID' + ], + [ + 'INTEGER', + 'INTEGER', + 'INTEGER' + ], + [ + false, + false, + false + ], + [ + false, + false, + false + ], + [ + 'doaID', + 'doaHostID' + ], + 'InnoDB', + 'utf8', + 'doaID', + 'doaID' + ); + } + /** + * Install our table non-destructively. + * + * @return bool + */ + public function install() + { + return self::$DB->query($this->createSql()); + } +} diff --git a/directorygroups/src/Managers/DirectoryGroupsManager.php b/directorygroups/src/Managers/DirectoryGroupsManager.php new file mode 100644 index 00000000..238f67ca --- /dev/null +++ b/directorygroups/src/Managers/DirectoryGroupsManager.php @@ -0,0 +1,169 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Managers; + +/** + * OU manager mass management class + * + * @category DirectoryGroupsManager + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class DirectoryGroupsManager extends \FOG\Base\FOGManagerController +{ + /** + * The base table name. + * + * @var string + */ + public $tablename = 'directoryOUs'; + /** + * Returns the CREATE TABLE (IF NOT EXISTS) statement for this table. + * + * Non-destructive and safe to re-run. Used as a step in schema(). + * + * @return string + */ + public function createSql() + { + return $this->createTableSql( + $this->tablename, + true, + [ + 'doID', + 'doName', + 'doDesc', + 'doCreatedBy', + 'doCreatedTime', + 'doDN', + 'doDomainID' + ], + [ + 'INTEGER', + 'VARCHAR(255)', + 'LONGTEXT', + 'VARCHAR(40)', + 'TIMESTAMP', + 'VARCHAR(255)', + 'INTEGER' + ], + [ + false, + false, + false, + false, + false, + false, + true + ], + [ + false, + false, + false, + false, + 'CURRENT_TIMESTAMP', + false, + false + ], + [ + 'doID', + 'doName' + ], + 'InnoDB', + 'utf8', + 'doID', + 'doID' + ); + } + /** + * The plugin's ordered, append-only schema migration list (all tables). + * Append new steps (e.g. "ALTER TABLE `directoryOUs` ADD COLUMN ...") to the END. + * + * @return array + */ + public function schema() + { + return [ + // 0 + $this->createSql(), + // 1 + (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsAssociationManager())->createSql(), + // 2 - the plugin's foreign keys. + // + // fogproject ADR 0031 decision 8: sweep, then add. ADD CONSTRAINT + // validates the rows already in the table and answers 1452 if any + // of them point at a parent that is gone -- and applyConstraints() + // REPORTS a refusal rather than returning it, so an install that + // skipped the sweep would succeed while silently not creating the + // constraint. The sweep is the precondition for the statement, not + // a policy choice. + // + // Both calls are filtered to this plugin's own group, so neither + // can reach another plugin's tables or core's. The relationships + // themselves are declared in fogproject's + // commons/schema-constraints.php: directoryOUAssoc.doaOUID CASCADE + // to `directoryOUs`, directoryOUAssoc.doaHostID CASCADE to + // `hosts`, and directoryOUs.doDomainID CASCADE to + // `directoryDomains`. That last table is created by a separate + // plugin step, not here. Half of them point at core tables, and + // that map is meant to answer "what points at hosts?" from one + // file. + // + // Appended rather than folded into an earlier step because + // installdb() SKIPS the pSchema steps an install has already + // passed instead of replaying them, so an edit to an earlier step + // is invisible to everyone already past it. + // + // No sentinel is needed: doaOUID and doaHostID are int(11) NOT NULL + // against int(11) parents, and an association row exists only to + // name both ends. doDomainID is NULL when an OU belongs to no + // domain, never 0. + // + // Idempotent, and re-run by the unfiltered reconcile after every + // core schema update: planConstraints() skips a constraint whose + // declaration already matches the map. + function () { + $res = \FOG\Db\SchemaReconciler::sweepOrphans('directorygroups'); + if (is_string($res)) { + return $res; + } + return \FOG\Db\SchemaReconciler::applyConstraints('directorygroups'); + }, + ]; + } + /** + * Installs the database non-destructively (create-if-absent + apply any + * pending additive steps). Does not drop existing data. + * + * @return bool + */ + public function install() + { + $res = \FOG\Items\Schema::applyUpdates($this->schema(), 0); + return $res['error'] === null; + } + /** + * Uninstalls the database + * + * @return bool + */ + public function uninstall() + { + $res = true; + (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsAssociationManager())->uninstall(); + return parent::uninstall(); + } +} diff --git a/directorygroups/src/Pages/DirectoryGroupsManagement.php b/directorygroups/src/Pages/DirectoryGroupsManagement.php new file mode 100644 index 00000000..d0527419 --- /dev/null +++ b/directorygroups/src/Pages/DirectoryGroupsManagement.php @@ -0,0 +1,425 @@ + + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Pages; + +/** + * OU management page. + * + * @category DirectoryGroupsManagement + * @package FOGProject + * @author Tom Elliott + * @author Lee Rowlett + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class DirectoryGroupsManagement extends \FOG\Base\FOGPage +{ + /** + * The node this page operates on. + * + * @var string + */ + public $node = 'directorygroups'; + /** + * Initializes the OU management page. + * + * @param string $name Something to lay it out as. + * + * @return void + */ + public function __construct($name = '') + { + $this->name = _('OU Management'); + parent::__construct($this->name); + $this->headerData = [ + _('OU Name'), + _('OU DN') + ]; + $this->attributes = [ + [], + [] + ]; + } + /** + * Builds the create-form fields (shared by add() and addModal()). + * + * @return array + */ + protected function _addFields() + { + $ou = filter_input(INPUT_POST, 'ou'); + $description = filter_input(INPUT_POST, 'description'); + $oudn = filter_input(INPUT_POST, 'oudn'); + + $labelClass = 'col-sm-3 col-form-label'; + + return [ + self::makeLabel( + $labelClass, + 'ou', + _('OU Name') + ) => self::makeInput( + 'form-control ouname-input', + 'ou', + _('OU Name'), + 'text', + 'ou', + $ou, + true + ), + self::makeLabel( + $labelClass, + 'description', + _('OU Description') + ) => self::makeTextarea( + 'form-control oudescription-input', + 'description', + _('OU Description'), + 'description', + $description + ), + self::makeLabel( + $labelClass, + 'oudn', + _('OU DN') + ) => self::makeInput( + 'form-control oudn-input', + 'oudn', + 'ou=computers,dc=example,dc=com', + 'text', + 'oudn', + $oudn, + true + ) + ]; + } + /** + * Creates new item. + * + * @return void + */ + public function add() + { + $this->renderAddForm( + 'directorygroups', + _('Create New OU'), + 'DIRECTORYGROUPS_ADD_FIELDS', + 'DirectoryGroups' + ); + } + /** + * Creates new item. + * + * @return void + */ + public function addModal() + { + $this->renderAddModalForm( + 'directorygroups', + 'DIRECTORYGROUPS_ADD_FIELDS', + 'DirectoryGroups' + ); + } + /** + * Actually create the ou. + * + * @return void + */ + public function addPost() + { + $this->handleAddPost( + 'DirectoryGroups', + 'DIRECTORYGROUPS_ADD', + _('OU added!'), + _('OU Create Success'), + _('OU Create Fail'), + function (&$serverFault) { + $ou = trim( + filter_input(INPUT_POST, 'ou') + ); + $description = trim( + filter_input(INPUT_POST, 'description') + ); + $oudn = trim( + filter_input(INPUT_POST, 'oudn') + ); + $exists = (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsManager()) + ->exists($ou); + if ($exists) { + throw new \Exception( + _('An ou already exists with this name!') + ); + } + $OU = (new \FOG\Plugins\DirectoryGroups\Items\DirectoryGroups()) + ->set('name', $ou) + ->set('description', $description) + ->set('ou', $oudn); + if (!$OU->save()) { + $serverFault = false; + throw new \Exception(_('Add ou failed!')); + } + return $OU; + } + ); + } + /** + * Displays the ou general tab. + * + * @return void + */ + public function directorygroupsGeneral() + { + $ou = ( + filter_input(INPUT_POST, 'ou') ?: + $this->obj->get('name') + ); + $description = ( + filter_input(INPUT_POST, 'description') ?: + $this->obj->get('description') + ); + $oudn = ( + filter_input(INPUT_POST, 'oudn') ?: + $this->obj->get('ou') + ); + + $labelClass = 'col-sm-3 col-form-label'; + + $fields = [ + self::makeLabel( + $labelClass, + 'ou', + _('OU Name') + ) => self::makeInput( + 'form-control ouname-input', + 'ou', + _('OU Name'), + 'text', + 'ou', + $ou, + true + ), + self::makeLabel( + $labelClass, + 'description', + _('OU Description') + ) => self::makeTextarea( + 'form-control oudescription-input', + 'description', + _('OU Description'), + 'description', + $description + ), + self::makeLabel( + $labelClass, + 'oudn', + _('OU DN') + ) => self::makeInput( + 'form-control oudn-input', + 'oudn', + 'ou=computers,dc=example,dc=com', + 'text', + 'oudn', + $oudn, + true + ) + ]; + + $buttons = self::makeButton( + 'general-send', + _('Update'), + 'btn btn-primary float-end' + ); + $buttons .= self::makeButton( + 'general-delete', + _('Delete'), + 'btn btn-danger float-start' + ); + + self::$HookManager->processEvent( + 'DIRECTORYGROUPS_GENERAL_FIELDS', + [ + 'fields' => &$fields, + 'buttons' => &$buttons, + 'DirectoryGroups' => &$this->obj + ] + ); + $rendered = self::formFields($fields); + unset($fields); + + echo self::makeFormTag( + '', + 'directorygroups-general-form', + self::makeTabUpdateURL( + 'directorygroups-general', + $this->obj->get('id') + ), + 'post', + 'application/x-www-form-urlencoded', + true + ); + echo '
'; + echo '
'; + echo $rendered; + echo '
'; + echo ''; + echo '
'; + echo ''; + } + /** + * Actually update the general information. + * + * @return void + */ + public function directorygroupsGeneralPost() + { + self::checkAuthAndCSRF(); + $ou = trim( + filter_input(INPUT_POST, 'ou') + ); + $description = trim( + filter_input(INPUT_POST, 'description') + ); + $oudn = trim( + filter_input(INPUT_POST, 'oudn') + ); + + $exists = (new \FOG\Plugins\DirectoryGroups\Managers\DirectoryGroupsManager()) + ->exists($ou); + if ($ou != $this->obj->get('name') + && $exists + ) { + throw new \Exception( + _('An OU already exists with this name!') + ); + } + $this->obj + ->set('name', $ou) + ->set('description', $description) + ->set('ou', $oudn); + } + /** + * Present the host membership tab. + * + * @return void + */ + public function directorygroupsHosts() + { + $this->renderAssocTab( + 'directorygroups-host', + _('OU Host Associations'), + _('Host Name'), + 'host' + ); + } + /** + * Update host membership. + * + * @return void + */ + public function directorygroupsHostPost() + { + $this->assocPost('addHost', 'removeHost'); + } + /** + * Present the ou to edit the page. + * + * @return void + */ + public function edit() + { + $tabData = []; + + // General + $tabData[] = [ + 'name' => _('General'), + 'id' => 'directorygroups-general', + 'generator' => function () { + $this->directorygroupsGeneral(); + } + ]; + + // Hosts + $tabData[] = [ + 'tabs' => [ + 'name' => _('Associations'), + 'tabData' => [ + [ + 'name' => _('Host Association'), + 'id' => 'directorygroups-host', + 'generator' => function () { + $this->directorygroupsHosts(); + } + ] + ] + ] + ]; + $this->renderEditTabs($tabData, $this->obj); + } + /** + * Actually update the ou. + * + * @return void + */ + public function editPost() + { + $this->handleEditPost( + 'DirectoryGroups', + 'DIRECTORYGROUPS_EDIT', + _('OU updated!'), + _('OU Update Success'), + _('OU Update Fail'), + function (&$serverFault) { + global $tab; + switch ($tab) { + case 'directorygroups-general': + $this->directorygroupsGeneralPost(); + break; + case 'directorygroups-host': + $this->directorygroupsHostPost(); + } + if (!$this->obj->save()) { + $serverFault = true; + throw new \Exception(_('OU update failed!')); + } + } + ); + } + /** + * OU -> host membership list + * + * @return void + */ + public function getHostsList() + { + return $this->assocItemsList( + 'host', + 'directorygroupsassociation', + 'directoryOUAssoc', + '`hosts`.`hostID`', + '`directoryOUAssoc`.`doaHostID`', + '`directoryOUAssoc`.`doaOUID`', + [ + [ + 'db' => 'directoryOUAssoc', + 'dt' => 'association', + 'removeFromQuery' => true + ] + ] + ); + } +} diff --git a/directorygroups/src/Reports/DirectoryGroups_Report.php b/directorygroups/src/Reports/DirectoryGroups_Report.php new file mode 100644 index 00000000..d2a009b2 --- /dev/null +++ b/directorygroups/src/Reports/DirectoryGroups_Report.php @@ -0,0 +1,80 @@ + + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ + +namespace FOG\Plugins\DirectoryGroups\Reports; + +/** + * OU report. + * + * @category DirectoryGroups_Report + * @package FOGProject + * @author Tom Elliott + * @license http://opensource.org/licenses/gpl-3.0 GPLv3 + * @link https://fogproject.org + */ +class DirectoryGroups_Report extends \FOG\Pages\ReportManagement +{ + /** + * The page to display. + * + * @return void + */ + public function file() + { + $this->title = self::reportTitle(); + + $this->headerData = [ + _('OU Name'), + _('Description'), + _('Created By'), + _('Created Time'), + _('OU DN') + ]; + $this->attributes = [ + [], + [], + [], + [], + [] + ]; + + echo '
'; + echo '
'; + echo '

'; + echo $this->title; + echo '

'; + echo '

'; + echo _('Use the selector to choose how many items you want exported'); + echo '

'; + echo '
'; + echo '
'; + echo $this->render(12, 'directorygroups-report-table'); + echo '
'; + echo '
'; + } + /** + * The rows this report serves. + * + * Split from the emit so the grid and the "CSV (All)" export run the + * same query -- ReportManagement::exportAll() serves this, and cannot + * take back control from a getList() that exits. + * + * @return array + */ + protected function reportRows() + { + \FOG\Router\Route::listem('directorygroups'); + + return (array) json_decode(\FOG\Router\Route::getData(), true); + } +} diff --git a/tests/fk-writes-are-null-not-zero.test.php b/tests/fk-writes-are-null-not-zero.test.php index 60ee3743..ae563793 100644 --- a/tests/fk-writes-are-null-not-zero.test.php +++ b/tests/fk-writes-are-null-not-zero.test.php @@ -84,6 +84,9 @@ 'laHostID' => ['location', 'LocationAssociation', 'hostID'], 'oaOUID' => ['ou', 'OUAssociation', 'ouID'], 'oaHostID' => ['ou', 'OUAssociation', 'hostID'], + 'doaOUID' => ['directorygroups', 'DirectoryGroupsAssociation', 'directorygroupsID'], + 'doaHostID' => ['directorygroups', 'DirectoryGroupsAssociation', 'hostID'], + 'doDomainID' => ['directorygroups', 'DirectoryGroups', 'domainID'], 'sgGroupID' => ['subnetgroup', 'SubnetGroup', 'groupID'], // satellite / junction, ldap 'lgServerID' => ['ldap', 'LDAPGroup', 'serverID'], diff --git a/tests/foreign-keys-applied-per-plugin.test.php b/tests/foreign-keys-applied-per-plugin.test.php index 3779684c..c147fc1c 100644 --- a/tests/foreign-keys-applied-per-plugin.test.php +++ b/tests/foreign-keys-applied-per-plugin.test.php @@ -54,6 +54,7 @@ $expected = [ 'location' => 'location/src/Managers/LocationManager.php', 'ou' => 'ou/src/Managers/OUManager.php', + 'directorygroups' => 'directorygroups/src/Managers/DirectoryGroupsManager.php', 'windowskey' => 'windowskey/src/Managers/WindowsKeyManager.php', 'ldap' => 'ldap/src/Managers/LDAPManager.php', 'oidc' => 'oidc/src/Managers/OIDCManager.php', diff --git a/tests/group-push-became-mass-edit.test.php b/tests/group-push-became-mass-edit.test.php index 1c42f811..64e6be4c 100644 --- a/tests/group-push-became-mass-edit.test.php +++ b/tests/group-push-became-mass-edit.test.php @@ -148,6 +148,13 @@ function check($label, $cond) 'assoc' => 'ouassociation', 'col' => 'ouID', ], + [ + 'file' => 'directorygroups/src/Hooks/AddDirectoryGroupsHost.php', + 'class' => 'FOG\Plugins\DirectoryGroups\Hooks\AddDirectoryGroupsHost', + 'key' => 'directorygroups', + 'assoc' => 'directorygroupsassociation', + 'col' => 'directorygroupsID', + ], ]; foreach ($cases as $case) { @@ -230,6 +237,7 @@ function check($label, $cond) \FOG\Base\Hook::$classes = [ 'LocationAssociationManager' => $manager, 'OUAssociationManager' => $manager, + 'DirectoryGroupsAssociationManager' => $manager, ]; if (isset($action['invalid'])) { \FOG\Base\Hook::$classes['Location'] = function ($id) { @@ -240,6 +248,8 @@ function check($label, $cond) }; \FOG\Base\Hook::$classes['OU'] = \FOG\Base\Hook::$classes['Location']; + \FOG\Base\Hook::$classes['DirectoryGroups'] = + \FOG\Base\Hook::$classes['Location']; } $hostIDs = [1, 2, 3]; $actions = [$key => $action]; diff --git a/tests/plugin-id-columns-are-classified.test.php b/tests/plugin-id-columns-are-classified.test.php index e551c459..ff0149bd 100644 --- a/tests/plugin-id-columns-are-classified.test.php +++ b/tests/plugin-id-columns-are-classified.test.php @@ -57,6 +57,7 @@ 'ldapUserGrant.lugID', 'location.lID', 'locationAssoc.laID', 'ntfy.nID', 'oidcGroupRoleAssoc.ograID', 'oidcGroupUserGroupAssoc.ogugID', 'oidcIdentity.oiID', 'oidcUserGrant.ougID', 'ou.ouID', 'ouAssoc.oaID', + 'directoryOUs.doID', 'directoryOUAssoc.doaID', 'pushbullet.pID', 'slack.sID', 'subnetgroup.sgID', 'windowsKeys.wkID', 'windowsKeysAssoc.wkaID', 'wolbroadcast.wbID', ]; @@ -69,6 +70,8 @@ 'locationAssoc.laLocationID', 'locationAssoc.laHostID', 'location.lStorageGroupID', 'location.lStorageNodeID', 'ouAssoc.oaOUID', 'ouAssoc.oaHostID', + 'directoryOUAssoc.doaOUID', 'directoryOUAssoc.doaHostID', + 'directoryOUs.doDomainID', 'windowsKeysAssoc.wkaImageID', 'windowsKeysAssoc.wkaKeyID', 'LDAPGroups.lgServerID', 'ldapGroupRoleAssoc.lgraGroupID', 'ldapGroupRoleAssoc.lgraRoleID', 'ldapGroupUserGroupAssoc.lgugGroupID', From cef0be6bb2d84b1d48504fdc88d2d19dbbe5f2da Mon Sep 17 00:00:00 2001 From: Tom Elliott Date: Fri, 16 Oct 2026 07:56:03 -0500 Subject: [PATCH 2/2] Add directory domains, the OU browser and the ou migration Each domain has its own server, bind account, CA certificate and base DNs. The plugin answers core's DIRECTORY_CONNECTION with the domain that matches the host's AD domain, and hides the FOG_DIRECTORY_* connection settings while it is installed (SETTINGS_HIDDEN). A domain's Organizational Units tab lists its OUs as a searchable tree and adds the selected ones as directory groups. The host's Active Directory tab gets a Browse Directory button that fills the Domain Name and OU fields from the tree. On install, the FOG_DIRECTORY_* connection becomes the first domain, and the ou plugin's OUs and host links are copied in, idempotently. Uninstall drops all three tables. Refs FOGProject/fogproject#1879 Co-Authored-By: Claude Opus 5.5 --- directorygroups/js/fog.directorydomain.add.js | 3 + .../js/fog.directorydomain.edit.js | 109 ++++ .../js/fog.directorydomain.export.js | 14 + .../js/fog.directorydomain.list.js | 18 + .../js/fog.directorygroups.host.edit.js | 101 ++++ .../Hooks/AddDirectoryDomainConnection.php | 117 ++++ .../src/Hooks/AddDirectoryGroupsAPI.php | 42 +- .../src/Hooks/AddDirectoryGroupsBrowse.php | 111 ++++ .../src/Hooks/AddDirectoryGroupsJS.php | 1 + .../src/Hooks/AddDirectoryGroupsMenuItem.php | 19 + directorygroups/src/Items/DirectoryDomain.php | 169 ++++++ .../src/Managers/DirectoryDomainManager.php | 109 ++++ .../src/Managers/DirectoryGroupsManager.php | 186 +++++- .../src/Pages/DirectoryDomainManagement.php | 531 ++++++++++++++++++ .../plugin-id-columns-are-classified.test.php | 2 +- 15 files changed, 1519 insertions(+), 13 deletions(-) create mode 100644 directorygroups/js/fog.directorydomain.add.js create mode 100644 directorygroups/js/fog.directorydomain.edit.js create mode 100644 directorygroups/js/fog.directorydomain.export.js create mode 100644 directorygroups/js/fog.directorydomain.list.js create mode 100644 directorygroups/src/Hooks/AddDirectoryDomainConnection.php create mode 100644 directorygroups/src/Hooks/AddDirectoryGroupsBrowse.php create mode 100644 directorygroups/src/Items/DirectoryDomain.php create mode 100644 directorygroups/src/Managers/DirectoryDomainManager.php create mode 100644 directorygroups/src/Pages/DirectoryDomainManagement.php diff --git a/directorygroups/js/fog.directorydomain.add.js b/directorygroups/js/fog.directorydomain.add.js new file mode 100644 index 00000000..fb42c8e4 --- /dev/null +++ b/directorygroups/js/fog.directorydomain.add.js @@ -0,0 +1,3 @@ +(function($) { + $('#directorydomain-create-form').wireCreateForm(); +})(jQuery); diff --git a/directorygroups/js/fog.directorydomain.edit.js b/directorygroups/js/fog.directorydomain.edit.js new file mode 100644 index 00000000..10031eb5 --- /dev/null +++ b/directorygroups/js/fog.directorydomain.edit.js @@ -0,0 +1,109 @@ +$(function() { + // --------------------------------------------------------------- + // GENERAL TAB + $.registerGeneralTab({ + nameInputSel: '#name', + formSel: '#directorydomain-general-form' + }); + + // --------------------------------------------------------------- + // ORGANIZATIONAL UNITS TAB + // + // The whole tree, client side: listOUs() already read every page of it, + // so search and scroll need no further round trip. Indented by depth + // and kept in the server's root-first order, so it reads as a tree. + var table = $('#directorydomain-ous-table'), + sendBtn = $('#directorydomain-ous-send'), + loaded = false, + dt; + + function load() { + if (loaded || !table.length) { + return; + } + loaded = true; + $.get( + '../management/index.php?node=directorydomain&sub=getOUs&id=' + + table.data('domain-id'), + function(res) { + dt = table.DataTable({ + data: res.data, + ordering: false, + pageLength: 25, + columns: [ + {data: 'dn'}, + {data: 'name'}, + {data: 'dn'}, + {data: 'groupID'} + ], + columnDefs: [ + { + targets: 0, + searchable: false, + render: function(data, type, row) { + if (row.groupID) { + return ''; + } + return ''; + } + }, + { + targets: 1, + render: function(data, type, row) { + if (type !== 'display') { + return data; + } + return ' ' + + $.escapeHtml(data) + ''; + } + }, + { + targets: 3, + searchable: false, + render: function(data) { + if (!data) { + return ''; + } + return '' + $.escapeHtml(String(data)) + ''; + } + } + ] + }); + }, + 'json' + ).fail(function(jqXHR) { + loaded = false; + $.notifyFromAPI(jqXHR.responseJSON, jqXHR); + }); + } + + // Fetched when the tab is first shown, not with the page: a slow or + // unreachable directory must not hold up the General tab. + $('a[href="#directorydomain-ous"]').on('shown.bs.tab', load); + if (window.location.hash === '#directorydomain-ous') { + load(); + } + + sendBtn.on('click', function() { + if (!dt) { + return; + } + var ous = dt.$('input.ou-pick:checked').map(function() { + return this.value; + }).get(); + sendBtn.prop('disabled', true); + $.apiCall('POST', table.data('update-url'), {ous: ous}, function(err) { + sendBtn.prop('disabled', false); + if (err) { + return; + } + dt.destroy(); + table.find('tbody').remove(); + loaded = false; + load(); + }); + }); +}); diff --git a/directorygroups/js/fog.directorydomain.export.js b/directorygroups/js/fog.directorydomain.export.js new file mode 100644 index 00000000..5cba31eb --- /dev/null +++ b/directorygroups/js/fog.directorydomain.export.js @@ -0,0 +1,14 @@ +(function($) { + // No bindPwd column: the export strips it (AddDirectoryGroupsAPI). + $('#directorydomain-export-table').registerExportTable([ + {data: 'name'}, + {data: 'description', visible: false}, + {data: 'createdBy', visible: false}, + {data: 'createdTime', visible: false}, + {data: 'uri'}, + {data: 'bindDN'}, + {data: 'caCert', visible: false}, + {data: 'baseDN'}, + {data: 'ouBase', visible: false} + ]); +})(jQuery); diff --git a/directorygroups/js/fog.directorydomain.list.js b/directorygroups/js/fog.directorydomain.list.js new file mode 100644 index 00000000..33b1d6e6 --- /dev/null +++ b/directorygroups/js/fog.directorydomain.list.js @@ -0,0 +1,18 @@ +(function($) { + $.registerListPage({ + order: [ + [0, 'asc'] + ], + columns: [ + {data: 'mainlink'}, + {data: 'uri'} + ], + rowId: 'id', + columnDefs: [ + { + responsivePriority: -1, + targets: 0 + } + ] + }); +})(jQuery); diff --git a/directorygroups/js/fog.directorygroups.host.edit.js b/directorygroups/js/fog.directorygroups.host.edit.js index 16eb8e87..1bc4a915 100644 --- a/directorygroups/js/fog.directorygroups.host.edit.js +++ b/directorygroups/js/fog.directorygroups.host.edit.js @@ -9,3 +9,104 @@ node: 'directorygroups' }); })(jQuery); +(function($) { + // --------------------------------------------------------------- + // ACTIVE DIRECTORY TAB: Browse Directory + // + // Lists a domain's OUs; a click on one fills the tab's Domain Name and + // Organizational Unit fields. The admin still clicks Update to save. + var browseBtn = $('#directorygroups-browse'), + modal = $('#directorygroups-browse-modal'), + domainSel = $('#directorygroups-browse-domain'), + table = $('#directorygroups-browse-table'), + domains = {}, + dt; + + if (!browseBtn.length) { + return; + } + + function showDomain(id) { + if (dt) { + dt.destroy(); + table.find('tbody').remove(); + dt = null; + } + $.get( + '../management/index.php?node=directorydomain&sub=getOUs&id=' + id, + function(res) { + dt = table.DataTable({ + data: res.data, + ordering: false, + pageLength: 25, + columns: [ + {data: 'name'}, + {data: 'dn'} + ], + columnDefs: [ + { + targets: 0, + render: function(data, type, row) { + if (type !== 'display') { + return data; + } + return ' ' + + $.escapeHtml(data) + ''; + } + } + ] + }); + }, + 'json' + ).fail(function(jqXHR) { + $.notifyFromAPI(jqXHR.responseJSON, jqXHR); + }); + } + + table.on('click', 'tbody tr', function() { + if (!dt) { + return; + } + var row = dt.row(this).data(); + if (!row) { + return; + } + $('#adDomain').val(domains[domainSel.val()]); + $('#adOU').val(row.dn); + modal.modal('hide'); + }); + + domainSel.on('change', function() { + showDomain(this.value); + }); + + browseBtn.on('click', function(e) { + e.preventDefault(); + modal.modal('show'); + if (domainSel.children().length) { + return; + } + $.get( + '../management/index.php?node=directorydomain&sub=getDomains', + function(res) { + var current = ($('#adDomain').val() || '').toLowerCase(), + pick = null; + $.each(res.data, function(i, d) { + domains[d.id] = d.name; + domainSel.append($('