From c991c601d9b7a144004f47ca21312d08051e7f30 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 17 Mar 2026 14:11:44 +0000 Subject: [PATCH] Add LdapServer MVC --- app/plugins/CoreServer/config/plugin.json | 15 ++ .../resources/locales/en_US/core_server.po | 41 ++- .../src/Controller/LdapServersController.php | 61 +++++ .../src/Model/Entity/LdapServer.php | 56 ++++ .../src/Model/Table/LdapServersTable.php | 246 ++++++++++++++++++ .../templates/LdapServers/fields.inc | 58 +++++ app/resources/locales/en_US/operation.po | 11 + app/src/Controller/ServersController.php | 58 ++++- app/src/Model/Table/ServersTable.php | 4 +- app/templates/Servers/columns.inc | 7 +- 10 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 app/plugins/CoreServer/src/Controller/LdapServersController.php create mode 100644 app/plugins/CoreServer/src/Model/Entity/LdapServer.php create mode 100644 app/plugins/CoreServer/src/Model/Table/LdapServersTable.php create mode 100644 app/plugins/CoreServer/templates/LdapServers/fields.inc diff --git a/app/plugins/CoreServer/config/plugin.json b/app/plugins/CoreServer/config/plugin.json index 9c5fce7c7..945314d17 100644 --- a/app/plugins/CoreServer/config/plugin.json +++ b/app/plugins/CoreServer/config/plugin.json @@ -2,6 +2,7 @@ "types": { "server": [ "HttpServers", + "LdapServers", "MatchServers", "Oauth2Servers", "SmtpServers", @@ -52,6 +53,20 @@ }, "clone_relation": true }, + "ldap_servers": { + "columns": { + "id": {}, + "server_id": {}, + "serverurl": { "type": "string", "size": 256 }, + "binddn": { "type": "string", "size": 256 }, + "password": { "type": "string", "size": 256 }, + "basedn": { "type": "string", "size": 256 }, + "group_basedn": { "type": "string", "size": 256 } + }, + "indexes": { + "ldap_servers_i1": { "columns": [ "server_id" ] } + } + }, "oauth2_servers": { "columns": { "id": {}, diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po index c6d7ebad1..8cbd3fc26 100644 --- a/app/plugins/CoreServer/resources/locales/en_US/core_server.po +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -161,4 +161,43 @@ msgid "field.SqlServers.type" msgstr "RDBMS Type" msgid "result.MatchServers.match.accepted" -msgstr "Match request requires administrator intervention, Match Request ID: {0}" \ No newline at end of file +msgstr "Match request requires administrator intervention, Match Request ID: {0}" + +msgid "error.LdapServers.connect" +msgstr "Failed to connect to LDAP server {0}" + +msgid "error.LdapServers.credentials" +msgstr "Missing authentication credentials for LDAP server {0}" + +msgid "error.LdapServers.serverurl.valid" +msgstr "Please enter a valid ldap or ldaps URL (e.g., ldaps://server:port)" + +msgid "field.LdapServers.serverurl" +msgstr "Server URL" + +msgid "field.LdapServers.serverurl.desc" +msgstr "LDAP server URL, e.g. ldap://ldap.example.org or ldaps://ldap.example.org" + +msgid "field.LdapServers.binddn" +msgstr "Bind DN" + +msgid "field.LdapServers.binddn.desc" +msgstr "DN to authenticate as to manage entries" + +msgid "field.LdapServers.password" +msgstr "Password" + +msgid "field.LdapServers.password.desc" +msgstr "Password to use for authentication" + +msgid "field.LdapServers.basedn" +msgstr "People Base DN" + +msgid "field.LdapServers.basedn.desc" +msgstr "Base DN to search/provision People under" + +msgid "field.LdapServers.group_basedn" +msgstr "Group Base DN" + +msgid "field.LdapServers.group_basedn.desc" +msgstr "Base DN to provision Group entries under (requires groupOfNames objectclass)" diff --git a/app/plugins/CoreServer/src/Controller/LdapServersController.php b/app/plugins/CoreServer/src/Controller/LdapServersController.php new file mode 100644 index 000000000..4de39310d --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/LdapServersController.php @@ -0,0 +1,61 @@ + [ + 'LdapServers.serverurl' => 'asc' + ] + ]; + + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response|null|void + * @since COmanage Registry v5.2.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->LdapServers->Servers->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->LdapServers->Servers->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->LdapServers->Servers->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/CoreServer/src/Model/Entity/LdapServer.php b/app/plugins/CoreServer/src/Model/Entity/LdapServer.php new file mode 100644 index 000000000..774ffac98 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Entity/LdapServer.php @@ -0,0 +1,56 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Fields that are excluded from JSON versions of the entity. + * + * @var array + */ + protected array $_hidden = [ + 'password' + ]; +} \ No newline at end of file diff --git a/app/plugins/CoreServer/src/Model/Table/LdapServersTable.php b/app/plugins/CoreServer/src/Model/Table/LdapServersTable.php new file mode 100644 index 000000000..30285d76e --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/LdapServersTable.php @@ -0,0 +1,246 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Servers'); + + $this->setDisplayField('serverurl'); + + $this->setPrimaryLink('server_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'testconnection' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Establish a connection to the specified LDAP server. + * + * @param int $serverId Server ID + * @return \LDAP\Connection|resource Connected and bound LDAP resource + * @throws \InvalidArgumentException + * @throws \RuntimeException + * @since COmanage Registry v5.2.0 + */ + protected function connect(int $serverId): bool + { + $server = $this->Servers->get($serverId, contain: ['LdapServers']); + + if($server->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Servers', [1]), $serverId])); + } + + $this->cxn = @ldap_connect($server->ldap_server->serverurl); + + if(!$this->cxn) { + $this->logLdapError($this->cxn, 'ldap_connect', [ + 'serverurl' => $server->ldap_server?->serverurl + ]); + throw new \RuntimeException(__d('core_server', 'error.LdapServers.connect', [$server->ldap_server->serverurl])); + } + + // Always use LDAP v3 + ldap_set_option($this->cxn, LDAP_OPT_PROTOCOL_VERSION, self::LDAP_PROTOCOL_VERSION); + + // Set network timeout (the old plugin used a default of 10 seconds) + ldap_set_option($this->cxn, LDAP_OPT_NETWORK_TIMEOUT, self::NETWORK_TIMEOUT); + + // Bind if credentials are provided + if(empty($server->ldap_server->binddn) || empty($server->ldap_server->password)) { + throw new \RuntimeException(ldap_error($this->cxn)); + } + + if(!@ldap_bind($this->cxn, $server->ldap_server->binddn, $server->ldap_server->password)) { + $this->logLdapError($this->cxn, 'ldap_bind', [ + 'binddn' => $server->ldap_server?->binddn, + ]); + throw new \RuntimeException(ldap_error($this->cxn), ldap_errno($this->cxn)); + } + + return true; + } + + + /** + * Retrieve the current LDAP connection object or its status. + * + * This method returns the current LDAP connection object if established, + * or a boolean status indicating the connection's state. + * + * @param int $serverId Server ID + * + * @return \LDAP\Connection|bool The LDAP connection object or false if not connected. + * @since COmanage Registry v5.2.0 + */ + public function getLdapConnection(int $serverId): \LDAP\Connection|bool + { + if (empty($this->cxn)) { + $this->connect($serverId); + } + return $this->cxn; + } + + /** + * Test connectivity to the LDAP server. + * + * @param int $serverId Server ID + * @param string $name Name of the connection test (for logging) + * + * @return bool True if the connection succeeds, false otherwise + * @since COmanage Registry v5.2.0 + */ + public function checkConnectivity(int $serverId, string $name): bool + { + try { + $cxn = $this->getLdapConnection($serverId); + if ($cxn) { + return true; + } + } catch (\Exception $e) { + Log::error(__METHOD__ . "::Connection test '{$name}' failed: " . $e->getMessage()); + } + + return false; + } + + /** + * Log useful information after a crash + * + * @param \LDAP\Connection $cxn LDAP Server connection object + * @param string $functionName LDAP Server method name + * @param array $functionParameters LDAP Server method list of parameters + * + * @return void + * @since COmanage Registry v5.2.0 + */ + public function logLdapError(\LDAP\Connection $cxn, string $functionName, array $functionParameters = []): void { + $context = [ + 'method' => $functionName, + 'parameters' => $functionParameters, + 'error_code' => ldap_errno($cxn), + 'error_message' => ldap_err2str(ldap_errno($cxn)) + ]; + + ldap_get_option($cxn, LDAP_OPT_DIAGNOSTIC_MESSAGE, $err); + if (!empty($err)) { + $context['diagnostic_message'] = $err; + } + + Log::error(__METHOD__ . "::LDAP error during $functionName", $context); + } + + /** + * Set validation rules. + * + * @param Validator $validator Validator + * @return Validator Validator + * @since COmanage Registry v5.2.0 + */ + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $this->registerStringValidation($validator, $schema, 'serverurl', true); + + $validator->add('serverurl', [ + 'validUrl' => [ + 'rule' => ['custom', '/^ldaps?:\/\/.*/'], + 'message' => __d('core_server', 'error.LdapServers.serverurl.valid') + ] + ]); + + $this->registerStringValidation($validator, $schema, 'binddn', true); + $this->registerStringValidation($validator, $schema, 'password', true); + + // Core base DN (used for People) + $this->registerStringValidation($validator, $schema, 'basedn', true); + + // Group base DN + $this->registerStringValidation($validator, $schema, 'group_basedn', false); + + return $validator; + } +} diff --git a/app/plugins/CoreServer/templates/LdapServers/fields.inc b/app/plugins/CoreServer/templates/LdapServers/fields.inc new file mode 100644 index 000000000..773befbf4 --- /dev/null +++ b/app/plugins/CoreServer/templates/LdapServers/fields.inc @@ -0,0 +1,58 @@ + 'fact_check', + 'order' => 'Default', + 'label' => __d('operation', 'connection.test'), + 'url' => [ + 'plugin' => null, + 'controller' => 'Servers', + 'action' => 'testconnection', + $vv_obj?->server_id + ], + 'class' => '', + ], +]; + +$fields = [ + 'serverurl', + 'binddn', + 'password', + 'basedn', + 'group_basedn' +]; + +$subnav = [ + 'tabs' => ['Servers', 'CoreServer.LdapServers'], + 'action' => [ + 'Servers' => ['edit'], + 'CoreServer.LdapServers' => ['edit'] + ] +]; diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index f1c2a522e..f9c226b7f 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -399,3 +399,14 @@ msgstr "View Petition {0}" msgid "view.ExternalIdentityRoles.a" msgstr "View Role {0}" +msgid "connection.test" +msgstr "Test Connection" + +msgid "rs.test.ok" +msgstr "Connection OK" + +msgid "rs.test.error" +msgstr "Connection Error" + +msgid "rs.test.na" +msgstr "Connection Test NOT Supported" diff --git a/app/src/Controller/ServersController.php b/app/src/Controller/ServersController.php index 335d0df83..221a0f63d 100644 --- a/app/src/Controller/ServersController.php +++ b/app/src/Controller/ServersController.php @@ -30,6 +30,7 @@ namespace App\Controller; // XXX not doing anything with Log yet +use App\Lib\Util\StringUtilities; use Cake\Log\Log; class ServersController extends StandardPluggableController { @@ -38,4 +39,59 @@ class ServersController extends StandardPluggableController { 'Servers.description' => 'asc' ] ]; -} \ No newline at end of file + + /** + * Test the connection to a configured server by attempting to establish a connection + * using the server's plugin-specific connection method. + * + * @param string $id Server identifier + * @return \Cake\Http\Response|null Redirects to the index view with the connection test result + * @throws \Cake\Datasource\Exception\RecordNotFoundException If server not found + */ + public function testconnection(string $id): ?\Cake\Http\Response + { + /** var Cake\ORM\Table $table */ + $table = $this->getCurrentTable(); + + $serverId = $this->request->getParam('pass')[0]; + $serverObj = $table->findById($serverId) + ->firstOrFail(); + + $pluginTable = $this->getTableLocator()->get($serverObj->plugin); + + // We need to find the plugin record for this server before we can test connectivity + $pluginObj = $pluginTable->find() + ->where([StringUtilities::tableToForeignKey($table) => $serverId]) + ->firstOrFail(); + + try { + if (method_exists($pluginTable, 'checkConnectivity')) { + // Set the Connection Manager config + $connection = $pluginTable->checkConnectivity((int)$serverId, 'server' . $serverId); + + if ($connection) { + $this->Flash->success(__d('operation', 'rs.test.ok')); + Log::debug("Successfully connected to server " . $serverId); + } else { + $this->Flash->error(__d('operation', 'rs.test.error')); + Log::error("Failed to connect to server " . $serverId . ": Connection failed"); + } + } else { + $this->Flash->information(__d('operation', 'rs.test.na')); + } + } catch (\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $fallbackRedirectToIndexPage = [ + 'controller' => 'Servers', + 'action' => 'index', + '?' => [ + 'co_id' => $serverObj->co_id + ] + ]; + + // Redirect to the referring page, with a fallback to the Servers index + return $this->redirect($this->referer($fallbackRedirectToIndexPage)); + } +} diff --git a/app/src/Model/Table/ServersTable.php b/app/src/Model/Table/ServersTable.php index 76c6d3a0c..2b0c3b8a2 100644 --- a/app/src/Model/Table/ServersTable.php +++ b/app/src/Model/Table/ServersTable.php @@ -87,6 +87,7 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['testconnection']); $this->setAutoViewVars([ 'plugins' => [ @@ -105,7 +106,8 @@ public function initialize(array $config): void { 'configure' => ['platformAdmin', 'coAdmin'], 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] + 'view' => ['platformAdmin', 'coAdmin'], + 'testconnection' => ['platformAdmin', 'coAdmin'], ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ diff --git a/app/templates/Servers/columns.inc b/app/templates/Servers/columns.inc index 28f2df24b..472638318 100644 --- a/app/templates/Servers/columns.inc +++ b/app/templates/Servers/columns.inc @@ -47,5 +47,10 @@ $rowActions = [ 'action' => 'configure', 'label' => __d('operation', 'configure.plugin'), 'icon' => 'electrical_services' - ] + ], + [ + 'action' => 'testconnection', + 'label' => __d('operation', 'connection.test'), + 'icon' => 'fact_check' + ], ];