From 04946b2be356728b8d8fbab22083f7e03e27d451 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 17 Mar 2026 14:11:44 +0000 Subject: [PATCH 1/9] 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 | 12 + app/src/Controller/ServersController.php | 59 ++++- app/src/Model/Table/ServersTable.php | 4 +- app/templates/Servers/columns.inc | 7 +- 10 files changed, 555 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 fda4e7142..cfd9844fd 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -407,3 +407,15 @@ 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..208332731 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,60 @@ 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 + * @since COmanage Registry v5.2.0 + */ + 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' + ], ]; From d52cd88bf612e7e2e594590389efb77d19f036d4 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 3 May 2026 08:58:43 +0300 Subject: [PATCH 2/9] Added common ldap server codes enum file. Added basic transaction methods supports from the ldap server. Fixed ldap connection cache. --- .../src/Lib/Enum/LdapCommonCodesEnum.php | 38 +++ .../src/Model/Table/LdapServersTable.php | 294 +++++++++++++++--- 2 files changed, 297 insertions(+), 35 deletions(-) create mode 100644 app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php diff --git a/app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php b/app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php new file mode 100644 index 000000000..46b197127 --- /dev/null +++ b/app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php @@ -0,0 +1,38 @@ + */ - private \LDAP\Connection|bool $cxn; + private array $cxnByServerId = []; /** * Perform Cake Model initialization. * * @param array $config Configuration options passed to constructor - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.3.0 */ public function initialize(array $config): void { parent::initialize($config); @@ -68,7 +68,7 @@ public function initialize(array $config): void { $this->addBehavior('Log'); $this->addBehavior('Timestamp'); - $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + $this->setTableType(TableTypeEnum::Configuration); // Define associations $this->belongsTo('Servers'); @@ -93,13 +93,13 @@ public function initialize(array $config): void { } /** - * Establish a connection to the specified LDAP server. + * Establish a connection to the specified LDAP server and cache it internally. * * @param int $serverId Server ID - * @return \LDAP\Connection|resource Connected and bound LDAP resource + * @return bool True if connected and bound successfully * @throws \InvalidArgumentException * @throws \RuntimeException - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.3.0 */ protected function connect(int $serverId): bool { @@ -109,54 +109,63 @@ protected function connect(int $serverId): bool throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Servers', [1]), $serverId])); } - $this->cxn = @ldap_connect($server->ldap_server->serverurl); + $cxn = @ldap_connect($server->ldap_server->serverurl); - if(!$this->cxn) { - $this->logLdapError($this->cxn, 'ldap_connect', [ - 'serverurl' => $server->ldap_server?->serverurl + if(!$cxn) { + $this->logLdapError($cxn, 'ldap_connect', [ + 'serverurl' => $server->ldap_server->serverurl ?? null ]); - throw new \RuntimeException(__d('core_server', 'error.LdapServers.connect', [$server->ldap_server->serverurl])); + + throw new \RuntimeException( + __d('core_server', 'error.LdapServers.connect', [$server->ldap_server->serverurl]), + LdapCommonCodesEnum::LDAP_CONNECT_ERROR + ); } // Always use LDAP v3 - ldap_set_option($this->cxn, LDAP_OPT_PROTOCOL_VERSION, self::LDAP_PROTOCOL_VERSION); + ldap_set_option($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); + // Set network timeout + ldap_set_option($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)); + throw new \RuntimeException(ldap_error($cxn)); } - if(!@ldap_bind($this->cxn, $server->ldap_server->binddn, $server->ldap_server->password)) { - $this->logLdapError($this->cxn, 'ldap_bind', [ + if(!@ldap_bind($cxn, $server->ldap_server->binddn, $server->ldap_server->password)) { + $this->logLdapError($cxn, 'ldap_bind', [ 'binddn' => $server->ldap_server?->binddn, ]); - throw new \RuntimeException(ldap_error($this->cxn), ldap_errno($this->cxn)); + throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); } + $this->cxnByServerId[$serverId] = $cxn; + return true; } /** - * Retrieve the current LDAP connection object or its status. + * Retrieve (and if necessary establish) an LDAP connection for the specified server. * - * This method returns the current LDAP connection object if established, - * or a boolean status indicating the connection's state. + * This method returns a cached LDAP connection for the given server ID. If no + * connection is currently cached, it will attempt to connect and bind by invoking + * connect($serverId), which is expected to populate the internal connection cache. * * @param int $serverId Server ID - * - * @return \LDAP\Connection|bool The LDAP connection object or false if not connected. - * @since COmanage Registry v5.2.0 + * @return \LDAP\Connection|bool The LDAP connection object on success, or false if not connected. + * @throws \InvalidArgumentException If the server is not active or is otherwise invalid. + * @throws \RuntimeException If the connection or bind fails. + * @since COmanage Registry v5.3.0 */ public function getLdapConnection(int $serverId): \LDAP\Connection|bool { - if (empty($this->cxn)) { + if (empty($this->cxnByServerId[$serverId])) { $this->connect($serverId); } - return $this->cxn; + + return $this->cxnByServerId[$serverId]; } /** @@ -166,7 +175,7 @@ public function getLdapConnection(int $serverId): \LDAP\Connection|bool * @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 + * @since COmanage Registry v5.3.0 */ public function checkConnectivity(int $serverId, string $name): bool { @@ -182,6 +191,221 @@ public function checkConnectivity(int $serverId, string $name): bool return false; } + /** + * Search an LDAP server. + * + * This method wraps ldap_search() with standardized error handling and logging. + * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). + * + * @param Connection $cxn Active LDAP connection (already connected and bound). + * @param string $baseDn Base DN for the search. + * @param string $filter LDAP search filter. + * @param array $attributes Attributes to return. + * @return array|bool LDAP search results (ldap_get_entries() format). + * @since COmanage Registry v5.3.0 + */ + public function queryLdap( + \LDAP\Connection $cxn, + string $baseDn, + string $filter, + array $attributes = [] + ): array|bool { + $currentErrorReporting = error_reporting(0); + $s = @ldap_search($cxn, $baseDn, $filter, $attributes); + error_reporting($currentErrorReporting); + + if (!$s) { + $this->logLdapError($cxn, 'ldap_search', [ + 'baseDN' => $baseDn, + 'filter' => $filter, + 'attributes' => $attributes + ]); + + throw new \RuntimeException(ldap_error($cxn) . " (" . $baseDn . ")", ldap_errno($cxn)); + } + + return ldap_get_entries($cxn, $s); + } + + /** + * Add an LDAP entry. + * + * This method wraps ldap_add() with standardized error handling and logging. + * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). + * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). + * @param string $dn DN of the entry to create. + * @param array $attributes LDAP attributes to set on the new entry. + * @return void + * @throws \RuntimeException On LDAP add failure. + * @since COmanage Registry v5.3.0 + */ + public function addEntry(\LDAP\Connection $cxn, string $dn, array $attributes): void { + $currentErrorReporting = error_reporting(0); + $ok = @ldap_add($cxn, $dn, $attributes); + error_reporting($currentErrorReporting); + + if (!$ok) { + $this->logLdapError($cxn, 'ldap_add', [ + 'dn' => $dn, + 'attributes' => $attributes + ]); + + throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + } + } + + /** + * Delete an LDAP entry. + * + * This method wraps ldap_delete() with standardized error handling and logging. + * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). + * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). + * @param string $dn DN of the entry to delete. + * @return void + * @throws \RuntimeException On LDAP delete failure. + * @since COmanage Registry v5.3.0 + */ + public function deleteEntry(\LDAP\Connection $cxn, string $dn): void { + $currentErrorReporting = error_reporting(0); + $ok = @ldap_delete($cxn, $dn); + error_reporting($currentErrorReporting); + + if (!$ok) { + $this->logLdapError($cxn, 'ldap_delete', [ + 'dn' => $dn + ]); + + throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + } + } + + /** + * Modify (replace) LDAP attributes on an entry. + * + * This method wraps ldap_mod_replace() with standardized error handling and logging. + * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). + * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). + * @param string $dn DN of the entry to modify. + * @param array $attributes LDAP attributes to replace. + * @return void + * @throws \RuntimeException On LDAP modify failure. + * @since COmanage Registry v5.3.0 + */ + public function modReplace(\LDAP\Connection $cxn, string $dn, array $attributes): void { + $currentErrorReporting = error_reporting(0); + $ok = @ldap_mod_replace($cxn, $dn, $attributes); + error_reporting($currentErrorReporting); + + if (!$ok) { + $this->logLdapError($cxn, 'ldap_mod_replace', [ + 'dn' => $dn, + 'attributes' => $attributes + ]); + + throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + } + } + + /** + * Rename (move/rename) an LDAP entry. + * + * This method wraps ldap_rename() with standardized error handling and logging. + * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). + * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). + * @param string $oldDn Current (full) DN of the entry. + * @param string $newRdn New RDN (relative distinguished name) for the entry. + * @param string|null $newParentDn New parent DN, or null to keep the same parent. + * @param bool $deleteOldRdn Whether to delete the old RDN value from the entry. + * @return void + * @throws \RuntimeException On LDAP rename failure. + * @since COmanage Registry v5.3.0 + */ + public function renameEntry( + \LDAP\Connection $cxn, + string $oldDn, + string $newRdn, + ?string $newParentDn = null, + bool $deleteOldRdn = true + ): void { + $currentErrorReporting = error_reporting(0); + $ok = @ldap_rename($cxn, $oldDn, $newRdn, $newParentDn, $deleteOldRdn); + error_reporting($currentErrorReporting); + + if (!$ok) { + $this->logLdapError($cxn, 'ldap_rename', [ + 'oldDn' => $oldDn, + 'newRdn' => $newRdn, + 'newParentDn' => $newParentDn, + 'deleteOldRdn' => $deleteOldRdn + ]); + + throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + } + } + + /** + * Read a single LDAP entry. + * + * This is a convenience wrapper around queryLdap() that returns the first entry + * (index 0) or null if no entry exists at the requested DN. + * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). + * @param string $dn DN to read. + * @param array $attributes Attributes to return. + * @return array|null The first LDAP entry (ldap_get_entries() entry format) or null if not found. + * @throws \RuntimeException On LDAP search failure. + * @since COmanage Registry v5.3.0 + */ + public function readEntry(\LDAP\Connection $cxn, string $dn, array $attributes = []): ?array { + $results = $this->queryLdap($cxn, $dn, '(objectclass=*)', $attributes); + + if (empty($results['count']) || (int)$results['count'] < 1) { + return null; + } + + return $results[0] ?? null; + } + + /** + * Disconnect (unbind) an LDAP connection for a given server ID. + * + * This method unbinds the cached connection (if any) and removes it from the internal cache. + * It does not throw on unbind failure (unbind failures are generally not actionable). + * + * @param int $serverId Server ID. + * @return void + * @since COmanage Registry v5.3.0 + */ + public function disconnect(int $serverId): void { + if (empty($this->cxnByServerId[$serverId])) { + return; + } + + $cxn = $this->cxnByServerId[$serverId]; + + $currentErrorReporting = error_reporting(0); + @ldap_unbind($cxn); + error_reporting($currentErrorReporting); + + unset($this->cxnByServerId[$serverId]); + } + + /** + * Disconnect (unbind) all cached LDAP connections. + * + * @return void + * @since COmanage Registry v5.3.0 + */ + public function disconnectAll(): void { + foreach (array_keys($this->cxnByServerId) as $serverId) { + $this->disconnect((int)$serverId); + } + } + /** * Log useful information after a crash * @@ -190,7 +414,7 @@ public function checkConnectivity(int $serverId, string $name): bool * @param array $functionParameters LDAP Server method list of parameters * * @return void - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.3.0 */ public function logLdapError(\LDAP\Connection $cxn, string $functionName, array $functionParameters = []): void { $context = [ @@ -213,7 +437,7 @@ public function logLdapError(\LDAP\Connection $cxn, string $functionName, array * * @param Validator $validator Validator * @return Validator Validator - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.3.0 */ public function validationDefault(Validator $validator): Validator { $schema = $this->getSchema(); From db07f2e634375c594630d665780ea27fc91fc1cd Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 8 May 2026 11:58:28 +0300 Subject: [PATCH 3/9] Add more ldap server helper function for interacting with the server. Add more LdapCommon codes. --- .../src/Lib/Enum/LdapCommonCodesEnum.php | 6 +- .../src/Model/Table/LdapServersTable.php | 393 ++++++++++++++++-- 2 files changed, 356 insertions(+), 43 deletions(-) diff --git a/app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php b/app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php index 46b197127..73ede9943 100644 --- a/app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php +++ b/app/plugins/CoreServer/src/Lib/Enum/LdapCommonCodesEnum.php @@ -33,6 +33,8 @@ class LdapCommonCodesEnum extends StandardEnum { - const LDAP_NO_SUCH_OBJECT = 0x20; /* 32 */ - const LDAP_CONNECT_ERROR = 0x5b; + const int LDAP_NO_SUCH_OBJECT = 0x20; // 32 + const int LDAP_ENTRY_ALREADY_EXISTS = 0x44; // 68 + // Application/internal code (not from ldap_errno()): + const int LDAP_CONNECT_ERROR = 0x5b; // 91 (internal) } diff --git a/app/plugins/CoreServer/src/Model/Table/LdapServersTable.php b/app/plugins/CoreServer/src/Model/Table/LdapServersTable.php index 5d637c7c8..6fa7d0b3e 100644 --- a/app/plugins/CoreServer/src/Model/Table/LdapServersTable.php +++ b/app/plugins/CoreServer/src/Model/Table/LdapServersTable.php @@ -29,12 +29,12 @@ namespace CoreServer\Model\Table; -use App\Lib\Enum\LdapCommonCodesEnum; use App\Lib\Enum\SuspendableStatusEnum; use App\Lib\Enum\TableTypeEnum; use Cake\Log\Log; use Cake\ORM\Table; use Cake\Validation\Validator; +use CoreServer\Lib\Enum\LdapCommonCodesEnum; use LDAP\Connection; class LdapServersTable extends Table { @@ -95,10 +95,13 @@ public function initialize(array $config): void { /** * Establish a connection to the specified LDAP server and cache it internally. * - * @param int $serverId Server ID - * @return bool True if connected and bound successfully - * @throws \InvalidArgumentException - * @throws \RuntimeException + * On failure, this method logs server-side LDAP diagnostics (when available) + * and throws a RuntimeException whose message includes those diagnostics. + * + * @param int $serverId Server ID. + * @return bool True if connected and bound successfully. + * @throws \InvalidArgumentException If the server is inactive/invalid. + * @throws \RuntimeException If the connection or bind fails. * @since COmanage Registry v5.3.0 */ protected function connect(int $serverId): bool @@ -112,32 +115,42 @@ protected function connect(int $serverId): bool $cxn = @ldap_connect($server->ldap_server->serverurl); if(!$cxn) { - $this->logLdapError($cxn, 'ldap_connect', [ - 'serverurl' => $server->ldap_server->serverurl ?? null - ]); - throw new \RuntimeException( __d('core_server', 'error.LdapServers.connect', [$server->ldap_server->serverurl]), LdapCommonCodesEnum::LDAP_CONNECT_ERROR ); } - // Always use LDAP v3 ldap_set_option($cxn, LDAP_OPT_PROTOCOL_VERSION, self::LDAP_PROTOCOL_VERSION); - - // Set network timeout ldap_set_option($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($cxn)); + // Avoid logging secrets; note we also don't have a meaningful ldap_errno() here. + $this->logLdapError($cxn, 'ldap_bind', [ + 'serverurl' => $server->ldap_server->serverurl, + 'binddn' => $server->ldap_server->binddn, + 'reason' => 'missing bind credentials', + ]); + + throw new \RuntimeException($this->formatLdapFailure($cxn, 'ldap_bind', [ + 'serverurl' => $server->ldap_server->serverurl, + 'binddn' => $server->ldap_server->binddn, + ])); } if(!@ldap_bind($cxn, $server->ldap_server->binddn, $server->ldap_server->password)) { $this->logLdapError($cxn, 'ldap_bind', [ - 'binddn' => $server->ldap_server?->binddn, + 'serverurl' => $server->ldap_server->serverurl, + 'binddn' => $server->ldap_server->binddn, ]); - throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + + throw new \RuntimeException( + $this->formatLdapFailure($cxn, 'ldap_bind', [ + 'serverurl' => $server->ldap_server->serverurl, + 'binddn' => $server->ldap_server->binddn, + ]), + ldap_errno($cxn) + ); } $this->cxnByServerId[$serverId] = $cxn; @@ -154,12 +167,12 @@ protected function connect(int $serverId): bool * connect($serverId), which is expected to populate the internal connection cache. * * @param int $serverId Server ID - * @return \LDAP\Connection|bool The LDAP connection object on success, or false if not connected. + * @return \LDAP\Connection The LDAP connection object on success, or false if not connected. * @throws \InvalidArgumentException If the server is not active or is otherwise invalid. * @throws \RuntimeException If the connection or bind fails. * @since COmanage Registry v5.3.0 */ - public function getLdapConnection(int $serverId): \LDAP\Connection|bool + public function getLdapConnection(int $serverId): \LDAP\Connection { if (empty($this->cxnByServerId[$serverId])) { $this->connect($serverId); @@ -197,11 +210,15 @@ public function checkConnectivity(int $serverId, string $name): bool * This method wraps ldap_search() with standardized error handling and logging. * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). * - * @param Connection $cxn Active LDAP connection (already connected and bound). + * On failure, this method logs baseDN/filter/attributes and throws a RuntimeException + * whose message includes the LDAP diagnostic message when available. + * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). * @param string $baseDn Base DN for the search. * @param string $filter LDAP search filter. - * @param array $attributes Attributes to return. - * @return array|bool LDAP search results (ldap_get_entries() format). + * @param array $attributes Attributes to return. + * @return array LDAP search results (ldap_get_entries() format). + * @throws \RuntimeException On LDAP search failure. * @since COmanage Registry v5.3.0 */ public function queryLdap( @@ -209,7 +226,7 @@ public function queryLdap( string $baseDn, string $filter, array $attributes = [] - ): array|bool { + ): array { $currentErrorReporting = error_reporting(0); $s = @ldap_search($cxn, $baseDn, $filter, $attributes); error_reporting($currentErrorReporting); @@ -218,10 +235,17 @@ public function queryLdap( $this->logLdapError($cxn, 'ldap_search', [ 'baseDN' => $baseDn, 'filter' => $filter, - 'attributes' => $attributes + 'attributes' => $attributes, ]); - throw new \RuntimeException(ldap_error($cxn) . " (" . $baseDn . ")", ldap_errno($cxn)); + throw new \RuntimeException( + $this->formatLdapFailure($cxn, 'ldap_search', [ + 'baseDN' => $baseDn, + 'filter' => $filter, + 'attributes' => $attributes, + ]), + ldap_errno($cxn) + ); } return ldap_get_entries($cxn, $s); @@ -233,14 +257,20 @@ public function queryLdap( * This method wraps ldap_add() with standardized error handling and logging. * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). * + * On failure, logs dn + attribute_keys (and full attributes) and throws a RuntimeException + * whose message includes the LDAP diagnostic message when available. + * * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). * @param string $dn DN of the entry to create. - * @param array $attributes LDAP attributes to set on the new entry. + * @param array $attributes LDAP attributes to set on the new entry. * @return void * @throws \RuntimeException On LDAP add failure. * @since COmanage Registry v5.3.0 */ public function addEntry(\LDAP\Connection $cxn, string $dn, array $attributes): void { + $attributes = $this->sanitizeAttributesForAdd($attributes); + $attrKeys = array_keys($attributes); + $currentErrorReporting = error_reporting(0); $ok = @ldap_add($cxn, $dn, $attributes); error_reporting($currentErrorReporting); @@ -248,19 +278,49 @@ public function addEntry(\LDAP\Connection $cxn, string $dn, array $attributes): if (!$ok) { $this->logLdapError($cxn, 'ldap_add', [ 'dn' => $dn, - 'attributes' => $attributes + 'attribute_keys' => $attrKeys, + 'attributes' => $attributes, ]); - throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + throw new \RuntimeException( + $this->formatLdapFailure($cxn, 'ldap_add', [ + 'dn' => $dn, + 'attribute_keys' => $attrKeys, + ]), + ldap_errno($cxn) + ); } } + /** + * Sanitize attributes for ldap_add(). + * + * Some LDAP servers reject attributes with empty array values on add (eg ['owner' => []]). + * For modify/rename we intentionally allow empty arrays to mean "clear attribute", + * but for add we should omit such attributes entirely. + * + * @param array $attributes + * @return array + */ + protected function sanitizeAttributesForAdd(array $attributes): array { + foreach ($attributes as $attr => $value) { + if (is_array($value) && empty($value)) { + unset($attributes[$attr]); + } + } + + return $attributes; + } + /** * Delete an LDAP entry. * * This method wraps ldap_delete() with standardized error handling and logging. * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). * + * On failure, logs dn and throws a RuntimeException whose message includes the + * LDAP diagnostic message when available. + * * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). * @param string $dn DN of the entry to delete. * @return void @@ -274,10 +334,15 @@ public function deleteEntry(\LDAP\Connection $cxn, string $dn): void { if (!$ok) { $this->logLdapError($cxn, 'ldap_delete', [ - 'dn' => $dn + 'dn' => $dn, ]); - throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + throw new \RuntimeException( + $this->formatLdapFailure($cxn, 'ldap_delete', [ + 'dn' => $dn, + ]), + ldap_errno($cxn) + ); } } @@ -285,11 +350,12 @@ public function deleteEntry(\LDAP\Connection $cxn, string $dn): void { * Modify (replace) LDAP attributes on an entry. * * This method wraps ldap_mod_replace() with standardized error handling and logging. - * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). + * On failure, it logs details via {@see logLdapError()} and throws a RuntimeException + * that includes the LDAP diagnostic message when available. * * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). * @param string $dn DN of the entry to modify. - * @param array $attributes LDAP attributes to replace. + * @param array $attributes LDAP attributes to replace. * @return void * @throws \RuntimeException On LDAP modify failure. * @since COmanage Registry v5.3.0 @@ -300,12 +366,21 @@ public function modReplace(\LDAP\Connection $cxn, string $dn, array $attributes) error_reporting($currentErrorReporting); if (!$ok) { + $attrKeys = array_keys($attributes); + $this->logLdapError($cxn, 'ldap_mod_replace', [ 'dn' => $dn, + 'attribute_keys' => $attrKeys, 'attributes' => $attributes ]); - throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + throw new \RuntimeException( + $this->formatLdapFailure($cxn, 'ldap_mod_replace', [ + 'dn' => $dn, + 'attribute_keys' => $attrKeys, + ]), + ldap_errno($cxn) + ); } } @@ -315,6 +390,9 @@ public function modReplace(\LDAP\Connection $cxn, string $dn, array $attributes) * This method wraps ldap_rename() with standardized error handling and logging. * The caller is responsible for obtaining a bound connection (eg via getLdapConnection()). * + * On failure, logs oldDn/newRdn/newParentDn/deleteOldRdn and throws a RuntimeException + * whose message includes the LDAP diagnostic message when available. + * * @param \LDAP\Connection $cxn Active LDAP connection (already connected and bound). * @param string $oldDn Current (full) DN of the entry. * @param string $newRdn New RDN (relative distinguished name) for the entry. @@ -340,10 +418,18 @@ public function renameEntry( 'oldDn' => $oldDn, 'newRdn' => $newRdn, 'newParentDn' => $newParentDn, - 'deleteOldRdn' => $deleteOldRdn + 'deleteOldRdn' => $deleteOldRdn, ]); - throw new \RuntimeException(ldap_error($cxn), ldap_errno($cxn)); + throw new \RuntimeException( + $this->formatLdapFailure($cxn, 'ldap_rename', [ + 'oldDn' => $oldDn, + 'newRdn' => $newRdn, + 'newParentDn' => $newParentDn, + 'deleteOldRdn' => $deleteOldRdn, + ]), + ldap_errno($cxn) + ); } } @@ -370,6 +456,180 @@ public function readEntry(\LDAP\Connection $cxn, string $dn, array $attributes = return $results[0] ?? null; } + /** + * Apply a standard LDAP provisioning plan (add/modify/rename/delete). + * + * Optimistically performs the smallest LDAP operation needed and falls back + * based on actual server error codes (no upfront existence reads). Lives on + * \CoreServer\Model\Table\LdapServersTable. + * + * Decision tree: + * - delete: deleteEntry(oldDn|newDn); ignore noSuchObject. + * - rename requested: renameEntry(oldDn -> newRdn); on noSuchObject -> add at newDn, + * on entryAlreadyExists -> modReplace at newDn, + * on success -> modReplace at newDn. + * - modify (default): modReplace(newDn); on noSuchObject -> addEntry(newDn). + * - add: addEntry(newDn); on entryAlreadyExists -> modReplace(newDn). + * + * @param \LDAP\Connection $cxn Bound LDAP connection. + * @param string|null $oldDn Previously provisioned DN (may be null). + * @param string|null $newDn Target DN (required for non-delete operations). + * @param string $baseDn Base DN used to derive RDN on rename (may be empty). + * @param array $attributes LDAP attributes for add/replace. + * @param array $options { + * @var bool $delete If true, delete oldDn (or newDn) and return. + * @var bool $allowRename If true, allow rename when oldDn != newDn. + * @var bool $deleteOldRdn Passed to ldap_rename (default true). + * @var bool $ignoreNoSuchObjectOnDelete Default true. + * @var bool $applyAttributesAfterRename Default true. + * } + * @return array{action:string,dn:?string} + * action ∈ {'delete','rename','modify','add','noop'} + * @throws \InvalidArgumentException If non-delete and $newDn is missing. + * @throws \RuntimeException On LDAP failures not handled by fallback. + */ + public function applyProvisioningPlan( + \LDAP\Connection $cxn, + ?string $oldDn, + ?string $newDn, + string $baseDn, + array $attributes, + array $options = [] + ): array { + $delete = (bool)($options['delete'] ?? false); + $allowRename = (bool)($options['allowRename'] ?? true); + $deleteOldRdn = (bool)($options['deleteOldRdn'] ?? true); + $ignoreNoSuchOnDelete = (bool)($options['ignoreNoSuchObjectOnDelete'] ?? true); + $applyAfterRename = (bool)($options['applyAttributesAfterRename'] ?? true); + + $oldDn = ($oldDn !== null && trim($oldDn) !== '') ? trim($oldDn) : null; + $newDn = ($newDn !== null && trim($newDn) !== '') ? trim($newDn) : null; + + // ---- DELETE ---- + if ($delete) { + $dnToDelete = $oldDn ?? $newDn; + + if ($dnToDelete === null) { + return ['action' => 'noop', 'dn' => null]; + } + + try { + $this->deleteEntry($cxn, $dnToDelete); + } catch (\RuntimeException $e) { + if ( + !$ignoreNoSuchOnDelete + || (int)$e->getCode() !== LdapCommonCodesEnum::LDAP_NO_SUCH_OBJECT + ) { + throw $e; + } + } + + return ['action' => 'delete', 'dn' => $dnToDelete]; + } + + if ($newDn === null) { + throw new \InvalidArgumentException('applyProvisioningPlan requires $newDn for non-delete operations'); + } + + // Treat DNs as case-insensitive for the rename test + $renameRequested = $allowRename + && $oldDn !== null + && strcasecmp($oldDn, $newDn) !== 0; + + // ---- RENAME ---- + if ($renameRequested) { + $newRdn = $this->computeRelativeRdn($newDn, $baseDn); + + try { + $this->renameEntry( + $cxn, + oldDn: $oldDn, + newRdn: $newRdn, + newParentDn: null, + deleteOldRdn: $deleteOldRdn + ); + + if ($applyAfterRename && !empty($attributes)) { + $this->modReplace($cxn, $newDn, $attributes); + } + + return ['action' => 'rename', 'dn' => $newDn]; + } catch (\RuntimeException $e) { + $code = (int)$e->getCode(); + + // Source DN doesn't exist anymore (or never did) -> just create it at newDn. + if ($code === LdapCommonCodesEnum::LDAP_NO_SUCH_OBJECT) { + return $this->addOrModifyAt($cxn, $newDn, $attributes); + } + + // Target already exists (someone else got there first or stale state) -> modify in place. + if ($code === LdapCommonCodesEnum::LDAP_ENTRY_ALREADY_EXISTS) { + $this->modReplace($cxn, $newDn, $attributes); + return ['action' => 'modify', 'dn' => $newDn]; + } + + throw $e; + } + } + + // ---- MODIFY (default), with add fallback ---- + return $this->addOrModifyAt($cxn, $newDn, $attributes); + } + + /** + * Try modify; if the entry doesn't exist, add it. + * If add races with another writer, fall back to modify. + * + * @param \LDAP\Connection $cxn + * @param string $dn + * @param array $attributes + * @return array{action:string,dn:string} + */ + protected function addOrModifyAt(\LDAP\Connection $cxn, string $dn, array $attributes): array { + try { + $this->modReplace($cxn, $dn, $attributes); + return ['action' => 'modify', 'dn' => $dn]; + } catch (\RuntimeException $e) { + if ((int)$e->getCode() !== LdapCommonCodesEnum::LDAP_NO_SUCH_OBJECT) { + throw $e; + } + } + + try { + $this->addEntry($cxn, $dn, $attributes); + return ['action' => 'add', 'dn' => $dn]; + } catch (\RuntimeException $e) { + if ((int)$e->getCode() !== 68 /* LDAP_ENTRY_ALREADY_EXISTS */) { + throw $e; + } + + // Race with another writer: entry exists now -> modify in place. + $this->modReplace($cxn, $dn, $attributes); + return ['action' => 'modify', 'dn' => $dn]; + } + } + + /** + * Compute the new RDN portion of $newDn relative to $baseDn. + * + * Performs a case-insensitive suffix match. If $baseDn is empty or not a suffix + * of $newDn, returns $newDn unchanged. + */ + protected function computeRelativeRdn(string $newDn, string $baseDn): string { + if ($baseDn === '') { + return $newDn; + } + + $suffix = ',' . $baseDn; + + if (strlen($newDn) > strlen($suffix) + && strcasecmp(substr($newDn, -strlen($suffix)), $suffix) === 0) { + return substr($newDn, 0, -strlen($suffix)); + } + + return $newDn; + } + /** * Disconnect (unbind) an LDAP connection for a given server ID. * @@ -407,21 +667,31 @@ public function disconnectAll(): void { } /** - * Log useful information after a crash + * Log useful information after an LDAP operation failure. * - * @param \LDAP\Connection $cxn LDAP Server connection object - * @param string $functionName LDAP Server method name - * @param array $functionParameters LDAP Server method list of parameters + * This method records: + * - the LDAP operation name (eg: ldap_mod_replace) + * - the call parameters (as provided by the caller) + * - the LDAP numeric error code (ldap_errno) + * - the server-provided error string (ldap_error) + * - the library error string for the code (ldap_err2str) + * - the optional diagnostic message (LDAP_OPT_DIAGNOSTIC_MESSAGE), if available * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected/bound). + * @param string $functionName LDAP function name being executed (eg: 'ldap_mod_replace'). + * @param array $functionParameters Parameters relevant to the operation (for debugging). * @return void - * @since COmanage Registry v5.3.0 + * @since COmanage Registry v5.3.0 */ public function logLdapError(\LDAP\Connection $cxn, string $functionName, array $functionParameters = []): void { + $errno = ldap_errno($cxn); + $context = [ 'method' => $functionName, 'parameters' => $functionParameters, - 'error_code' => ldap_errno($cxn), - 'error_message' => ldap_err2str(ldap_errno($cxn)) + 'error_code' => $errno, + 'error' => ldap_error($cxn), + 'error_string' => ldap_err2str($errno), ]; ldap_get_option($cxn, LDAP_OPT_DIAGNOSTIC_MESSAGE, $err); @@ -432,6 +702,47 @@ public function logLdapError(\LDAP\Connection $cxn, string $functionName, array Log::error(__METHOD__ . "::LDAP error during $functionName", $context); } + /** + * Format a detailed LDAP failure message for exception propagation. + * + * This is intended to improve debuggability when callers catch a RuntimeException + * and only have access to $e->getMessage(). It attempts to include: + * - ldap_error() (server message) + * - ldap_errno() and ldap_err2str() (code + meaning) + * - LDAP_OPT_DIAGNOSTIC_MESSAGE (if provided by the server) + * - a small JSON-encoded context payload (eg DN, attribute keys) + * + * @param \LDAP\Connection $cxn Active LDAP connection (already connected/bound). + * @param string $operation Operation label (eg: 'ldap_mod_replace'). + * @param array $context Optional additional context to embed in the message. + * @return string Human-readable, log-friendly error message. + * @since COmanage Registry v5.3.0 + */ + protected function formatLdapFailure(\LDAP\Connection $cxn, string $operation, array $context = []): string + { + $errno = ldap_errno($cxn); + + $msg = $operation + . ' failed: ' + . ldap_error($cxn) + . ' (code ' + . $errno + . ': ' + . ldap_err2str($errno) + . ')'; + + ldap_get_option($cxn, LDAP_OPT_DIAGNOSTIC_MESSAGE, $diag); + if (!empty($diag)) { + $msg .= '; diagnostic=' . (is_string($diag) ? $diag : json_encode($diag)); + } + + if (!empty($context)) { + $msg .= '; context=' . json_encode($context); + } + + return $msg; + } + /** * Set validation rules. * From bf616a2331251aba03513ed5cff0714f2359d9f6 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Fri, 8 May 2026 12:01:24 +0300 Subject: [PATCH 4/9] fix phpdoc since version number --- app/plugins/CoreServer/src/Controller/LdapServersController.php | 2 +- app/plugins/CoreServer/src/Model/Entity/LdapServer.php | 2 +- app/plugins/CoreServer/templates/LdapServers/fields.inc | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/plugins/CoreServer/src/Controller/LdapServersController.php b/app/plugins/CoreServer/src/Controller/LdapServersController.php index 4de39310d..58fb98325 100644 --- a/app/plugins/CoreServer/src/Controller/LdapServersController.php +++ b/app/plugins/CoreServer/src/Controller/LdapServersController.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.3.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ diff --git a/app/plugins/CoreServer/src/Model/Entity/LdapServer.php b/app/plugins/CoreServer/src/Model/Entity/LdapServer.php index 774ffac98..412234337 100644 --- a/app/plugins/CoreServer/src/Model/Entity/LdapServer.php +++ b/app/plugins/CoreServer/src/Model/Entity/LdapServer.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.3.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ diff --git a/app/plugins/CoreServer/templates/LdapServers/fields.inc b/app/plugins/CoreServer/templates/LdapServers/fields.inc index 773befbf4..1f745f46b 100644 --- a/app/plugins/CoreServer/templates/LdapServers/fields.inc +++ b/app/plugins/CoreServer/templates/LdapServers/fields.inc @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.2.0 + * @since COmanage Registry v5.3.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ From 2506b5afd63eff82a910673700e9109038d02d53 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Tue, 17 Mar 2026 14:11:44 +0000 Subject: [PATCH 5/9] LdapConnector/LdapProvisioner Plugin --- app/plugins/LdapConnector/config/plugin.json | 320 ++++++ .../resources/locales/en_US/ldap_connector.po | 122 ++ .../src/Controller/AppController.php | 36 + .../Controller/EduMemberSchemasController.php | 100 ++ .../Controller/EduPersonSchemasController.php | 103 ++ .../GroupOfNamesSchemasController.php | 86 ++ .../InetOrgPersonSchemasController.php | 131 +++ .../Controller/LdapProvisionersController.php | 95 ++ .../LdapPublicKeySchemasController.php | 84 ++ .../LdapSchemaAttributesController.php | 50 + .../src/Controller/LdapSchemasController.php | 137 +++ .../OrganizationalPersonSchemasController.php | 121 ++ .../Controller/PersonSchemasController.php | 84 ++ .../PosixAccountSchemasController.php | 84 ++ .../PosixGroupSchemasController.php | 84 ++ .../VoPosixAccountSchemasController.php | 84 ++ .../VoPosixGroupSchemasController.php | 84 ++ .../LdapConnector/src/LdapConnectorPlugin.php | 124 ++ .../Lib/Traits/LdapObjectClassSchemaTrait.php | 113 ++ .../LdapSchemaProvisioningConfigTrait.php | 264 +++++ .../Lib/Traits/SchemaAttributeExportTrait.php | 204 ++++ .../src/Lib/Traits/SchemaPluginCoIdTrait.php | 100 ++ .../src/Model/Entity/EduMemberSchema.php | 48 + .../src/Model/Entity/EduPersonSchema.php | 48 + .../src/Model/Entity/GroupOfNamesSchema.php | 48 + .../src/Model/Entity/InetOrgPersonSchema.php | 48 + .../src/Model/Entity/LdapProvisioner.php | 42 + .../src/Model/Entity/LdapProvisionerDn.php | 43 + .../src/Model/Entity/LdapPublicKeySchema.php | 48 + .../src/Model/Entity/LdapSchema.php | 42 + .../src/Model/Entity/LdapSchemaAttribute.php | 30 + .../Entity/OrganizationalPersonSchema.php | 43 + .../src/Model/Entity/PersonSchema.php | 42 + .../src/Model/Entity/PosixAccountSchema.php | 48 + .../src/Model/Entity/PosixGroupSchema.php | 48 + .../src/Model/Entity/VoPosixAccountSchema.php | 48 + .../src/Model/Entity/VoPosixGroupSchema.php | 48 + .../src/Model/Table/EduMemberSchemasTable.php | 378 ++++++ .../src/Model/Table/EduPersonSchemasTable.php | 312 +++++ .../Model/Table/GroupOfNamesSchemasTable.php | 417 +++++++ .../Model/Table/InetOrgPersonSchemasTable.php | 871 ++++++++++++++ .../Model/Table/LdapProvisionerDnsTable.php | 643 +++++++++++ .../src/Model/Table/LdapProvisionersTable.php | 1009 +++++++++++++++++ .../Model/Table/LdapPublicKeySchemasTable.php | 232 ++++ .../Model/Table/LdapSchemaAttributesTable.php | 174 +++ .../src/Model/Table/LdapSchemasTable.php | 282 +++++ .../OrganizationalPersonSchemasTable.php | 295 +++++ .../src/Model/Table/PersonSchemasTable.php | 362 ++++++ .../Model/Table/PosixAccountSchemasTable.php | 248 ++++ .../Model/Table/PosixGroupSchemasTable.php | 241 ++++ .../Table/VoPosixAccountSchemasTable.php | 250 ++++ .../Model/Table/VoPosixGroupSchemasTable.php | 240 ++++ .../templates/EduMemberSchemas/fields.inc | 53 + .../templates/EduPersonSchemas/fields.inc | 65 ++ .../templates/GroupOfNamesSchemas/fields.inc | 48 + .../templates/InetOrgPersonSchemas/fields.inc | 86 ++ .../LdapProvisioners/fields-links.inc | 37 + .../templates/LdapProvisioners/fields-nav.inc | 31 + .../templates/LdapProvisioners/fields.inc | 56 + .../templates/LdapPublicKeySchemas/fields.inc | 48 + .../templates/LdapSchemaAttributes/.keep | 0 .../templates/LdapSchemas/columns.inc | 71 ++ .../templates/LdapSchemas/fields.inc | 36 + .../OrganizationalPersonSchemas/fields.inc | 76 ++ .../templates/PersonSchemas/fields.inc | 48 + .../templates/PosixAccountSchemas/fields.inc | 48 + .../templates/PosixGroupSchemas/fields.inc | 48 + .../VoPosixAccountSchemas/fields.inc | 48 + .../templates/VoPosixGroupSchemas/fields.inc | 48 + .../templates/element/schemaAttributes.php | 273 +++++ .../schemaAttributes/attributeControls.php | 74 ++ .../element/schemaAttributes/attributeRow.php | 200 ++++ .../schemaAttributes/groupingSelector.php | 44 + .../LdapProvisionersControllerTest.php | 27 + app/plugins/LdapConnector/webroot/.gitkeep | 0 .../webroot/css/ldap-connector.css | 3 + .../Component/BreadcrumbComponent.php | 14 +- .../StandardPluggableController.php | 2 +- app/src/Lib/Util/SchemaManager.php | 40 +- app/src/Lib/Util/StringUtilities.php | 8 +- app/src/Lib/Util/TableUtilities.php | 1 + app/src/Model/Table/GroupMembersTable.php | 4 +- app/src/View/Helper/FieldHelper.php | 2 +- app/src/View/Helper/TabHelper.php | 34 +- app/templates/ProvisioningTargets/fields.inc | 17 +- app/templates/Standard/index.php | 4 + app/templates/Standard/subnavigation.inc | 6 +- app/templates/element/form/listItem.php | 1 - app/vendor/cakephp-plugins.php | 5 + app/vendor/composer/autoload_psr4.php | 3 + app/vendor/composer/autoload_static.php | 301 +++-- 91 files changed, 11007 insertions(+), 141 deletions(-) create mode 100644 app/plugins/LdapConnector/config/plugin.json create mode 100644 app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po create mode 100644 app/plugins/LdapConnector/src/Controller/AppController.php create mode 100644 app/plugins/LdapConnector/src/Controller/EduMemberSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/EduPersonSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/GroupOfNamesSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/InetOrgPersonSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/LdapProvisionersController.php create mode 100644 app/plugins/LdapConnector/src/Controller/LdapPublicKeySchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/LdapSchemaAttributesController.php create mode 100644 app/plugins/LdapConnector/src/Controller/LdapSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/OrganizationalPersonSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/PersonSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/PosixAccountSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/PosixGroupSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/VoPosixAccountSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Controller/VoPosixGroupSchemasController.php create mode 100644 app/plugins/LdapConnector/src/LdapConnectorPlugin.php create mode 100644 app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php create mode 100644 app/plugins/LdapConnector/src/Lib/Traits/LdapSchemaProvisioningConfigTrait.php create mode 100644 app/plugins/LdapConnector/src/Lib/Traits/SchemaAttributeExportTrait.php create mode 100644 app/plugins/LdapConnector/src/Lib/Traits/SchemaPluginCoIdTrait.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/EduMemberSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/EduPersonSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/GroupOfNamesSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/InetOrgPersonSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/LdapProvisioner.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/LdapProvisionerDn.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/LdapPublicKeySchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/LdapSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/LdapSchemaAttribute.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/OrganizationalPersonSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/PersonSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/PosixAccountSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/PosixGroupSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/VoPosixAccountSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/VoPosixGroupSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/InetOrgPersonSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/LdapProvisionerDnsTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/LdapPublicKeySchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/LdapSchemaAttributesTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/LdapSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/OrganizationalPersonSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/PersonSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/PosixAccountSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/VoPosixAccountSchemasTable.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php create mode 100644 app/plugins/LdapConnector/templates/EduMemberSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/EduPersonSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/GroupOfNamesSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/InetOrgPersonSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/LdapProvisioners/fields-links.inc create mode 100644 app/plugins/LdapConnector/templates/LdapProvisioners/fields-nav.inc create mode 100644 app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc create mode 100644 app/plugins/LdapConnector/templates/LdapPublicKeySchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/LdapSchemaAttributes/.keep create mode 100644 app/plugins/LdapConnector/templates/LdapSchemas/columns.inc create mode 100644 app/plugins/LdapConnector/templates/LdapSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/OrganizationalPersonSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/PersonSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/PosixAccountSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/PosixGroupSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/VoPosixAccountSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/VoPosixGroupSchemas/fields.inc create mode 100644 app/plugins/LdapConnector/templates/element/schemaAttributes.php create mode 100644 app/plugins/LdapConnector/templates/element/schemaAttributes/attributeControls.php create mode 100644 app/plugins/LdapConnector/templates/element/schemaAttributes/attributeRow.php create mode 100644 app/plugins/LdapConnector/templates/element/schemaAttributes/groupingSelector.php create mode 100644 app/plugins/LdapConnector/tests/TestCase/Controller/LdapProvisionersControllerTest.php create mode 100644 app/plugins/LdapConnector/webroot/.gitkeep create mode 100644 app/plugins/LdapConnector/webroot/css/ldap-connector.css diff --git a/app/plugins/LdapConnector/config/plugin.json b/app/plugins/LdapConnector/config/plugin.json new file mode 100644 index 000000000..3b24cd37b --- /dev/null +++ b/app/plugins/LdapConnector/config/plugin.json @@ -0,0 +1,320 @@ +{ + "types": { + "provisioning_target": [ + "LdapProvisioners" + ], + "ldap_schema": [ + "PersonSchemas", + "OrganizationalPersonSchemas", + "InetOrgPersonSchemas", + "PosixGroupSchemas", + "PosixAccountSchemas", + "LdapPublicKeySchemas", + "GroupOfNamesSchemas", + "VoPosixGroupSchemas", + "VoPosixAccountSchemas", + "EduMemberSchemas", + "EduPersonSchemas" + ] + }, + "schema": { + "tables": { + "ldap_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "server_id": { "notnull": false }, + "dn_attribute_name": { "type": "string", "size": 32, "notnull": false }, + "dn_identifier_type_id": { + "type": "integer", + "foreignkey": { "table": "types", "column": "id" }, + "notnull": false + } + }, + "changelog": true, + "indexes": { + "ldap_provisioners_i1": { "columns": [ "provisioning_target_id" ] }, + "ldap_provisioners_i2": { "columns": [ "server_id" ] } + } + }, + + "ldap_provisioner_dns": { + "columns": { + "id": {}, + "ldap_provisioner_id": { + "type": "integer", + "foreignkey": { "table": "ldap_provisioners", "column": "id" }, + "notnull": true + }, + "person_id": { + "type": "integer", + "foreignkey": { "table": "people", "column": "id" }, + "notnull": false + }, + "group_id": { + "type": "integer", + "foreignkey": { "table": "groups", "column": "id" }, + "notnull": false + }, + "dn": { "type": "string", "size": 256, "notnull": true } + }, + "indexes": { + "ldap_provisioner_dns_i1": { + "columns": [ "ldap_provisioner_id", "person_id" ] + }, + "ldap_provisioner_dns_i2": { + "columns": [ "ldap_provisioner_id", "group_id" ] + } + }, + "changelog": true + }, + + "ldap_schemas": { + "columns": { + "id": {}, + "ldap_provisioner_id": { "type": "integer", "foreignkey": { "table": "ldap_provisioners", "column": "id" } }, + "plugin": { "type": "string", "size": 128 }, + "status": { "type": "enum" }, + "description": { "type": "string", "size": 128 } + }, + "indexes": { + "ldap_schemas_i1": { "columns": [ "ldap_provisioner_id" ] } + }, + "changelog": true + }, + + "ldap_schema_attributes": { + "comment": "Per LDAP schema instance attribute export configuration (objectclass/attribute-level), including required flags and display ordering.", + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" }, + "notnull": true + }, + "objectclass": { "type": "string", "size": 64, "notnull": true }, + "attribute": { "type": "string", "size": 128, "notnull": true }, + + "required": { "type": "boolean", "notnull": true }, + "multiple": { "type": "boolean", "notnull": true }, + + "export": { "type": "boolean", "notnull": true }, + + "ordr": {}, + + "type_id": { "notnull": false, "comment": "Skeletal row needs to be created without type" }, + "use_org_value": { "type": "boolean" } + }, + + "changelog": true, + "indexes": { + "ldap_schema_attributes_i1": { "columns": [ "ldap_schema_id" ] }, + "ldap_schema_attributes_i2": { + "unique": true, + "columns": [ + "ldap_schema_id", + "objectclass", + "attribute", + "revision", + "ldap_schema_attribute_id" + ] + }, + "ldap_schema_attributes_i3": { "needed": false, "columns": [ "ldap_schema_id", "export" ] } + } + }, + + "person_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "person_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "person_schema_id" ] + } + } + }, + + "organizational_person_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + + "changelog": true, + + "indexes": { + "organizational_person_schemas_i1": { + "columns": [ "ldap_schema_id" ] + }, + + "organizational_person_schemas_i2": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "organizational_person_schema_id" ] + } + } + }, + + "inet_org_person_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "inet_org_person_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "inet_org_person_schema_id" ] + } + } + }, + + "posix_group_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "posix_group_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "posix_group_schema_id" ] + } + } + }, + + "posix_account_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "posix_account_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "posix_account_schema_id" ] + } + } + }, + + "ldap_public_key_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "ldap_public_key_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "ldap_public_key_schema_id" ] + } + } + }, + + "group_of_names_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "group_of_names_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "group_of_names_schema_id" ] + } + } + }, + + "vo_posix_group_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "vo_posix_group_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "vo_posix_group_schema_id" ] + } + } + }, + + "vo_posix_account_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "vo_posix_account_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "vo_posix_account_schema_id" ] + } + } + }, + + "edu_member_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "edu_member_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "edu_member_schema_id" ] + } + } + }, + + "edu_person_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "edu_person_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "edu_person_schema_id" ] + } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po b/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po new file mode 100644 index 000000000..cb3de82c8 --- /dev/null +++ b/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po @@ -0,0 +1,122 @@ +# COmanage Registry Localizations (sql_connector domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry +# @since COmanage Registry v5.3.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.LdapProvisioners" +msgstr "{0,plural,=1{LDAP Provisioner} other{LDAP Provisioners}}" + +msgid "controller.LdapSchemas" +msgstr "{0,plural,=1{LDAP Schema} other{LDAP Schemas}}" + +msgid "operation.resync" +msgstr "Resync Data" + +msgid "result.prov.added" +msgstr "New record published" + +msgid "result.prov.deleted" +msgstr "Record deleted" + +msgid "result.prov.ineligible" +msgstr "Record is not eligible for provisioning" + +msgid "result.prov.updated" +msgstr "Record updated" + +msgid "result.resync.ok" +msgstr "Data Synced" + +msgid "schema.attributes" +msgstr "Schema Attributes" + +msgid "types.all" +msgstr "All Types" + +msgid "field.LdapConnector.address_type_id" +msgstr "Address Type" + +msgid "field.LdapConnector.telephoneNumber_type_id" +msgstr "Telephone Number Type" + +msgid "field.LdapConnector.facsimileTelephoneNumber_type_id" +msgstr "Facsimile Telephone Number Type" + +msgid "field.LdapConnector.person.cn" +msgstr "Canonical Name" + +msgid "field.LdapConnector.person.sn" +msgstr "Surname" + +msgid "field.LdapConnector.organizationalPerson.title" +msgstr "Title" + +msgid "field.LdapConnector.organizationalPerson.ou" +msgstr "Organizational Unit" + +msgid "field.LdapConnector.organizationalPerson.telephoneNumber" +msgstr "Telephone Number" + +msgid "field.LdapConnector.organizationalPerson.facsimileTelephoneNumber" +msgstr "Facsimile Telephone Number" + +msgid "field.LdapConnector.organizationalPerson.street" +msgstr "Street" + +msgid "field.LdapConnector.organizationalPerson.l" +msgstr "Locality" + +msgid "field.LdapConnector.organizationalPerson.st" +msgstr "State" + +msgid "field.LdapConnector.organizationalPerson.postalCode" +msgstr "Postal Code" + +msgid "field.LdapConnector.eduMember.isMemberOf" +msgstr "Applies to Person" + +msgid "field.LdapConnector.eduMember.hasMember" +msgstr "Applies to Group" + +msgid "field.LdapConnector.eduPerson.use_org_value" +msgstr "Use value from External Identity Source" + +msgid "field.LdapConnector.eduPerson.use_org_value.desc" +msgstr "When enabled, the exported value is taken from the person’s External Identity Source record." + +msgid "field.LdapConnector.inetOrgPerson.use_org_value" +msgstr "Use value from External Identity Source" + +msgid "field.LdapConnector.inetOrgPerson.use_org_value.desc" +msgstr "When enabled, the exported value is taken from the person’s External Identity Source record." + +msgid "field.LdapProvisioners.dn_identifier_type_id" +msgstr "People DN Identifier Type" + +msgid "field.LdapProvisioners.dn_identifier_type_id.desc" +msgstr "When constructing People DNs, use the value associated with this identifier type as the value for the unique component (If multiple values are available for the attribute, the value selected is non-deterministic)" + +msgid "field.LdapProvisioners.dn_attribute_name" +msgstr "People DN Attribute Name" + +msgid "field.LdapProvisioners.dn_attribute_name.desc" +msgstr "When constructing People DNs, use this attribute name for the unique component" \ No newline at end of file diff --git a/app/plugins/LdapConnector/src/Controller/AppController.php b/app/plugins/LdapConnector/src/Controller/AppController.php new file mode 100644 index 000000000..555e50d6f --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/AppController.php @@ -0,0 +1,36 @@ + [ + 'EduMemberSchemas.id' => 'asc' + ] + ]; + + /** + * Edit an eduMember Schema. + * + * Updates schema attributes for the specified eduMember Schema ID. + * + * @param string $id The ID of the eduMember Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->EduMemberSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->EduMemberSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->EduMemberSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Calculate the CO ID for the current request context. + * + * For /ldap-connector/edu-member-schemas/edit/:id, derive CO via: + * EduMemberSchemas(id) -> LdapSchemas -> LdapProvisioners -> ProvisioningTargets -> co_id + * + * @return int|null CO ID if it can be determined, else null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function calculateRequestedCOID(): ?int + { + return $this->calculateRequestedCOIDForSchemaPluginEdit(); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/EduPersonSchemasController.php b/app/plugins/LdapConnector/src/Controller/EduPersonSchemasController.php new file mode 100644 index 000000000..1c339a13f --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/EduPersonSchemasController.php @@ -0,0 +1,103 @@ + [ + 'EduPersonSchemas.id' => 'asc' + ] + ]; + + /** + * Edit an eduPerson Schema. + * + * Updates schema attributes for the specified eduPerson Schema ID. + * + * @param string $id The ID of the eduPerson Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) + { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) + { + $link = $this->getPrimaryLink(true); + + if (!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->EduPersonSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->EduPersonSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->EduPersonSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Calculate the CO ID for the current request context. + * + * For /ldap-connector/edu-person-schemas/edit/:id, derive CO via: + * EduPersonSchemas(id) -> LdapSchemas -> LdapProvisioners -> ProvisioningTargets -> co_id + * + * @return int|null CO ID if it can be determined, else null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function calculateRequestedCOID(): ?int + { + return $this->calculateRequestedCOIDForSchemaPluginEdit(); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/GroupOfNamesSchemasController.php b/app/plugins/LdapConnector/src/Controller/GroupOfNamesSchemasController.php new file mode 100644 index 000000000..2ea9c0869 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/GroupOfNamesSchemasController.php @@ -0,0 +1,86 @@ + [ + 'GroupOfNamesSchemas.id' => 'asc' + ] + ]; + + /** + * Edit a Group Of Names Schema. + * + * Updates schema attributes for the specified Group Of Names Schema ID. + * + * @param string $id The ID of the Group Of Names Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->GroupOfNamesSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->GroupOfNamesSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->GroupOfNamesSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/InetOrgPersonSchemasController.php b/app/plugins/LdapConnector/src/Controller/InetOrgPersonSchemasController.php new file mode 100644 index 000000000..c23aaf6b5 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/InetOrgPersonSchemasController.php @@ -0,0 +1,131 @@ + + * @since COmanage Registry v5.3.0 + */ + protected array $paginate = [ + 'order' => [ + 'InetOrgPersonSchemas.id' => 'asc', + ], + ]; + + /** + * Edit an inetOrgPerson Schema. + * + * Updates schema attributes for the specified inetOrgPerson Schema ID. + * + * @param string $id The ID of the inetOrgPerson Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) + { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * Establishes the breadcrumb parent object (LdapSchema) for edit/view pages. + * + * @param EventInterface $event Cake Event. + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) + { + $link = $this->getPrimaryLink(true); + + if (!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->InetOrgPersonSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->InetOrgPersonSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->InetOrgPersonSchemas->LdapSchemas->getPrimaryKey()); + } + + // Derive the currently selected Address grouping type from one member attribute (street). + // This is only for rendering the grouping dropdown's current value. + $addressGroupingTypeId = null; + + $vvObj = $this->viewBuilder()->getVar('vv_obj'); + if (!empty($vvObj) + && !empty($vvObj->ldap_schema) + && !empty($vvObj->ldap_schema->ldap_schema_attributes)) { + foreach ($vvObj->ldap_schema->ldap_schema_attributes as $a) { + if (!empty($a->attribute) && $a->attribute === 'roomNumber') { + $addressGroupingTypeId = $a->type_id ?? null; + break; + } + } + } + + $this->set('vv_address_grouping_type_id', $addressGroupingTypeId); + + return parent::beforeRender($event); + } + + /** + * Calculate the CO ID for the current request context. + * + * For /ldap-connector/inet-org-person-schemas/edit/:id, derive CO via: + * InetOrgPersonSchemas(id) -> LdapSchemas -> LdapProvisioners -> ProvisioningTargets -> co_id + * + * This supports proper Types resolution in the schema UI (via AutoViewVars). + * + * @return int|null CO ID if it can be determined, else null. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function calculateRequestedCOID(): ?int + { + return $this->calculateRequestedCOIDForSchemaPluginEdit(); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/LdapProvisionersController.php b/app/plugins/LdapConnector/src/Controller/LdapProvisionersController.php new file mode 100644 index 000000000..c8768ac60 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/LdapProvisionersController.php @@ -0,0 +1,95 @@ + [ + 'LdapProvisioners.id' => 'asc' + ] + ]; + + /** + * Perform controller initialization. + * + * Configures breadcrumb behavior for plugin configuration edit/view pages. + * + * @return void + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function initialize(): void { + parent::initialize(); + + // Don't inject an automatic "index" breadcrumb for plugin configuration edit pages. + // We want: ... > Provisioning Targets > Configure LDAP Provisioner + $this->Breadcrumb->skipParents([ + '/^\/ldap-connector\/ldap-provisioners\/(edit|view)\//' + ]); + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->LdapProvisioners->ProvisioningTargets->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->LdapProvisioners->ProvisioningTargets->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->LdapProvisioners->ProvisioningTargets->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Resync all data, including Groups. + * + * @param string $id LdapProvisioner ID + * @return void + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function resync(string $id) { + // not yet implemented + } +} diff --git a/app/plugins/LdapConnector/src/Controller/LdapPublicKeySchemasController.php b/app/plugins/LdapConnector/src/Controller/LdapPublicKeySchemasController.php new file mode 100644 index 000000000..80e8c4001 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/LdapPublicKeySchemasController.php @@ -0,0 +1,84 @@ + [ + 'LdapPublicKeySchemas.id' => 'asc' + ] + ]; + + /** + * Edit an LDAP Public Key Schema. + * + * Updates schema attributes for the specified LDAP Public Key Schema ID. + * + * @param string $id The ID of the LDAP Public Key Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->LdapPublicKeySchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->LdapPublicKeySchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->LdapPublicKeySchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/LdapSchemaAttributesController.php b/app/plugins/LdapConnector/src/Controller/LdapSchemaAttributesController.php new file mode 100644 index 000000000..2c713e7a3 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/LdapSchemaAttributesController.php @@ -0,0 +1,50 @@ + [ + 'LdapSchemaAttributes.objectclass' => 'asc', + 'LdapSchemaAttributes.ordr' => 'asc', + 'LdapSchemaAttributes.attribute' => 'asc', + ], + ]; + + /** + * Callback run prior to the request render. + * + * Sets breadcrumb parent to the owning LdapSchema (via primary link ldap_schema_id). + * + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response|null|void + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) + { + $link = $this->getPrimaryLink(true); + + if (!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->LdapSchemaAttributes->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->LdapSchemaAttributes->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->LdapSchemaAttributes->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/LdapSchemasController.php b/app/plugins/LdapConnector/src/Controller/LdapSchemasController.php new file mode 100644 index 000000000..d2f594835 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/LdapSchemasController.php @@ -0,0 +1,137 @@ + [ + 'LdapSchemas.description' => 'asc' + ] + ]; + + /** + * Perform controller initialization. + * + * Configures breadcrumb behavior for query-context primary links. + * + * @return void + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function initialize(): void + { + parent::initialize(); + + // Build breadcrumb chain from query context: ?ldap_provisioner_id=... + $this->Breadcrumb->configureQueryPrimaryLinks([ + 'index' => ['ldap_provisioner_id'] + ]); + } + + /** + * Callback run prior to the request render. + * + * Sets breadcrumb parent to the owning LdapProvisioner (via primary link ldap_provisioner_id). + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->LdapSchemas->LdapProvisioners->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->LdapSchemas->LdapProvisioners->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->LdapSchemas->LdapProvisioners->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Calculate the CO ID for the current request context. + * + * For /ldap-connector/ldap-schemas/configure/:id, derive CO via: + * LdapSchemas(id) -> LdapProvisioners -> ProvisioningTargets -> co_id + * + * @return int|null CO ID if it can be determined, else null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function calculateRequestedCOID(): ?int + { + $action = (string)$this->request->getParam('action'); + + // We only implement what we need right now. Other actions can fall back to default behavior. + if ($action !== 'configure') { + return null; + } + + $passed = (array)$this->request->getParam('pass'); + $ldapSchemaId = !empty($passed[0]) ? (int)$passed[0] : null; + + if (empty($ldapSchemaId)) { + return null; + } + + // LdapSchemas belongsTo LdapProvisioners; LdapProvisioners belongsTo ProvisioningTargets + $ldapSchema = $this->LdapSchemas->get($ldapSchemaId, contain: [ + 'LdapProvisioners' => [ + 'ProvisioningTargets' + ] + ]); + + if (!empty($ldapSchema->ldap_provisioner) + && !empty($ldapSchema->ldap_provisioner->provisioning_target) + && !empty($ldapSchema->ldap_provisioner->provisioning_target->co_id)) { + return (int)$ldapSchema->ldap_provisioner->provisioning_target->co_id; + } + + // Fallback: if contain didn’t hydrate for some reason but FK is present + if (!empty($ldapSchema->ldap_provisioner_id)) { + $prov = $this->LdapSchemas->LdapProvisioners->get((int)$ldapSchema->ldap_provisioner_id, contain: [ + 'ProvisioningTargets' + ]); + + if (!empty($prov->provisioning_target) && !empty($prov->provisioning_target->co_id)) { + return (int)$prov->provisioning_target->co_id; + } + } + + return null; + } +} diff --git a/app/plugins/LdapConnector/src/Controller/OrganizationalPersonSchemasController.php b/app/plugins/LdapConnector/src/Controller/OrganizationalPersonSchemasController.php new file mode 100644 index 000000000..45acc63bd --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/OrganizationalPersonSchemasController.php @@ -0,0 +1,121 @@ + [ + 'OrganizationalPersonSchemas.id' => 'asc' + ] + ]; + + /** + * Edit an Organizational Person Schema. + * + * Updates schema attributes for the specified Organizational Person Schema ID. + * + * @param string $id The ID of the Organizational Person Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) + { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) + { + $link = $this->getPrimaryLink(true); + + if (!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->OrganizationalPersonSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->OrganizationalPersonSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->OrganizationalPersonSchemas->LdapSchemas->getPrimaryKey()); + } + + // Derive the currently selected Address grouping type from one member attribute (street). + // This is only for rendering the grouping dropdown's current value. + $addressGroupingTypeId = null; + + $vvObj = $this->viewBuilder()->getVar('vv_obj'); + if (!empty($vvObj) + && !empty($vvObj->ldap_schema) + && !empty($vvObj->ldap_schema->ldap_schema_attributes)) { + foreach ($vvObj->ldap_schema->ldap_schema_attributes as $a) { + if (!empty($a->attribute) && $a->attribute === 'street') { + $addressGroupingTypeId = $a->type_id ?? null; + break; + } + } + } + + $this->set('vv_address_grouping_type_id', $addressGroupingTypeId); + + return parent::beforeRender($event); + } + + /** + * Calculate the CO ID for the current request context. + * + * For /ldap-connector/organizational-person-schemas/edit/:id, derive CO via: + * OrganizationalPersonSchemas(id) -> LdapSchemas -> LdapProvisioners -> ProvisioningTargets -> co_id + * + * @return int|null CO ID if it can be determined, else null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function calculateRequestedCOID(): ?int + { + return $this->calculateRequestedCOIDForSchemaPluginEdit(); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/PersonSchemasController.php b/app/plugins/LdapConnector/src/Controller/PersonSchemasController.php new file mode 100644 index 000000000..4baa6860a --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/PersonSchemasController.php @@ -0,0 +1,84 @@ + [ + 'PersonSchemas.id' => 'asc' + ] + ]; + + /** + * Edit a Person Schema. + * + * Updates schema attributes for the specified Person Schema ID. + * + * @param string $id The ID of the Person Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->PersonSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->PersonSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->PersonSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/PosixAccountSchemasController.php b/app/plugins/LdapConnector/src/Controller/PosixAccountSchemasController.php new file mode 100644 index 000000000..c7bb5926e --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/PosixAccountSchemasController.php @@ -0,0 +1,84 @@ + [ + 'PosixAccountSchemas.id' => 'asc' + ] + ]; + + /** + * Edit a POSIX Account Schema. + * + * Updates schema attributes for the specified POSIX Account Schema ID. + * + * @param string $id The ID of the POSIX Account Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->PosixAccountSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->PosixAccountSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->PosixAccountSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/PosixGroupSchemasController.php b/app/plugins/LdapConnector/src/Controller/PosixGroupSchemasController.php new file mode 100644 index 000000000..91a065e2f --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/PosixGroupSchemasController.php @@ -0,0 +1,84 @@ + [ + 'PosixGroupSchemas.id' => 'asc' + ] + ]; + + /** + * Edit a POSIX Group Schema. + * + * Updates schema attributes for the specified POSIX Group Schema ID. + * + * @param string $id The ID of the POSIX Group Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->PosixGroupSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->PosixGroupSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->PosixGroupSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/VoPosixAccountSchemasController.php b/app/plugins/LdapConnector/src/Controller/VoPosixAccountSchemasController.php new file mode 100644 index 000000000..40e330710 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/VoPosixAccountSchemasController.php @@ -0,0 +1,84 @@ + [ + 'VoPosixAccountSchemas.id' => 'asc' + ] + ]; + + /** + * Edit a VO POSIX Account Schema. + * + * Updates schema attributes for the specified VO POSIX Account Schema ID. + * + * @param string $id The ID of the VO POSIX Account Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->VoPosixAccountSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->VoPosixAccountSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->VoPosixAccountSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/Controller/VoPosixGroupSchemasController.php b/app/plugins/LdapConnector/src/Controller/VoPosixGroupSchemasController.php new file mode 100644 index 000000000..fbf6ade96 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/VoPosixGroupSchemasController.php @@ -0,0 +1,84 @@ + [ + 'VoPosixGroupSchemas.id' => 'asc' + ] + ]; + + /** + * Edit a VO POSIX Group Schema. + * + * Updates schema attributes for the specified VO POSIX Group Schema ID. + * + * @param string $id The ID of the VO POSIX Group Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->VoPosixGroupSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->VoPosixGroupSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->VoPosixGroupSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} diff --git a/app/plugins/LdapConnector/src/LdapConnectorPlugin.php b/app/plugins/LdapConnector/src/LdapConnectorPlugin.php new file mode 100644 index 000000000..517b2cf76 --- /dev/null +++ b/app/plugins/LdapConnector/src/LdapConnectorPlugin.php @@ -0,0 +1,124 @@ +plugin( + 'LdapConnector', + ['path' => '/ldap-connector'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + // remove this method hook if you don't need it + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + // remove this method hook if you don't need it + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + // remove this method hook if you don't need it + } +} diff --git a/app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php b/app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php new file mode 100644 index 000000000..dafc1a4cd --- /dev/null +++ b/app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php @@ -0,0 +1,113 @@ + $ret Assembled attributes (mutated in-place) + * @return void + * @throws \LogicException|\Throwable If ldapObjectClass() returns an empty string + * @since COmanage Registry v5.3.0 + */ + protected function ensureSchemaObjectClass(array &$ret): void + { + if (empty($ret)) { + return; + } + + $oc = $this->ldapObjectClass(); + if ($oc === '') { + throw new \LogicException('ldapObjectClass() must not return an empty string'); + } + + if (!array_key_exists('objectClass', $ret)) { + $ret['objectClass'] = [$oc]; + return; + } + + if (is_array($ret['objectClass'])) { + $ret['objectClass'] = array_values(array_unique(array_merge($ret['objectClass'], [$oc]))); + return; + } + + $ret['objectClass'] = array_values(array_unique([(string)$ret['objectClass'], $oc])); + } + + /** + * Build a contain() clause that loads LdapSchemas and filters its LdapSchemaAttributes + * to only the objectclass owned by the implementing schema table. + * + * This is intended for use with QueryModificationTrait's setViewContains(), + * setEditContains(), etc. + * + * Example return value: + * [ + * 'LdapSchemas' => [ + * 'LdapSchemaAttributes' => function($q) { ... } + * ] + * ] + * + * @param string $alias Association alias for the attributes relation (default: 'LdapSchemaAttributes') + * @return array Contain clause suitable for Cake ORM Query::contain() + * @throws \LogicException If ldapObjectClass() returns an empty string + * @since COmanage Registry v5.3.0 + */ + public function ldapSchemaAttributesContain(string $alias = 'LdapSchemaAttributes'): array + { + $oc = $this->ldapObjectClass(); + + return [ + 'LdapSchemas' => [ + $alias => function ($q) use ($oc, $alias) { + return $q->where(["{$alias}.objectclass" => $oc]) + ->orderBy(["{$alias}.ordr" => 'ASC', "{$alias}.attribute" => 'ASC']); + } + ] + ]; + } +} diff --git a/app/plugins/LdapConnector/src/Lib/Traits/LdapSchemaProvisioningConfigTrait.php b/app/plugins/LdapConnector/src/Lib/Traits/LdapSchemaProvisioningConfigTrait.php new file mode 100644 index 000000000..e6b703d18 --- /dev/null +++ b/app/plugins/LdapConnector/src/Lib/Traits/LdapSchemaProvisioningConfigTrait.php @@ -0,0 +1,264 @@ +>} + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function resolveSchemaAttributeConfigForProvisioningTarget( + ProvisioningTarget $provisioningTarget, + string $schemaPluginModel, + string $objectclass + ): array { + $ret = [ + 'ldapSchemaId' => null, + 'cfgByAttr' => [], + ]; + + $ldapProvisionerId = $this->resolveLdapProvisionerIdForProvisioningTarget($provisioningTarget); + if (empty($ldapProvisionerId)) { + return $ret; + } + + $ldapSchemaId = $this->resolveActiveLdapSchemaId( + ldapProvisionerId: $ldapProvisionerId, + schemaPluginModel: $schemaPluginModel + ); + + if (empty($ldapSchemaId)) { + return $ret; + } + + $ret['ldapSchemaId'] = $ldapSchemaId; + $ret['cfgByAttr'] = $this->loadLdapSchemaAttributeConfigByAttr( + ldapSchemaId: $ldapSchemaId, + objectclass: $objectclass + ); + + return $ret; + } + + /** + * Resolve ldap_provisioners.id for the given ProvisioningTarget. + * + * @param ProvisioningTarget $provisioningTarget Provisioning target entity + * @return int|null LdapProvisioners.id or null if not found / invalid + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function resolveLdapProvisionerIdForProvisioningTarget( + ProvisioningTarget $provisioningTarget + ): ?int { + $provisioningTargetId = (int)($provisioningTarget->id ?? 0); + if ($provisioningTargetId < 1) { + return null; + } + + $LdapProvisioners = TableRegistry::getTableLocator()->get('LdapConnector.LdapProvisioners'); + + $ldapProvisioner = $LdapProvisioners->find() + ->select(['id']) + ->where(['LdapProvisioners.provisioning_target_id' => $provisioningTargetId]) + ->orderBy(['LdapProvisioners.id' => 'ASC']) + ->first(); + + if (empty($ldapProvisioner) || empty($ldapProvisioner->id)) { + return null; + } + + return (int)$ldapProvisioner->id; + } + + /** + * Resolve the active ldap_schemas.id for a given ldap_provisioner_id and schema plugin model. + * + * @param int $ldapProvisionerId LdapProvisioners.id + * @param string $schemaPluginModel Eg "LdapConnector.EduMemberSchemas" + * @return int|null LdapSchemas.id or null if no active row exists + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function resolveActiveLdapSchemaId(int $ldapProvisionerId, string $schemaPluginModel): ?int + { + if ($ldapProvisionerId < 1 || $schemaPluginModel === '') { + return null; + } + + $LdapSchemas = TableRegistry::getTableLocator()->get('LdapConnector.LdapSchemas'); + + $schema = $LdapSchemas->find() + ->select(['id']) + ->where([ + 'LdapSchemas.ldap_provisioner_id' => $ldapProvisionerId, + 'LdapSchemas.plugin' => $schemaPluginModel, + 'LdapSchemas.status' => SuspendableStatusEnum::Active, + ]) + ->orderBy(['LdapSchemas.id' => 'ASC']) + ->first(); + + if (empty($schema) || empty($schema->id)) { + return null; + } + + return (int)$schema->id; + } + + /** + * Load ldap_schema_attributes rows for a given ldap_schema_id and objectclass, + * re-keyed by attribute name. + * + * Example (objectclass = "groupOfNames"): + * + * If ldap_schema_attributes contains rows with (at minimum) these columns: + * id, ldap_schema_id, objectclass, attribute, required, multiple, export, ordr, type_id, use_org_value + * + * For example, rows: + * - (id=101, ldap_schema_id=10, objectclass="groupOfNames", attribute="cn", required=1, multiple=0, export=1, ordr=1, type_id=null, use_org_value=0) + * - (id=102, ldap_schema_id=10, objectclass="groupOfNames", attribute="member", required=1, multiple=1, export=1, ordr=2, type_id=null, use_org_value=0) + * - (id=103, ldap_schema_id=10, objectclass="groupOfNames", attribute="owner", required=0, multiple=1, export=0, ordr=3, type_id=null, use_org_value=0) + * - (id=104, ldap_schema_id=10, objectclass="groupOfNames", attribute="description", required=0, multiple=0, export=1, ordr=4, type_id=null, use_org_value=0) + * + * Will be returned as an array keyed by attribute name, where each value is the + * underlying row array: + * + * [ + * 'cn' => [ + * 'id' => 101, + * 'ldap_schema_id' => 10, + * 'objectclass' => 'groupOfNames', + * 'attribute' => 'cn', + * 'required' => true, + * 'multiple' => false, + * 'export' => true, + * 'ordr' => 1, + * 'type_id' => null, + * 'use_org_value' => false, + * ], + * 'member' => [ + * 'id' => 102, + * 'ldap_schema_id' => 10, + * 'objectclass' => 'groupOfNames', + * 'attribute' => 'member', + * 'required' => true, + * 'multiple' => true, + * 'export' => true, + * 'ordr' => 2, + * 'type_id' => null, + * 'use_org_value' => false, + * ], + * 'owner' => [ + * 'id' => 103, + * 'ldap_schema_id' => 10, + * 'objectclass' => 'groupOfNames', + * 'attribute' => 'owner', + * 'required' => false, + * 'multiple' => true, + * 'export' => false, + * 'ordr' => 3, + * 'type_id' => null, + * 'use_org_value' => false, + * ], + * 'description' => [ + * 'id' => 104, + * 'ldap_schema_id' => 10, + * 'objectclass' => 'groupOfNames', + * 'attribute' => 'description', + * 'required' => false, + * 'multiple' => false, + * 'export' => true, + * 'ordr' => 4, + * 'type_id' => null, + * 'use_org_value' => false, + * ], + * ] + * + * @param int $ldapSchemaId LdapSchemas.id + * @param string $objectclass Eg "eduMember" + * @return array> Attribute config keyed by attribute name + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function loadLdapSchemaAttributeConfigByAttr(int $ldapSchemaId, string $objectclass): array + { + if ($ldapSchemaId < 1 || $objectclass === '') { + return []; + } + + $LdapSchemaAttributes = TableRegistry::getTableLocator()->get('LdapConnector.LdapSchemaAttributes'); + + $rows = $LdapSchemaAttributes->find() + ->where([ + 'LdapSchemaAttributes.ldap_schema_id' => $ldapSchemaId, + 'LdapSchemaAttributes.objectclass' => $objectclass, + ]) + ->enableHydration(false) + ->all() + ->toArray(); + + $cfgByAttr = []; + foreach ($rows as $r) { + if (!empty($r['attribute'])) { + $cfgByAttr[(string)$r['attribute']] = $r; + } + } + + return $cfgByAttr; + } + + /** + * Standard "enabled attribute" check (required OR export). + * + * @param array|null $cfg + * @return bool + * @since COmanage Registry v5.3.0 + */ + protected function ldapSchemaAttributeEnabled(?array $cfg): bool + { + if (empty($cfg)) { + return false; + } + + return !empty($cfg['required']) || !empty($cfg['export']); + } +} diff --git a/app/plugins/LdapConnector/src/Lib/Traits/SchemaAttributeExportTrait.php b/app/plugins/LdapConnector/src/Lib/Traits/SchemaAttributeExportTrait.php new file mode 100644 index 000000000..a135b6c17 --- /dev/null +++ b/app/plugins/LdapConnector/src/Lib/Traits/SchemaAttributeExportTrait.php @@ -0,0 +1,204 @@ +request->getData($fieldPrefix); + if (empty($posted)) { + return; + } + + $postedGroupings = (array)$this->request->getData('LdapSchemaGroupings'); + + $table = $this->getCurrentTable(); + + // Needs to exist on the schema table (provided by LdapObjectClassSchemaTrait) + $oc = $table->ldapObjectClass(); + + $schemaRow = $table->findById($id)->firstOrFail(); + + $LdapSchemaAttributes = TableRegistry::getTableLocator()->get('LdapConnector.LdapSchemaAttributes'); + + // Build attribute => groupingKey map from schema plugin definitions (if available) + $attributeGroupingMap = []; + if (method_exists($table, 'getAttributes')) { + $defs = (array)$table->getAttributes(); + $ocDef = (array)($defs[$oc] ?? []); + $attrDefs = (array)($ocDef['attributes'] ?? []); + foreach ($attrDefs as $attrName => $attrCfg) { + if (!empty($attrCfg['grouping'])) { + $attributeGroupingMap[(string)$attrName] = (string)$attrCfg['grouping']; + } + } + } + + // Track which attributes received an explicit per-attribute type_id in this POST + $explicitTypeIds = []; + + foreach ($posted as $row) { + $attrId = isset($row['id']) ? (int)$row['id'] : 0; + if ($attrId <= 0) { + continue; + } + + $attr = $LdapSchemaAttributes->find() + ->where([ + 'LdapSchemaAttributes.id' => $attrId, + 'LdapSchemaAttributes.ldap_schema_id' => (int)$schemaRow->ldap_schema_id, + 'LdapSchemaAttributes.objectclass' => $oc, + ]) + ->first(); + + if (!$attr) { + continue; + } + + // --- export --- + $export = !empty($row['export']) ? 1 : 0; + + // Enforce "required" always exported (server-side) + if (!empty($attr->required)) { + $export = 1; + } + + $dirty = false; + + if ((int)$attr->export !== (int)$export) { + $attr->export = (bool)$export; + $dirty = true; + } + + // --- type_id (nullable; empty => "All Types") --- + if (array_key_exists('type_id', $row)) { + $postedTypeId = $row['type_id']; + + if ($postedTypeId === '' || $postedTypeId === null) { + $postedTypeId = null; + } else { + $postedTypeId = (int)$postedTypeId; + if ($postedTypeId <= 0) { + $postedTypeId = null; + } + } + + // Mark attribute as explicitly set (even if null), so grouping defaults won't override it. + $explicitTypeIds[(string)$attr->attribute] = true; + + if (($attr->type_id ?? null) !== $postedTypeId) { + $attr->type_id = $postedTypeId; + $dirty = true; + } + } + + // --- use_org_value (nullable/optional; default false) --- + if (array_key_exists('use_org_value', $row)) { + $postedUseOrg = !empty($row['use_org_value']) ? 1 : 0; + + if ((int)($attr->use_org_value ?? 0) !== (int)$postedUseOrg) { + $attr->use_org_value = (bool)$postedUseOrg; + $dirty = true; + } + } + + if ($dirty) { + $LdapSchemaAttributes->saveOrFail($attr, [ + 'validate' => true, + 'checkRules' => true, + ]); + } + } + + // Apply grouping-level type_id defaults (only to attributes in that grouping that + // did not have an explicit per-attribute type_id posted). + if (!empty($postedGroupings) && !empty($attributeGroupingMap)) { + foreach ($postedGroupings as $groupKey => $gRow) { + if (!is_array($gRow) || !array_key_exists('type_id', $gRow)) { + continue; + } + + $groupTypeId = $gRow['type_id']; + if ($groupTypeId === '' || $groupTypeId === null) { + $groupTypeId = null; + } else { + $groupTypeId = (int)$groupTypeId; + if ($groupTypeId <= 0) { + $groupTypeId = null; + } + } + + foreach ($attributeGroupingMap as $attrName => $attrGroupKey) { + if ($attrGroupKey !== (string)$groupKey) { + continue; + } + + // Respect explicit per-attribute type selection + if (!empty($explicitTypeIds[$attrName])) { + continue; + } + + $attr = $LdapSchemaAttributes->find() + ->where([ + 'LdapSchemaAttributes.ldap_schema_id' => (int)$schemaRow->ldap_schema_id, + 'LdapSchemaAttributes.objectclass' => $oc, + 'LdapSchemaAttributes.attribute' => $attrName, + ]) + ->first(); + + if (!$attr) { + continue; + } + + if (($attr->type_id ?? null) !== $groupTypeId) { + $attr->type_id = $groupTypeId; + + $LdapSchemaAttributes->saveOrFail($attr, [ + 'validate' => true, + 'checkRules' => true, + ]); + } + } + } + } + } +} diff --git a/app/plugins/LdapConnector/src/Lib/Traits/SchemaPluginCoIdTrait.php b/app/plugins/LdapConnector/src/Lib/Traits/SchemaPluginCoIdTrait.php new file mode 100644 index 000000000..04fa1030b --- /dev/null +++ b/app/plugins/LdapConnector/src/Lib/Traits/SchemaPluginCoIdTrait.php @@ -0,0 +1,100 @@ + LdapSchemas -> LdapProvisioners -> ProvisioningTargets -> co_id + * + * Intended for controllers like: + * - /ldap-connector/*-schemas/edit/:id + * + * @param string $actionName Controller action name to match (default: "edit"). + * @return int|null CO ID if it can be determined, else null. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function calculateRequestedCOIDForSchemaPluginEdit(string $actionName = 'edit'): ?int + { + $action = (string)$this->request->getParam('action'); + + if ($action !== $actionName) { + return null; + } + + $passed = (array)$this->request->getParam('pass'); + $schemaPluginRowId = !empty($passed[0]) ? (int)$passed[0] : null; + + if (empty($schemaPluginRowId)) { + return null; + } + + // Works for any schema plugin controller because StandardPluginController provides getCurrentTable() + $SchemaPluginTable = $this->getCurrentTable(); + + $row = $SchemaPluginTable->get($schemaPluginRowId, contain: [ + 'LdapSchemas' => [ + 'LdapProvisioners' => [ + 'ProvisioningTargets', + ], + ], + ]); + + if (!empty($row->ldap_schema) + && !empty($row->ldap_schema->ldap_provisioner) + && !empty($row->ldap_schema->ldap_provisioner->provisioning_target) + && !empty($row->ldap_schema->ldap_provisioner->provisioning_target->co_id)) { + return (int)$row->ldap_schema->ldap_provisioner->provisioning_target->co_id; + } + + // Fallback: walk the FKs without relying on contain hydration + if (!empty($row->ldap_schema_id)) { + $LdapSchemas = $SchemaPluginTable->getAssociation('LdapSchemas')->getTarget(); + + $ldapSchema = $LdapSchemas->get((int)$row->ldap_schema_id, contain: [ + 'LdapProvisioners' => [ + 'ProvisioningTargets', + ], + ]); + + if (!empty($ldapSchema->ldap_provisioner) + && !empty($ldapSchema->ldap_provisioner->provisioning_target) + && !empty($ldapSchema->ldap_provisioner->provisioning_target->co_id)) { + return (int)$ldapSchema->ldap_provisioner->provisioning_target->co_id; + } + } + + return null; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/EduMemberSchema.php b/app/plugins/LdapConnector/src/Model/Entity/EduMemberSchema.php new file mode 100644 index 000000000..b100191d7 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/EduMemberSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/EduPersonSchema.php b/app/plugins/LdapConnector/src/Model/Entity/EduPersonSchema.php new file mode 100644 index 000000000..efbd4e436 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/EduPersonSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/GroupOfNamesSchema.php b/app/plugins/LdapConnector/src/Model/Entity/GroupOfNamesSchema.php new file mode 100644 index 000000000..3e4ff0c42 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/GroupOfNamesSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/InetOrgPersonSchema.php b/app/plugins/LdapConnector/src/Model/Entity/InetOrgPersonSchema.php new file mode 100644 index 000000000..ef768dd57 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/InetOrgPersonSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/LdapProvisioner.php b/app/plugins/LdapConnector/src/Model/Entity/LdapProvisioner.php new file mode 100644 index 000000000..fb23fade3 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/LdapProvisioner.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/LdapProvisionerDn.php b/app/plugins/LdapConnector/src/Model/Entity/LdapProvisionerDn.php new file mode 100644 index 000000000..5946f83c0 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/LdapProvisionerDn.php @@ -0,0 +1,43 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/LdapPublicKeySchema.php b/app/plugins/LdapConnector/src/Model/Entity/LdapPublicKeySchema.php new file mode 100644 index 000000000..7ac03319f --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/LdapPublicKeySchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/LdapSchema.php b/app/plugins/LdapConnector/src/Model/Entity/LdapSchema.php new file mode 100644 index 000000000..78d260e4a --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/LdapSchema.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/LdapSchemaAttribute.php b/app/plugins/LdapConnector/src/Model/Entity/LdapSchemaAttribute.php new file mode 100644 index 000000000..4c1ed37df --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/LdapSchemaAttribute.php @@ -0,0 +1,30 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/OrganizationalPersonSchema.php b/app/plugins/LdapConnector/src/Model/Entity/OrganizationalPersonSchema.php new file mode 100644 index 000000000..e86c14520 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/OrganizationalPersonSchema.php @@ -0,0 +1,43 @@ + true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/PersonSchema.php b/app/plugins/LdapConnector/src/Model/Entity/PersonSchema.php new file mode 100644 index 000000000..312bf5f4c --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/PersonSchema.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/PosixAccountSchema.php b/app/plugins/LdapConnector/src/Model/Entity/PosixAccountSchema.php new file mode 100644 index 000000000..808f928bb --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/PosixAccountSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/PosixGroupSchema.php b/app/plugins/LdapConnector/src/Model/Entity/PosixGroupSchema.php new file mode 100644 index 000000000..e25350d3e --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/PosixGroupSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/VoPosixAccountSchema.php b/app/plugins/LdapConnector/src/Model/Entity/VoPosixAccountSchema.php new file mode 100644 index 000000000..8898d022e --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/VoPosixAccountSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/VoPosixGroupSchema.php b/app/plugins/LdapConnector/src/Model/Entity/VoPosixGroupSchema.php new file mode 100644 index 000000000..911104da5 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/VoPosixGroupSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php new file mode 100644 index 000000000..4bbc4184e --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php @@ -0,0 +1,378 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'eduMember'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + // For attribute-level type selection (eg hasMember identifier type) + $this->setAutoViewVars([ + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ] + ]); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for eduMember. + * + * Notes: + * - "extendedtype" is not supported in v5. + * - For hasMember, we provide a database-backed default identifier Type via default_type_id. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + // Default identifier type is UID; resolve to Types.id if we have CO context. + $coId = !empty($this->curCoId) ? (int)$this->curCoId : null; + $uidTypeId = null; + + if (!empty($coId)) { + $Types = TableRegistry::getTableLocator()->get('Types'); + // Type "value" is stored lower-case (eg "uid") in Registry defaults + $uidTypeId = $Types->getTypeId($coId, 'Identifiers.type', 'uid'); + } + + return [ + 'eduMember' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'isMemberOf' => [ + 'required' => false, + 'multiple' => true, + 'description' => __d('ldap_connector', 'field.LdapConnector.eduMember.isMemberOf') + ], + 'hasMember' => [ + 'required' => false, + 'multiple' => true, + 'default_type_id' => $uidTypeId, + 'description' => __d('ldap_connector', 'field.LdapConnector.eduMember.hasMember') + ] + ] + ] + ]; + } + + + /** + * Hook to assemble the attributes for this specific schema. + * + * Example return values: + * + * - For a Person provision (className = "People"): + * [ + * 'objectClass' => ['eduMember'], + * 'isMemberOf' => ['CO:members:active', 'CO:members:all'] + * ] + * + * - For a Group provision (className = "Groups"): + * [ + * 'objectClass' => ['eduMember'], + * 'hasMember' => ['alice', 'bob', 'carol'] + * ] + * + * - If no attributes are enabled/exported for this schema instance, or the current + * entity does not match the supported model for an attribute: + * [] + * + * Notes: + * - When this schema contributes any attributes, it also ensures 'objectClass' includes + * this schema's objectclass (eg 'eduMember'). + * + * @param ProvisioningTarget $provisioningTarget The provisioning target being processed. + * @param string $className Name of the model being provisioned (eg: People, Groups). + * @param object $data Provisioned entity (Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array Assembled attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + $ret = []; + + // Standardized config resolution (ProvisioningTarget -> LdapProvisioner -> LdapSchema -> LdapSchemaAttributes) + $cfg = $this->resolveSchemaAttributeConfigForProvisioningTarget( + $provisioningTarget, + 'LdapConnector.EduMemberSchemas', + $this->ldapObjectClass() + ); + + $cfgByAttr = $cfg['cfgByAttr']; + + // Short-circuit if nothing for this objectclass is enabled for export + $isMemberOfEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['isMemberOf'] ?? null); + $hasMemberEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['hasMember'] ?? null); + + if (!$isMemberOfEnabled && !$hasMemberEnabled) { + return $ret; + } + + // eduMember:isMemberOf (People) — export group names + if ($isMemberOfEnabled && $className === 'People' && $data instanceof Person) { + $names = []; + + foreach (($data->group_members ?? []) as $gm) { + $groupName = null; + + if (is_object($gm)) { + // Prefer contained Group + if (!empty($gm->group) && is_object($gm->group) && !empty($gm->group->name)) { + $groupName = (string)$gm->group->name; + } elseif (!empty($gm->group_id)) { + // Fallback: lookup Group name + try { + $Groups = TableRegistry::getTableLocator()->get('Groups'); + $g = $Groups->get((int)$gm->group_id); + if (!empty($g->name)) { + $groupName = (string)$g->name; + } + } catch (\Throwable $e) { + // best-effort + } + } + } + + if ($groupName !== null && $groupName !== '') { + $names[] = $groupName; + } + } + + $names = array_values(array_unique($names)); + + if (!empty($names)) { + $ret['isMemberOf'] = $names; + } + } + + // eduMember:hasMember (Groups) — member identifiers of configured type_id + if ($hasMemberEnabled && $className === 'Groups' && $data instanceof Group) { + $typeId = $cfgByAttr['hasMember']['type_id'] ?? null; + $typeId = ($typeId !== null && $typeId !== '') ? (int)$typeId : null; + + $values = []; + + if (!empty($typeId)) { + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + foreach (($data->group_members ?? []) as $gm) { + $personId = null; + + if (is_object($gm)) { + $personId = $gm->person_id ?? null; + } + + if (empty($personId)) { + continue; + } + + $idRows = $Identifiers->find() + ->select(['identifier']) + ->where([ + 'Identifiers.person_id' => (int)$personId, + 'Identifiers.type_id' => $typeId, + 'Identifiers.status' => SuspendableStatusEnum::Active, + ]) + ->enableHydration(false) + ->all() + ->toArray(); + + foreach ($idRows as $r) { + if (!empty($r['identifier'])) { + $values[] = (string)$r['identifier']; + } + } + } + } + + $values = array_values(array_unique(array_filter($values, static fn($v) => $v !== ''))); + + if (!empty($values)) { + $ret['hasMember'] = $values; + } elseif ($op === 'modify' || $op === 'rename') { + // v4 parity: on modify/rename, emit an empty list to clear any previous values + $ret['hasMember'] = []; + } + } + + $this->ensureSchemaObjectClass($ret); + + return $ret; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'edu_member_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate eduMember Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'edu_member_schema_id'], + 'Duplicate eduMember Schema definition for this LDAP Schema' + ), + 'uniqueEduMemberSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string { + return 'eduMember'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php new file mode 100644 index 000000000..eaea5100f --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php @@ -0,0 +1,312 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'eduPerson'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + // View vars for type pickers (Types-backed) + $this->setAutoViewVars([ + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'affiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + ]); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the parent LDAP Schema + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string + { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for eduPerson. + * + * Resolves database-backed default type IDs (where available) based on the current CO context. + * + * @return array Array of supported attributes and metadata for UI/configuration. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array + { + $coId = !empty($this->curCoId) ? (int)$this->curCoId : null; + + $Types = null; + if (!empty($coId)) { + $Types = TableRegistry::getTableLocator()->get('Types'); + } + + $preferredNameTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Names.type', 'preferred') : null); + + $eppnIdentifierTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Identifiers.type', 'eppn') : null); + $enterpriseIdentifierTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Identifiers.type', 'enterprise') : null); + + return [ + 'eduPerson' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'eduPersonAffiliation' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'PersonRoles.affiliation_type' + ], + 'eduPersonEntitlement' => [ + 'required' => false, + 'multiple' => true + ], + 'eduPersonNickname' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Names.type', + 'default_type_id' => $preferredNameTypeId + ], + 'eduPersonOrcid' => [ + 'required' => false, + 'multiple' => true, + 'alloworgvalue' => true + ], + 'eduPersonPrincipalName' => [ + 'required' => false, + 'multiple' => false, + 'alloworgvalue' => true, + 'type_attribute' => 'Identifiers.type', + 'default_type_id' => $eppnIdentifierTypeId + ], + 'eduPersonPrincipalNamePrior' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Identifiers.type', + 'default_type_id' => $eppnIdentifierTypeId + ], + 'eduPersonScopedAffiliation' => [ + 'required' => false, + 'multiple' => true, + 'requirescope' => true, + 'type_attribute' => 'PersonRoles.affiliation_type' + ], + 'eduPersonUniqueId' => [ + 'required' => false, + 'multiple' => false, + 'requirescope' => true, + 'type_attribute' => 'Identifiers.type', + 'default_type_id' => $enterpriseIdentifierTypeId + ] + ] + ] + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * This method is called during provisioning while building the outbound LDAP + * attribute set for an entry. Schema-specific implementations should: + * - return only attributes that are enabled for export in the schema instance + * configuration (required OR export), + * - optionally emit empty arrays (`attr => []`) on modify/rename to request + * attribute removal (deletion semantics depend on the caller). + * + * Stub behavior: + * - Returns an empty array (no attributes contributed by this schema). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'edu_person_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate eduPerson Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate eduPerson Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'edu_person_schema_id'], + 'Duplicate eduPerson Schema definition for this LDAP Schema' + ), + 'uniqueEduPersonSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string + { + return 'eduPerson'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php new file mode 100644 index 000000000..1e595e1ba --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php @@ -0,0 +1,417 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'groupOfNames'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for groupOfNames. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + return [ + 'groupOfNames' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'cn' => [ + 'required' => true, + 'multiple' => false + ], + 'member' => [ + 'required' => true, + 'multiple' => true + ], + 'owner' => [ + 'required' => false, + 'multiple' => true + ], + 'description' => [ + 'required' => false, + 'multiple' => false + ] + ] + ] + ]; + } + + /** + * Hook to assemble the attributes for this specific schema. + * + * Best-effort mapping for groupOfNames (Groups only): + * - objectClass: Always emitted when this schema contributes any attributes; includes this schema's objectclass. + * - cn: Group name (fallback: description, then ".") + * - description: Group description + * - member: Member DNs (resolved from LdapProvisionerDns for each active+valid member person_id) + * - owner: Owner DNs (best-effort; from $group->group_owners if present, else from owners_group_id membership) + * + * Example return value (for a Group provision): + * + * [ + * 'objectClass' => ['groupOfNames'], + * 'cn' => 'A+ Parkour Club', + * 'description' => 'we like to jump over things', + * 'member' => [ + * 'uid=rob,ou=people,dc=example,dc=org', + * 'uid=alice,ou=people,dc=example,dc=org', + * ], + * 'owner' => [ + * 'uid=rob,ou=people,dc=example,dc=org' + * ] + * ] + * + * Notes: + * - This method only returns attributes that are enabled for export (required OR export) + * in the schema instance configuration. + * - For parity with v4, group membership is re-evaluated as "active+valid" at assembly time + * (People.status in {Active, GracePeriod}, validity window on GroupMembers). + * - If member is enabled but no member DNs can be resolved, this method throws UnderflowException('member') + * (matching v4 groupOfNames behavior). + * + * @param ProvisioningTarget $provisioningTarget The provisioning target being processed. + * @param string $className Name of the model being provisioned (eg: People, Groups). + * @param object $data Provisioned entity (Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array Assembled attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + $ret = []; + + if ($className !== 'Groups' || !($data instanceof Group)) { + return $ret; + } + + $cfg = $this->resolveSchemaAttributeConfigForProvisioningTarget( + $provisioningTarget, + 'LdapConnector.GroupOfNamesSchemas', + $this->ldapObjectClass() + ); + + $cfgByAttr = $cfg['cfgByAttr']; + + $cnEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['cn'] ?? null); + $descriptionEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['description'] ?? null); + $memberEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['member'] ?? null); + $ownerEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['owner'] ?? null); + + if (!$cnEnabled && !$descriptionEnabled && !$memberEnabled && !$ownerEnabled) { + return $ret; + } + + // cn + if ($cnEnabled) { + if (!empty($data->name)) { + $ret['cn'] = (string)$data->name; + } elseif (!empty($data->description)) { + $ret['cn'] = (string)$data->description; + } else { + $ret['cn'] = '.'; + } + } + + // description + if ($descriptionEnabled && !empty($data->description)) { + $ret['description'] = (string)$data->description; + } + + // Resolve DNs for members/owners via LdapProvisionerDns + $ldapProvisionerId = $this->resolveLdapProvisionerIdForProvisioningTarget($provisioningTarget); + if (empty($ldapProvisionerId)) { + return $ret; + } + + $LdapProvisionerDns = TableRegistry::getTableLocator()->get('LdapConnector.LdapProvisionerDns'); + + $resolvePersonDn = static function (int $personId) use ($LdapProvisionerDns, $ldapProvisionerId): ?string { + if ($personId < 1) { + return null; + } + + $row = $LdapProvisionerDns->find() + ->select(['dn']) + ->where([ + 'LdapProvisionerDns.ldap_provisioner_id' => $ldapProvisionerId, + 'LdapProvisionerDns.person_id' => $personId, + ]) + ->enableHydration(false) + ->first(); + + return (!empty($row['dn']) ? (string)$row['dn'] : null); + }; + + // member + if ($memberEnabled) { + $dns = []; + + foreach (($data->group_members ?? []) as $gm) { + if (!is_object($gm) || empty($gm->person_id)) { + continue; + } + + $dn = $resolvePersonDn((int)$gm->person_id); + if (!empty($dn)) { + $dns[] = $dn; + } + } + + $dns = array_values(array_unique(array_filter($dns, static fn($v) => $v !== ''))); + + if (empty($dns)) { + throw new \UnderflowException('member'); + } + + $ret['member'] = $dns; + } + + // owner + if ($ownerEnabled) { + $ownerDns = []; + + // If caller provided explicit owners on the entity + foreach (($data->group_owners ?? []) as $go) { + if (!is_object($go) || empty($go->person_id)) { + continue; + } + + $dn = $resolvePersonDn((int)$go->person_id); + if (!empty($dn)) { + $ownerDns[] = $dn; + } + } + + // Else, best-effort: owners are members of owners_group_id + if (empty($ownerDns) && !empty($data->owners_group_id)) { + try { + $GroupMembers = TableRegistry::getTableLocator()->get('GroupMembers'); + + $rows = $GroupMembers->find() + ->select(['person_id']) + ->where([ + 'GroupMembers.group_id' => (int)$data->owners_group_id, + 'GroupMembers.deleted' => false, + ]) + ->enableHydration(false) + ->all() + ->toArray(); + + foreach ($rows as $r) { + if (empty($r['person_id'])) { + continue; + } + + $dn = $resolvePersonDn((int)$r['person_id']); + if (!empty($dn)) { + $ownerDns[] = $dn; + } + } + } catch (\Throwable $e) { + // best-effort + } + } + + $ownerDns = array_values(array_unique(array_filter($ownerDns, static fn($v) => $v !== ''))); + + if (!empty($ownerDns)) { + // Normal case: emit owner DNs + $ret['owner'] = $ownerDns; + } else { + // v4 parity: on modify/rename, emit an empty list to clear any previous owner attribute; + // on add, omit owner entirely. + if ($op === 'modify' || $op === 'rename') { + $ret['owner'] = []; + } + } + } + + $this->ensureSchemaObjectClass($ret); + + return $ret; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'group_of_names_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate Group Of Names Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate Group Of Names Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'group_of_names_schema_id'], + 'Duplicate Group Of Names Schema definition for this LDAP Schema' + ), + 'uniqueGroupOfNamesSchemPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string { + return 'groupOfNames'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/InetOrgPersonSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/InetOrgPersonSchemasTable.php new file mode 100644 index 000000000..5966f8629 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/InetOrgPersonSchemasTable.php @@ -0,0 +1,871 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'inetOrgPerson'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + // View vars for type pickers (Types-backed). + // These are used by the schemaAttributes UI element to render per-attribute type dropdowns. + $this->setAutoViewVars([ + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type', + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type', + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type', + ], + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type', + ], + 'urlTypes' => [ + 'type' => 'type', + 'attribute' => 'Urls.type', + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ], + ]); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'], + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + ], + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the parent LDAP Schema + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string + { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for inetOrgPerson. + * + * Notes: + * - "Extended attributes" are not used in v5; per-attribute types are handled via Types + type_id. + * - Default types are resolved via Types->getTypeId() (database-backed). + * + * @return array Array of supported attributes and metadata for UI/configuration. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array + { + $coId = !empty($this->curCoId) ? (int)$this->curCoId : null; + + $Types = null; + if (!empty($coId)) { + $Types = TableRegistry::getTableLocator()->get('Types'); + } + + $officialNameTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Names.type', 'official') : null); + $preferredNameTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Names.type', 'preferred') : null); + + $officialEmailTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'EmailAddresses.type', 'official') : null); + $officeAddrTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Addresses.type', 'official') : null); + $officialUrlTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Urls.type', 'official') : null); + + $mobileTelTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'TelephoneNumbers.type', 'mobile') : null); + + $eppnIdentifierTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Identifiers.type', 'eppn') : null); + $uidIdentifierTypeId = (!empty($Types) ? $Types->getTypeId($coId, 'Identifiers.type', 'uid') : null); + + return [ + 'inetOrgPerson' => [ + 'objectclass' => [ + 'required' => true, + ], + 'attributes' => [ + 'givenName' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Names.type', + 'default_type_id' => $officialNameTypeId, + ], + 'displayName' => [ + 'required' => false, + 'multiple' => false, + 'type_attribute' => 'Names.type', + 'default_type_id' => $preferredNameTypeId, + ], + 'o' => [ + 'required' => false, + 'multiple' => true, + ], + 'labeledURI' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Urls.type', + 'default_type_id' => $officialUrlTypeId, + ], + 'mail' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'EmailAddresses.type', + 'default_type_id' => $officialEmailTypeId, + ], + 'mobile' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'TelephoneNumbers.type', + 'default_type_id' => $mobileTelTypeId, + ], + 'employeeNumber' => [ + 'required' => false, + 'multiple' => false, + 'type_attribute' => 'Identifiers.type', + 'default_type_id' => $eppnIdentifierTypeId, + ], + 'employeeType' => [ + 'required' => false, + 'multiple' => true, + ], + 'roomNumber' => [ + 'required' => false, + 'multiple' => true, + 'grouping' => 'address', + ], + 'uid' => [ + 'required' => false, + 'multiple' => true, + 'alloworgvalue' => true, + 'type_attribute' => 'Identifiers.type', + 'default_type_id' => $uidIdentifierTypeId, + ], + ], + 'groupings' => [ + 'address' => [ + 'label' => 'Address', + 'multiple' => true, + 'default_type_id' => $officeAddrTypeId, + ], + ], + ], + ]; + } + + /** + * Hook to assemble the attributes for this specific schema. + * + * This method is invoked by {@see \LdapConnector\Model\Table\LdapProvisionersTable} + * while building the LDAP attribute set for an entry. It returns a plain PHP array + * suitable for passing to {@see \CoreServer\Model\Table\LdapServersTable::addEntry()} + * / {@see \CoreServer\Model\Table\LdapServersTable::modReplace()}. + * + * Operation semantics (v4 parity): + * - On {@see $op} = 'add': emit only attributes with values. + * - On {@see $op} = 'modify' or 'rename': if an attribute is enabled for export but + * cannot be populated, emit an empty array (`attr => []`) to request removal of + * any previously provisioned values (matching v4 "mod_replace clears with []"). + * + * Mapping summary (People only, inetOrgPerson): + * - givenName: + * - From the Person's primary name `given` (v4 used PrimaryName.given). + * - displayName: + * - From the first matching Name record by configured type_id (fallback: primary name), + * rendered as a CN-like string (see renderName()). + * - o: + * - Best-effort from contained Person Roles `organization` values. + * - labeledURI: + * - From contained Url records of configured type_id; if a description is present, + * append as "URL DESCRIPTION" (v4 behavior). + * - mail: + * - From contained EmailAddress records of configured type_id. + * - mobile: + * - From contained TelephoneNumber records of configured type_id, searched on the + * Person and then on each contained Person Role. + * - employeeNumber: + * - From the first matching Identifier record of configured type_id (single-valued). + * - employeeType: + * - Best-effort from contained Person Role affiliation_type->value (unmapped). + * - roomNumber: + * - Best-effort from contained Address.room values (Person then Roles), filtered by + * configured/default address type (grouping semantics). + * - uid: + * - From Identifier records of configured type_id; if use_org_value is enabled and + * organizational identities are contained, prefer org identity identifiers first. + * + * Example (typical add): + * + * [ + * 'objectClass' => ['inetOrgPerson'], + * 'givenName' => ['Robert'], + * 'displayName' => 'Robert Andrews', + * 'o' => ['Example University'], + * 'mail' => ['robert.andrews@example.org'], + * 'mobile' => ['+1 555 0100'], + * 'employeeNumber' => 'robert.andrews@example.org', + * 'employeeType' => ['staff'], + * 'roomNumber' => ['Bldg 1, Room 203'], + * 'labeledURI' => ['https://example.org/profile/ra Profile'], + * 'uid' => ['randrews'] + * ] + * + * + * Example (modify/rename where some values are now absent and must be cleared): + * + * [ + * 'objectClass' => ['inetOrgPerson'], + * 'mobile' => [], + * 'roomNumber' => [], + * 'labeledURI' => [] + * ] + * + * + * Notes: + * - This method only returns attributes that are enabled for export (required OR export) + * in the schema instance configuration (ldap_schema_attributes rows). + * - When this schema contributes any attributes, it also ensures 'objectClass' includes + * this schema's objectclass (eg 'inetOrgPerson'). + * - Value normalization/deduplication is performed conservatively here (array_unique); + * deeper normalization is handled by the LDAP server and/or upstream provisioning rules. + * + * @param ProvisioningTarget $provisioningTarget The provisioning target being processed. + * @param string $className Name of the model being provisioned (eg: People, Groups). + * @param object $data Provisioned entity (Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array Assembled attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + $ret = []; + + // inetOrgPerson only applies to People provisions + if ($className !== 'People' || !($data instanceof \App\Model\Entity\Person)) { + return $ret; + } + + $isModifyLike = ($op === 'modify' || $op === 'rename'); + + // Resolve per-schema attribute export config (required OR export) + $cfg = $this->resolveSchemaAttributeConfigForProvisioningTarget( + $provisioningTarget, + 'LdapConnector.InetOrgPersonSchemas', + $this->ldapObjectClass() + ); + + $cfgByAttr = $cfg['cfgByAttr']; + + $givenNameEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['givenName'] ?? null); + $displayNameEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['displayName'] ?? null); + $oEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['o'] ?? null); + $labeledUriEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['labeledURI'] ?? null); + $mailEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['mail'] ?? null); + $mobileEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['mobile'] ?? null); + $employeeNumberEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['employeeNumber'] ?? null); + $employeeTypeEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['employeeType'] ?? null); + $roomNumberEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['roomNumber'] ?? null); + $uidEnabled = $this->ldapSchemaAttributeEnabled($cfgByAttr['uid'] ?? null); + + if ( + !$givenNameEnabled + && !$displayNameEnabled + && !$oEnabled + && !$labeledUriEnabled + && !$mailEnabled + && !$mobileEnabled + && !$employeeNumberEnabled + && !$employeeTypeEnabled + && !$roomNumberEnabled + && !$uidEnabled + ) { + return $ret; + } + + $coId = !empty($data->co_id) ? (int)$data->co_id : null; + + // givenName (v4: from PrimaryName->given) + if ($givenNameEnabled) { + $primaryName = $data->primary_name ?? null; + + if (is_object($primaryName) && !empty($primaryName->given)) { + $ret['givenName'] = [(string)$primaryName->given]; + } elseif ($isModifyLike) { + $ret['givenName'] = []; + } + } + + // displayName (v4: first matching Name by type, rendered via generateCn) + if ($displayNameEnabled) { + $targetTypeId = $cfgByAttr['displayName']['type_id'] ?? null; + $targetTypeId = ($targetTypeId !== null && $targetTypeId !== '') ? (int)$targetTypeId : null; + + if (empty($targetTypeId)) { + $targetTypeId = $this->resolveTypeId($coId, 'Names.type', 'preferred'); + } + + $chosen = null; + + foreach (($data->names ?? []) as $n) { + if (!is_object($n)) { + continue; + } + + if (!empty($targetTypeId)) { + $nTypeId = (!empty($n->type_id) ? (int)$n->type_id : null); + if (empty($nTypeId) || $nTypeId !== $targetTypeId) { + continue; + } + } + + $chosen = $n; + break; + } + + // Fallback to primary name if no matching Name is present + if ($chosen === null && is_object($data->primary_name ?? null)) { + $chosen = $data->primary_name; + } + + if ($chosen !== null) { + $rendered = $this->renderName($chosen); + if ($rendered !== '') { + $ret['displayName'] = $rendered; + } elseif ($isModifyLike) { + $ret['displayName'] = []; + } + } elseif ($isModifyLike) { + $ret['displayName'] = []; + } + } + + // o (v4: from CoPersonRole.o; v5 best-effort: PersonRole.organization) + if ($oEnabled) { + $vals = []; + + foreach (($data->person_roles ?? []) as $pr) { + if (!is_object($pr)) { + continue; + } + + $org = $pr->organization ?? null; + if ($org !== null && (string)$org !== '') { + $vals[] = (string)$org; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['o'] = $vals; + } elseif ($isModifyLike) { + $ret['o'] = []; + } + } + + // labeledURI (v4: url + " " + description if present) + if ($labeledUriEnabled) { + $targetTypeId = $cfgByAttr['labeledURI']['type_id'] ?? null; + $targetTypeId = ($targetTypeId !== null && $targetTypeId !== '') ? (int)$targetTypeId : null; + + if (empty($targetTypeId)) { + $targetTypeId = $this->resolveTypeId($coId, 'Urls.type', 'official'); + } + + $vals = []; + + foreach (($data->urls ?? []) as $u) { + if (!is_object($u) || empty($u->url)) { + continue; + } + + if (!empty($targetTypeId)) { + $uTypeId = (!empty($u->type_id) ? (int)$u->type_id : null); + if (empty($uTypeId) || $uTypeId !== $targetTypeId) { + continue; + } + } + + $v = (string)$u->url; + + if (!empty($u->description)) { + $v .= ' ' . (string)$u->description; + } + + if ($v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['labeledURI'] = $vals; + } elseif ($isModifyLike) { + $ret['labeledURI'] = []; + } + } + + // mail + if ($mailEnabled) { + $targetTypeId = $cfgByAttr['mail']['type_id'] ?? null; + $targetTypeId = ($targetTypeId !== null && $targetTypeId !== '') ? (int)$targetTypeId : null; + + if (empty($targetTypeId)) { + $targetTypeId = $this->resolveTypeId($coId, 'EmailAddresses.type', 'official'); + } + + $vals = []; + + foreach (($data->email_addresses ?? []) as $em) { + if (!is_object($em) || empty($em->mail)) { + continue; + } + + if (!empty($targetTypeId)) { + $emTypeId = (!empty($em->type_id) ? (int)$em->type_id : null); + if (empty($emTypeId) || $emTypeId !== $targetTypeId) { + continue; + } + } + + $vals[] = (string)$em->mail; + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['mail'] = $vals; + } elseif ($isModifyLike) { + $ret['mail'] = []; + } + } + + // mobile (v4: from role telephone numbers; v5 best-effort: person + roles) + if ($mobileEnabled) { + $targetTypeId = $cfgByAttr['mobile']['type_id'] ?? null; + $targetTypeId = ($targetTypeId !== null && $targetTypeId !== '') ? (int)$targetTypeId : null; + + if (empty($targetTypeId)) { + $targetTypeId = $this->resolveTypeId($coId, 'TelephoneNumbers.type', 'mobile'); + } + + $vals = []; + + $consumeTelList = static function (array $tels) use (&$vals, $targetTypeId): void { + foreach ($tels as $tn) { + if (!is_object($tn) || empty($tn->number)) { + continue; + } + + if (!empty($targetTypeId)) { + $tnTypeId = (!empty($tn->type_id) ? (int)$tn->type_id : null); + if (empty($tnTypeId) || $tnTypeId !== $targetTypeId) { + continue; + } + } + + $vals[] = (string)$tn->number; + } + }; + + $consumeTelList((array)($data->telephone_numbers ?? [])); + + foreach (($data->person_roles ?? []) as $pr) { + if (!is_object($pr)) { + continue; + } + $consumeTelList((array)($pr->telephone_numbers ?? [])); + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['mobile'] = $vals; + } elseif ($isModifyLike) { + $ret['mobile'] = []; + } + } + + // employeeNumber (v4: from Identifier of configured type) + if ($employeeNumberEnabled) { + $targetTypeId = $cfgByAttr['employeeNumber']['type_id'] ?? null; + $targetTypeId = ($targetTypeId !== null && $targetTypeId !== '') ? (int)$targetTypeId : null; + + if (empty($targetTypeId)) { + $targetTypeId = $this->resolveTypeId($coId, 'Identifiers.type', 'eppn'); + } + + $val = null; + + foreach (($data->identifiers ?? []) as $id) { + if (!is_object($id) || empty($id->identifier)) { + continue; + } + + if (!empty($targetTypeId)) { + $idTypeId = (!empty($id->type_id) ? (int)$id->type_id : null); + if (empty($idTypeId) || $idTypeId !== $targetTypeId) { + continue; + } + } + + $val = (string)$id->identifier; + break; + } + + if ($val !== null && $val !== '') { + $ret['employeeNumber'] = $val; + } elseif ($isModifyLike) { + $ret['employeeNumber'] = []; + } + } + + // employeeType (v4: role affiliation, no mapping) + if ($employeeTypeEnabled) { + $vals = []; + + foreach (($data->person_roles ?? []) as $pr) { + if (!is_object($pr)) { + continue; + } + + $v = null; + + if (!empty($pr->affiliation_type) + && is_object($pr->affiliation_type) + && !empty($pr->affiliation_type->value)) { + $v = (string)$pr->affiliation_type->value; + } + + if ($v !== null && $v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['employeeType'] = $vals; + } elseif ($isModifyLike) { + $ret['employeeType'] = []; + } + } + + // roomNumber (grouping address) (v4: from role Address.roomNumber-ish; v5: Address.room) + if ($roomNumberEnabled) { + $targetTypeId = $this->resolveTypeId($coId, 'Addresses.type', 'official'); + + $vals = []; + + $consumeAddrList = static function (array $addrs) use (&$vals, $targetTypeId): void { + foreach ($addrs as $a) { + if (!is_object($a)) { + continue; + } + + if (!empty($targetTypeId)) { + $aTypeId = (!empty($a->type_id) ? (int)$a->type_id : null); + if (empty($aTypeId) || $aTypeId !== $targetTypeId) { + continue; + } + } + + if (!empty($a->room)) { + $vals[] = (string)$a->room; + } + } + }; + + $consumeAddrList((array)($data->addresses ?? [])); + + foreach (($data->person_roles ?? []) as $pr) { + if (!is_object($pr)) { + continue; + } + $consumeAddrList((array)($pr->addresses ?? [])); + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['roomNumber'] = $vals; + } elseif ($isModifyLike) { + $ret['roomNumber'] = []; + } + } + + // uid (v4: Identifier, optionally from Org Identity if configured) + if ($uidEnabled) { + $useOrgValue = !empty($cfgByAttr['uid']['use_org_value']); + + $targetTypeId = $cfgByAttr['uid']['type_id'] ?? null; + $targetTypeId = ($targetTypeId !== null && $targetTypeId !== '') ? (int)$targetTypeId : null; + + if (empty($targetTypeId)) { + $targetTypeId = $this->resolveTypeId($coId, 'Identifiers.type', 'uid'); + } + + $vals = []; + + $consumeIdentifierList = static function (array $idents) use (&$vals, $targetTypeId): void { + foreach ($idents as $id) { + if (!is_object($id) || empty($id->identifier)) { + continue; + } + + if (!empty($targetTypeId)) { + $idTypeId = (!empty($id->type_id) ? (int)$id->type_id : null); + if (empty($idTypeId) || $idTypeId !== $targetTypeId) { + continue; + } + } + + $vals[] = (string)$id->identifier; + } + }; + + if ($useOrgValue) { + foreach (($data->org_identity_links ?? []) as $lnk) { + if (!is_object($lnk) || empty($lnk->org_identity) || !is_object($lnk->org_identity)) { + continue; + } + $consumeIdentifierList((array)($lnk->org_identity->identifiers ?? [])); + } + } + + if (empty($vals)) { + $consumeIdentifierList((array)($data->identifiers ?? [])); + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['uid'] = $vals; + } elseif ($isModifyLike) { + $ret['uid'] = []; + } + } + + $this->ensureSchemaObjectClass($ret); + + return $ret; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'inet_org_person_schema_id']], + ], + 'provider' => 'table', + 'message' => 'Duplicate inetOrgPerson Schema definition for this LDAP Schema', + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate inetOrgPerson Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'inet_org_person_schema_id'], + 'Duplicate inetOrgPerson Schema definition for this LDAP Schema' + ), + 'uniqueInetOrgPersonSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string + { + return 'inetOrgPerson'; + } + + /** + * Helper: resolve default Types.id for a type "value". + * + * @param int|null $coId + * @param string $attribute Eg: 'Names.type' + * @param string $value Eg: 'official' + * @return int|null + */ + private function resolveTypeId(?int $coId, string $attribute, string $value): ?int + { + if (empty($coId)) { + return null; + } + + try { + $Types = TableRegistry::getTableLocator()->get('Types'); + $id = $Types->getTypeId($coId, $attribute, $value); + + return (!empty($id) ? (int)$id : null); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Helper: render a "CN-like" string from a Name(-like) entity. + * + * @param object $name + * @return string + */ + private function renderName(object $name): string + { + if (isset($name->full_name) && (string)$name->full_name !== '') { + return (string)$name->full_name; + } + + $given = !empty($name->given) ? (string)$name->given : ''; + $middle = !empty($name->middle) ? (string)$name->middle : ''; + $family = !empty($name->family) ? (string)$name->family : ''; + + $s = trim($given . ' ' . $middle . ' ' . $family); + + $collapsed = ($s !== '' ? preg_replace('/\s+/', ' ', $s) : '.'); + return ($collapsed !== null ? $collapsed : '.'); + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionerDnsTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionerDnsTable.php new file mode 100644 index 000000000..7e8cdce01 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionerDnsTable.php @@ -0,0 +1,643 @@ +setTable('ldap_provisioner_dns'); + $this->setPrimaryKey('id'); + $this->setDisplayField('dn'); + + // Changelog-safe operational state + $this->addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapProvisioners', [ + 'foreignKey' => 'ldap_provisioner_id', + 'joinType' => 'INNER', + ]); + + $this->belongsTo('People', [ + 'foreignKey' => 'person_id', + ]); + + $this->belongsTo('Groups', [ + 'foreignKey' => 'group_id', + ]); + + // Rows are always scoped to a specific LdapProvisioner instance. + $this->setPrimaryLink(['LdapConnector.ldap_provisioner_id']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'], + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + ], + ]); + } + + /** + * Default validation rules. + * + * Enforces: + * - required ldap_provisioner_id + * - dn length and non-empty constraint + * - XOR constraint: exactly one of person_id or group_id must be set + * - changelog-safe uniqueness (application-level) for: + * - (ldap_provisioner_id, person_id) among current rows + * - (ldap_provisioner_id, group_id) among current rows + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator Modified validator instance. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('ldap_provisioner_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->notEmptyString('ldap_provisioner_id'); + + $validator->add('person_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->allowEmptyString('person_id'); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->allowEmptyString('group_id'); + + $validator->scalar('dn') + ->maxLength('dn', 256) + ->notEmptyString('dn'); + + // Exactly one of person_id or group_id must be present (XOR). + $validator->add('person_id', 'personXorGroup', [ + 'rule' => function ($value, $context) { + $data = $context['data'] ?? []; + $hasPerson = !empty($data['person_id'] ?? null); + $hasGroup = !empty($data['group_id'] ?? null); + + return ($hasPerson xor $hasGroup); + }, + 'message' => 'Exactly one of person_id or group_id must be provided', + ]); + + // Changelog-safe uniqueness. + // + // Important: The scope includes: + // - ldap_provisioner_id + // - person_id OR group_id + // - revision + // - ldap_provisioner_dn_id (the Changelog parent FK for archived rows) + // + // This matches the general pattern used by other changelog-enabled tables. + + $validator->add('person_id', 'uniquePersonPerProvisionerCurrentRow', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_provisioner_id', 'person_id', 'revision', 'ldap_provisioner_dn_id']] + ], + 'provider' => 'table', + 'message' => 'DN mapping already exists for this person and provisioner' + ]); + + $validator->add('group_id', 'uniqueGroupPerProvisionerCurrentRow', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_provisioner_id', 'group_id', 'revision', 'ldap_provisioner_dn_id']] + ], + 'provider' => 'table', + 'message' => 'DN mapping already exists for this group and provisioner' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Enforces changelog-safe uniqueness (application-level) for: + * - (ldap_provisioner_id, person_id) among current rows + * - (ldap_provisioner_id, group_id) among current rows + * + * Note: Without DB unique constraints, duplicates remain possible under concurrency. + * + * @param RulesChecker $rules Rules checker. + * @return RulesChecker + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add( + $rules->isUnique( + ['ldap_provisioner_id', 'person_id', 'revision', 'ldap_provisioner_dn_id'], + 'DN mapping already exists for this person and provisioner' + ), + 'uniquePersonPerProvisionerCurrentRow', + ['errorField' => 'person_id'] + ); + + $rules->add( + $rules->isUnique( + ['ldap_provisioner_id', 'group_id', 'revision', 'ldap_provisioner_dn_id'], + 'DN mapping already exists for this group and provisioner' + ), + 'uniqueGroupPerProvisionerCurrentRow', + ['errorField' => 'group_id'] + ); + + return $rules; + } + + /** + * Escape an RDN value for DN construction. + * + * @param string $value RDN value + * @return string Escaped value + * @since COmanage Registry v5.3.0 + */ + protected function escapeRdnValue(string $value): string + { + if (function_exists('ldap_escape')) { + return ldap_escape($value, '', LDAP_ESCAPE_DN); + } + + // Fallback: minimal RFC4514-ish escaping. + $value = str_replace( + ['\\', ',', '+', '"', '<', '>', ';', '='], + ['\\\\', '\,', '\+', '\"', '\<', '\>', '\;', '\='], + $value + ); + + if ($value !== '' && $value[0] === '#') { + $value = '\#' . substr($value, 1); + } + + $value = preg_replace('/^ /', '\ ', $value); + $value = preg_replace('/ $/', '\ ', $value); + + return (string)$value; + } + + /** + * Assign a DN for a Person during provisioning. + * + * @param ProvisioningTarget $provisioningTarget Provisioning target entity (contains ldap_provisioner config). + * @param Person $person Provisioned person entity (expects identifiers to be contained). + * @param string $baseDn Base DN to append (eg: "ou=people,dc=example,dc=org"). + * @return string DN + * + * @throws \RuntimeException + * @since COmanage Registry v5.3.0 + */ + public function assignPersonDn( + ProvisioningTarget $provisioningTarget, + Person $person, + string $baseDn + ): string { + $dnAttributeName = $provisioningTarget->ldap_provisioner->dn_attribute_name ?? null; + $dnIdentifierTypeId = $provisioningTarget->ldap_provisioner->dn_identifier_type_id ?? null; + + if (empty($dnAttributeName) || empty($dnIdentifierTypeId) || empty($baseDn)) { + throw new \RuntimeException(__d('ldap_connector', 'DN configuration is incomplete')); + } + + $identifiers = $person->identifiers ?? []; + + foreach ($identifiers as $identifier) { + $typeId = $identifier->type_id ?? null; + $value = $identifier->identifier ?? null; + $status = $identifier->status ?? null; + + if (!empty($typeId) + && (int)$typeId === (int)$dnIdentifierTypeId + && !empty($value) + && (string)$status === StatusEnum::Active) { + $escaped = $this->escapeRdnValue((string)$value); + return $dnAttributeName . '=' . $escaped . ',' . $baseDn; + } + } + + // We can't proceed without a DN. + throw new \RuntimeException( + __d( + 'ldap_connector', + 'No active identifier found for DN Identifier Type ID {0}', + [(string)$dnIdentifierTypeId] + ) + ); + } + + /** + * Determine the RDN attributes used to generate a DN. + * + * @param string $dn Full DN (eg: "uid=alice,ou=people,dc=example,dc=org") + * @param string $baseDn Base DN suffix (eg: "ou=people,dc=example,dc=org") + * @return array Attribute/value pairs (RDN components), not including the base DN + * @since COmanage Registry v5.3.0 + */ + public function dnAttributes(string $dn, string $baseDn): array + { + $ret = []; + + if ($dn === '' || $baseDn === '') { + return $ret; + } + + $suffix = ',' . $baseDn; + + // Only strip baseDn if it is actually a suffix. + if (strlen($dn) <= strlen($suffix) || strcasecmp(substr($dn, -strlen($suffix)), $suffix) !== 0) { + return $ret; + } + + $prefix = rtrim(substr($dn, 0, -strlen($suffix)), " ,"); + if ($prefix === '') { + return $ret; + } + + $attrs = explode(',', $prefix); + + foreach ($attrs as $a) { + $a = trim($a); + if ($a === '') { + continue; + } + + $av = explode('=', $a, 2); + if (count($av) !== 2 || $av[0] === '') { + continue; + } + + $ret[$av[0]] = $av[1]; + } + + return $ret; + } + + /** + * Obtain a DN for a Person, possibly assigning or reassigning one. + * + * @param ProvisioningTarget $provisioningTarget Provisioning target entity (contains ldap_provisioner config). + * @param Person $person Provisioned person entity (expects identifiers to be contained). + * @param string $baseDn Base DN suffix (eg: "ou=people,dc=example,dc=org"). + * @param bool $assign Whether to assign a DN if one is not found and reassign if the DN should be changed. + * @return array An array of the following: + * - olddn: Old (current) DN (may be null) + * - olddnid: Database row ID of old dn (may be null, to facilitate delete) + * - newdn: New DN (may be null) + * - newdnerr: Error message if new DN cannot be assigned + * @throws \RuntimeException + * @since COmanage Registry v5.3.0 + */ + public function obtainPersonDn( + ProvisioningTarget $provisioningTarget, + Person $person, + string $baseDn, + bool $assign = true + ): array { + $curDn = null; + $curDnId = null; + $newDn = null; + $newDnErr = null; + + $ldapProvisionerId = $provisioningTarget->ldap_provisioner->id ?? null; + if (empty($ldapProvisionerId) || empty($person->id)) { + return [ + 'olddn' => null, + 'olddnid' => null, + 'newdn' => null, + 'newdnerr' => __d('ldap_connector', 'Unable to determine provisioner or person ID'), + ]; + } + + $dnEntity = $this->find() + ->where([ + 'LdapProvisionerDns.ldap_provisioner_id' => (int)$ldapProvisionerId, + 'LdapProvisionerDns.person_id' => (int)$person->id, + ]) + ->first(); + + if ($dnEntity) { + $curDn = $dnEntity->dn ?? null; + $curDnId = $dnEntity->id ?? null; + } + + try { + $newDn = $this->assignPersonDn($provisioningTarget, $person, $baseDn); + } catch (\Throwable $e) { + $newDnErr = $e->getMessage(); + } + + if ($assign) { + if (!empty($newDn) && $curDn !== $newDn) { + if ($dnEntity) { + $dnEntity = $this->patchEntity($dnEntity, ['dn' => $newDn]); + } else { + $dnEntity = $this->newEntity([ + 'ldap_provisioner_id' => (int)$ldapProvisionerId, + 'person_id' => (int)$person->id, + 'dn' => $newDn, + ]); + } + + if (!$this->save($dnEntity)) { + throw new \RuntimeException(__d('ldap_connector', 'Failed to save DN mapping')); + } + } + } + + return [ + 'olddn' => $curDn, + 'olddnid' => $curDnId, + 'newdn' => $newDn, + 'newdnerr' => $newDnErr, + ]; + } + + /** + * Map a set of Person IDs to their provisioned DNs for a given LDAP provisioner. + * + * @param int $ldapProvisionerId LdapProvisioners.id + * @param array $personIds Person IDs to resolve + * @return array Array of DNs found (unordered, may have fewer entries than requested) + * @since COmanage Registry v5.3.0 + */ + public function dnsForPeopleIds(int $ldapProvisionerId, array $personIds): array + { + $personIds = array_values(array_unique(array_map('intval', $personIds))); + $personIds = array_values(array_filter($personIds, static fn($v) => $v > 0)); + + if (empty($personIds)) { + return []; + } + + $rows = $this->find() + ->select(['dn']) + ->where([ + 'LdapProvisionerDns.ldap_provisioner_id' => $ldapProvisionerId, + 'LdapProvisionerDns.person_id IN' => $personIds, + ]) + ->enableHydration(false) + ->all() + ->toArray(); + + $dns = []; + foreach ($rows as $r) { + if (!empty($r['dn'])) { + $dns[] = (string)$r['dn']; + } + } + + return $dns; + } + + /** + * Extract Person IDs from GroupMember entities/rows. + * + * @param array $groupMembers Array of GroupMember entities (or array-ish rows) + * @return array Person IDs + * @since COmanage Registry v5.3.0 + */ + public function extractPersonIdsFromGroupMembers(array $groupMembers): array + { + $ids = []; + + foreach ($groupMembers as $m) { + $personId = null; + + if (is_object($m)) { + $personId = $m->person_id ?? null; + } elseif (is_array($m)) { + $personId = $m['person_id'] ?? null; + } + + if ($personId !== null && $personId !== '') { + $ids[] = (int)$personId; + } + } + + $ids = array_values(array_unique(array_filter($ids, static fn($v) => $v > 0))); + + return $ids; + } + + /** + * Assign a DN for a Group during provisioning. + * + * @param ProvisioningTarget $provisioningTarget Provisioning target entity (contains ldap_provisioner config). + * @param Group $group Provisioned group entity. + * @param string $groupBaseDn Base DN to append (eg: "ou=groups,dc=example,dc=org"). + * @return string DN + * + * @throws \RuntimeException + * @since COmanage Registry v5.3.0 + */ + public function assignGroupDn( + ProvisioningTarget $provisioningTarget, + Group $group, + string $groupBaseDn + ): string { + $groupName = $group->name ?? null; + + if (empty($groupName)) { + throw new \RuntimeException(__d('ldap_connector', 'Group DN component is missing: {0}', ['cn'])); + } + + if (empty($groupBaseDn)) { + throw new \RuntimeException(__d('ldap_connector', 'DN configuration is incomplete')); + } + + $escaped = $this->escapeRdnValue((string)$groupName); + + return 'cn=' . $escaped . ',' . $groupBaseDn; + } + + /** + * Obtain a DN for a Group, possibly assigning or reassigning one. + * + * @param ProvisioningTarget $provisioningTarget Provisioning target entity (contains ldap_provisioner config). + * @param Group $group Provisioned group entity. + * @param string $groupBaseDn Base DN suffix (eg: "ou=groups,dc=example,dc=org"). + * @param bool $assign Whether to assign a DN if one is not found and reassign if the DN should be changed. + * @return array An array of the following: + * - olddn: Old (current) DN (may be null) + * - olddnid: Database row ID of old dn (may be null, to facilitate delete) + * - newdn: New DN (may be null) + * - newdnerr: Error message if new DN cannot be assigned + * @throws \RuntimeException + * @since COmanage Registry v5.3.0 + */ + public function obtainGroupDn( + ProvisioningTarget $provisioningTarget, + Group $group, + string $groupBaseDn, + bool $assign = true + ): array { + $curDn = null; + $curDnId = null; + $newDn = null; + $newDnErr = null; + + $ldapProvisionerId = $provisioningTarget->ldap_provisioner->id ?? null; + if (empty($ldapProvisionerId) || empty($group->id)) { + return [ + 'olddn' => null, + 'olddnid' => null, + 'newdn' => null, + 'newdnerr' => __d('ldap_connector', 'Unable to determine provisioner or group ID'), + ]; + } + + $dnEntity = $this->find() + ->where([ + 'LdapProvisionerDns.ldap_provisioner_id' => (int)$ldapProvisionerId, + 'LdapProvisionerDns.group_id' => (int)$group->id, + ]) + ->first(); + + if ($dnEntity) { + $curDn = $dnEntity->dn ?? null; + $curDnId = $dnEntity->id ?? null; + } + + try { + $newDn = $this->assignGroupDn($provisioningTarget, $group, $groupBaseDn); + } catch (\Throwable $e) { + $newDnErr = $e->getMessage(); + } + + if ($assign) { + if (!empty($newDn) && $curDn !== $newDn) { + if ($dnEntity) { + $dnEntity = $this->patchEntity($dnEntity, ['dn' => $newDn]); + } else { + $dnEntity = $this->newEntity([ + 'ldap_provisioner_id' => (int)$ldapProvisionerId, + 'group_id' => (int)$group->id, + 'dn' => $newDn, + ]); + } + + if (!$this->save($dnEntity)) { + throw new \RuntimeException(__d('ldap_connector', 'Failed to save DN mapping')); + } + } + } + + return [ + 'olddn' => $curDn, + 'olddnid' => $curDnId, + 'newdn' => $newDn, + 'newdnerr' => $newDnErr, + ]; + } + + /** + * Map a set of Group IDs to their provisioned DNs for a given LDAP provisioner. + * + * @param int $ldapProvisionerId LdapProvisioners.id + * @param array $groupIds Group IDs to resolve + * @return array Array of DNs found (unordered, may have fewer entries than requested) + * @since COmanage Registry v5.3.0 + */ + public function dnsForGroupIds(int $ldapProvisionerId, array $groupIds): array + { + $groupIds = array_values(array_unique(array_map('intval', $groupIds))); + $groupIds = array_values(array_filter($groupIds, static fn($v) => $v > 0)); + + if (empty($groupIds)) { + return []; + } + + $rows = $this->find() + ->select(['dn']) + ->where([ + 'LdapProvisionerDns.ldap_provisioner_id' => $ldapProvisionerId, + 'LdapProvisionerDns.group_id IN' => $groupIds, + ]) + ->enableHydration(false) + ->all() + ->toArray(); + + $dns = []; + foreach ($rows as $r) { + if (!empty($r['dn'])) { + $dns[] = (string)$r['dn']; + } + } + + return $dns; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php new file mode 100644 index 000000000..21f468e58 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php @@ -0,0 +1,1009 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + $this->setPrimaryLink(['provisioning_target_id']); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['resync']); + + // Associations + // Define associations + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('Servers'); + $this->belongsTo('DnIdentifierTypes') + ->setClassName('Types') + ->setForeignKey('dn_identifier_type_id') + ->setProperty('dn_identifier_type'); + + $this->setDisplayField('server_id'); + + // We now link to the new Pluggable Model manager for schemas + $this->hasMany('LdapConnector.LdapSchemas') + ->setDependent(true) + ->setForeignKey('ldap_provisioner_id') + ->setCascadeCallbacks(true); + $this->hasMany('LdapConnector.LdapProvisionerDns') + ->setDependent(true) + ->setForeignKey('ldap_provisioner_id') + ->setCascadeCallbacks(true); + + $this->setAutoViewVars([ + 'servers' => [ + 'type' => 'plugin', + 'model' => 'CoreServer.LdapServers' + ], + 'dnIdentifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ] + ]); + + $this->setViewContains(['ProvisioningTargets', 'Servers']); + $this->setEditContains(['ProvisioningTargets', 'Servers']); + $this->setIndexContains(['ProvisioningTargets', 'Servers']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'resync' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + // Declare supported models for provisioning + $this->setProvisionableModels(['People', 'Groups']); + } + + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->provisioning_target) || empty($entity->provisioning_target->description)) { + return null; + } + + return (string)$entity->provisioning_target->description; + } + + + /** + * After saving a new LdapProvisioner, auto-create one LdapSchema row + * per available ldap_schema plugin so they are ready to be enabled. + * + * @param EventInterface $event Event + * @param EntityInterface $entity Entity (ie: Co) + * @param ArrayObject $options Save options + * @return bool True on success + * @since COmanage Registry v5.3.0 + */ + public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { + // Only run on create, not on update + if (!$entity->isNew()) { + return true; + } + + $LdapSchemas = TableRegistry::getTableLocator()->get('LdapConnector.LdapSchemas'); + $Plugins = TableRegistry::getTableLocator()->get('Plugins'); + + // These schemas are always active — they are RFC-required objectclasses + $alwaysActive = ['PersonSchemas', 'OrganizationalPersonSchemas', 'InetOrgPersonSchemas']; + + // Get all active ldap_schema plugin models + $availableSchemas = $Plugins->getActivePluginModels('ldap_schema'); + + foreach ($availableSchemas as $pluginName) { + $modelName = StringUtilities::pluginModel($pluginName); + $humanReadable = Inflector::humanize(Inflector::underscore($modelName)); + + $status = in_array($modelName, $alwaysActive, true) + ? SuspendableStatusEnum::Active + : SuspendableStatusEnum::Suspended; + + $schemaEntity = $LdapSchemas->newEntity([ + 'ldap_provisioner_id' => $entity->id, + 'plugin' => $pluginName, + 'status' => $status, + 'description' => $humanReadable . ' for Provisioner #' . $entity->id, + ]); + + $LdapSchemas->save($schemaEntity, ['validate' => false, 'checkRules' => false]); + } + + return true; + } + + + /** + * Default validation rules for the table. + * + * Defines required fields and their validation types for the table records. + * + * @param Validator $validator The validator object. + * @return Validator + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('provisioning_target_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('provisioning_target_id'); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $this->registerStringValidation($validator, $schema, 'dn_attribute_name', true); + $validator->add('dn_identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('dn_identifier_type_id'); + +// $validator->add('cluster_id', [ +// 'content' => ['rule' => 'isInteger'] +// ]); +// $validator->allowEmptyString('cluster_id'); +// +// $this->registerStringValidation($validator, $schema, 'unconf_attr_mode', true); +// +// $this->registerStringValidation($validator, $schema, 'scope_suffix', false); +// +// $validator->boolean('attr_opts')->allowEmptyString('attr_opts'); + + return $validator; + } + + /** + * Handles provisioning logic for the target + * + * @param ProvisioningTarget $provisioningTarget The provisioning target being processed. + * @param string $className Name of the model being provisioned (eg: People, Groups). + * @param object $data Object data related to the provisioning request. + * @param string $eligibility Eligibility value for provisioning. + * @return array Array containing provisioning result keys: status, comment, and identifier. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function provision( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + string $eligibility + ): array { + // Result factory: keep this local and consistent. + $result = static function ( + string $status, + string $comment, + ?string $identifier + ): array { + return [ + 'status' => $status, + 'comment' => $comment, + 'identifier' => $identifier, + ]; + }; + + // Guard-only checks (no I/O, no LDAP) to short-circuit obvious cases. + $guard = $this->guardProvisioningPrerequisites( + provisioningTarget: $provisioningTarget, + eligibility: $eligibility, + resultFactory: $result + ); + + if (!empty($guard['result'])) { + return $guard['result']; + } + + $eligibilityValue = (string)$guard['eligibilityValue']; + $serverId = (int)$guard['serverId']; + + // Guard: only these model names are supported. + if ($className !== 'People' && $className !== 'Groups') { + return $result( + ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'Unsupported provisioning class: {0}', [$className]), + null + ); + } + + // Load LDAP server configuration (I/O). + $CoreLdapServers = TableRegistry::getTableLocator()->get('CoreServer.LdapServers'); + + $ldapServer = $CoreLdapServers->find() + ->where(['server_id' => $serverId]) + ->first(); + + if (!$ldapServer) { + return $result( + ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'LDAP server configuration not found for server_id {0}', [$serverId]), + null + ); + } + + $peopleBaseDn = (string)($ldapServer->basedn ?? ''); + $groupBaseDn = (string)($ldapServer->group_basedn ?? ''); + + $LdapProvisionerDns = TableRegistry::getTableLocator()->get('LdapConnector.LdapProvisionerDns'); + + // Dispatch (keep the branching shallow and explicit). + if ($className === 'People') { + return $this->provisionPeople( + provisioningTarget: $provisioningTarget, + data: $data, + eligibilityValue: $eligibilityValue, + serverId: $serverId, + CoreLdapServers: $CoreLdapServers, + ldapServer: $ldapServer, + peopleBaseDn: $peopleBaseDn, + LdapProvisionerDns: $LdapProvisionerDns + ); + } + + return $this->provisionGroups( + provisioningTarget: $provisioningTarget, + data: $data, + eligibilityValue: $eligibilityValue, + serverId: $serverId, + CoreLdapServers: $CoreLdapServers, + ldapServer: $ldapServer, + groupBaseDn: $groupBaseDn, + LdapProvisionerDns: $LdapProvisionerDns + ); + } + + + /** + * Provision a Person into LDAP (create/modify/rename/delete). + * + * Assumes caller already performed high-level guards (eg ineligible), + * and already loaded LDAP server config (including $peopleBaseDn) and table instances. + * + * @param ProvisioningTarget $provisioningTarget + * @param object $data Person entity + * @param string $eligibilityValue Normalized eligibility string + * @param int $serverId LDAP server_id + * @param Table $CoreLdapServers CoreServer.LdapServers table + * @param object $ldapServer LDAP server entity (not currently used, but kept for symmetry/future needs) + * @param string $peopleBaseDn Base DN for People + * @param Table $LdapProvisionerDns LdapConnector.LdapProvisionerDns table + * @return array{status:string,comment:string,identifier:?string} + * @throws \Throwable + */ + protected function provisionPeople( + ProvisioningTarget $provisioningTarget, + object $data, + string $eligibilityValue, + int $serverId, + Table $CoreLdapServers, + object $ldapServer, + string $peopleBaseDn, + Table $LdapProvisionerDns + ): array { + $result = static function ( + string $status, + string $comment, + ?string $identifier + ): array { + return [ + 'status' => $status, + 'comment' => $comment, + 'identifier' => $identifier, + ]; + }; + + $personId = isset($data->id) ? (int)$data->id : null; + + // Guard: we need some identifier to look up the DN mapping. + if (empty($personId)) { + return $result( + ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'Missing person identifier for provisioning'), + null + ); + } + + // Fetch old DN (may be null if never provisioned). + $oldDn = $this->fetchProvisionedDn( + provisioningTarget: $provisioningTarget, + groupId: null, + personId: $personId + ); + + // Guard: Deleted -> best-effort delete in LDAP, do not attempt DN assignment. + if ($eligibilityValue === ProvisioningEligibilityEnum::Deleted) { + if (!empty($oldDn)) { + $cxn = $CoreLdapServers->getLdapConnection($serverId); + + $CoreLdapServers->applyProvisioningPlan( + $cxn, + oldDn: (string)$oldDn, + newDn: null, + baseDn: $peopleBaseDn, + attributes: [], + options: [ + 'delete' => true, + 'ignoreNoSuchObjectOnDelete' => true, + ] + ); + } + + return $result( + ProvisioningStatusEnum::NotProvisioned, + __d('ldap_connector', 'Record removed from LDAP'), + null + ); + } + + // Guard: base DN required for all non-delete operations. + if ($peopleBaseDn === '') { + return $result( + ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'Missing LDAP server basedn for server_id {0}', [$serverId]), + null + ); + } + + /** @var \App\Model\Entity\Person $person */ + $person = $data; + + // Obtain/assign new DN (also updates the DN cache table). + $dnInfo = $LdapProvisionerDns->obtainPersonDn( + provisioningTarget: $provisioningTarget, + person: $person, + baseDn: $peopleBaseDn, + assign: true + ); + + $newDn = !empty($dnInfo['newdn']) ? (string)$dnInfo['newdn'] : null; + + // Guard: no DN means we cannot provision. + if (empty($newDn)) { + return $result( + ProvisioningStatusEnum::NotProvisioned, + !empty($dnInfo['newdnerr']) ? (string)$dnInfo['newdnerr'] : '', + null + ); + } + + // Operation hint for schema assembly (do not probe LDAP for existence). + if (empty($oldDn)) { + $opHint = 'add'; + } else { + $opHint = (strcasecmp((string)$oldDn, $newDn) !== 0) ? 'rename' : 'modify'; + } + + // Assemble attributes once, based on the operation hint. + $ldapProvisionerId = (int)($provisioningTarget->ldap_provisioner->id ?? 0); + + $attributes = $this->assembleAttributesFromEnabledSchemas( + provisioningTarget: $provisioningTarget, + ldapProvisionerId: $ldapProvisionerId, + className: 'People', + data: $data, + op: $opHint + ); + + // Guard: if no schemas contribute anything, do not touch LDAP. + if (empty($attributes)) { + return $result( + ProvisioningStatusEnum::NotProvisioned, + __d('ldap_connector', 'No enabled LDAP schema attributes for provisioning'), + null + ); + } + + // Execute provisioning via centralized plan (handles add/modify/rename fallbacks). + $cxn = $CoreLdapServers->getLdapConnection($serverId); + + $plan = $CoreLdapServers->applyProvisioningPlan( + $cxn, + oldDn: (!empty($oldDn) ? (string)$oldDn : null), + newDn: $newDn, + baseDn: $peopleBaseDn, + attributes: $attributes, + options: [ + // We already handled Deleted above; here we just allow rename when DN changed. + 'allowRename' => true, + 'applyAttributesAfterRename' => true, + ] + ); + + $action = (string)($plan['action'] ?? 'unknown'); + $dn = (string)($plan['dn'] ?? $newDn); + + return $result( + ProvisioningStatusEnum::Provisioned, + '[' . $action . '] ' . $dn, + $dn + ); + } + + + /** + * Validate preconditions for provisioning and compute basic IDs. + * + * This method is intentionally "guard-only": it does not query tables or fetch + * LDAP server configuration. It returns either an early provisioning result, or + * the normalized inputs required for config retrieval inside provision(). + * + * @param \App\Model\Entity\ProvisioningTarget $provisioningTarget + * @param mixed $eligibility string|ProvisioningEligibilityEnum (or other scalar-ish) + * @param callable $resultFactory function(string $status, string $comment, ?string $identifier): array + * @return array{result:array|null,eligibilityValue:string,ldapProvisionerId:int,serverId:int} + */ + protected function guardProvisioningPrerequisites( + \App\Model\Entity\ProvisioningTarget $provisioningTarget, + mixed $eligibility, + callable $resultFactory + ): array + { + $eligibilityValue = (string)$eligibility; + + // Short-circuit: ineligible records are not provisioned + if ($eligibilityValue === \App\Lib\Enum\ProvisioningEligibilityEnum::Ineligible) { + return [ + 'result' => $resultFactory( + \App\Lib\Enum\ProvisioningStatusEnum::NotProvisioned, + __d('ldap_connector', 'Record is not eligible for provisioning'), + null + ), + 'eligibilityValue' => $eligibilityValue, + 'ldapProvisionerId' => 0, + 'serverId' => 0, + ]; + } + + $ldapProvisionerId = (int)($provisioningTarget->ldap_provisioner->id ?? 0); + if ($ldapProvisionerId < 1) { + return [ + 'result' => $resultFactory( + \App\Lib\Enum\ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'Missing ldap_provisioner configuration on provisioning target'), + null + ), + 'eligibilityValue' => $eligibilityValue, + 'ldapProvisionerId' => 0, + 'serverId' => 0, + ]; + } + + $serverId = (int)($provisioningTarget->ldap_provisioner->server_id ?? 0); + if ($serverId < 1) { + return [ + 'result' => $resultFactory( + \App\Lib\Enum\ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'Missing server_id for LDAP provisioner'), + null + ), + 'eligibilityValue' => $eligibilityValue, + 'ldapProvisionerId' => $ldapProvisionerId, + 'serverId' => 0, + ]; + } + + return [ + 'result' => null, + 'eligibilityValue' => $eligibilityValue, + 'ldapProvisionerId' => $ldapProvisionerId, + 'serverId' => $serverId, + ]; + } + + + /** + * Provision a Group into LDAP (create/modify/rename/delete). + * + * Assumes caller already performed high-level guards (eg ineligible), + * and already loaded LDAP server config (including $groupBaseDn) and table instances. + * + * @param ProvisioningTarget $provisioningTarget + * @param object $data Group entity + * @param string $eligibilityValue Normalized eligibility string + * @param int $serverId LDAP server_id + * @param Table $CoreLdapServers CoreServer.LdapServers table + * @param object $ldapServer LDAP server entity (not currently used, but kept for symmetry/future needs) + * @param string $groupBaseDn Base DN for Groups + * @param Table $LdapProvisionerDns LdapConnector.LdapProvisionerDns table + * @return array{status:string,comment:string,identifier:?string} + * @throws \Throwable + */ + protected function provisionGroups( + ProvisioningTarget $provisioningTarget, + object $data, + string $eligibilityValue, + int $serverId, + Table $CoreLdapServers, + object $ldapServer, + string $groupBaseDn, + Table $LdapProvisionerDns + ): array { + $result = static function ( + string $status, + string $comment, + ?string $identifier + ): array { + return [ + 'status' => $status, + 'comment' => $comment, + 'identifier' => $identifier, + ]; + }; + + $groupId = isset($data->id) ? (int)$data->id : null; + + // Guard: we need some identifier to look up the DN mapping. + if (empty($groupId)) { + return $result( + ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'Missing group identifier for provisioning'), + null + ); + } + + // Fetch old DN (may be null if never provisioned). + $oldDn = $this->fetchProvisionedDn( + provisioningTarget: $provisioningTarget, + groupId: $groupId, + personId: null + ); + + // Guard: Deleted -> best-effort delete in LDAP, do not attempt DN assignment. + if ($eligibilityValue === ProvisioningEligibilityEnum::Deleted || $eligibilityValue === 'Deleted') { + if (!empty($oldDn)) { + $cxn = $CoreLdapServers->getLdapConnection($serverId); + + $CoreLdapServers->applyProvisioningPlan( + $cxn, + oldDn: (string)$oldDn, + newDn: null, + baseDn: $groupBaseDn, + attributes: [], + options: [ + 'delete' => true, + 'ignoreNoSuchObjectOnDelete' => true, + ] + ); + } + + return $result( + ProvisioningStatusEnum::NotProvisioned, + __d('ldap_connector', 'Record removed from LDAP'), + null + ); + } + + // Guard: base DN required for all non-delete operations. + if ($groupBaseDn === '') { + return $result( + ProvisioningStatusEnum::Unknown, + __d('ldap_connector', 'Missing LDAP server group_basedn for server_id {0}', [$serverId]), + null + ); + } + + /** @var \App\Model\Entity\Group $group */ + $group = $data; + + // Obtain/assign new DN (also updates the DN cache table). + $dnInfo = $LdapProvisionerDns->obtainGroupDn( + provisioningTarget: $provisioningTarget, + group: $group, + groupBaseDn: $groupBaseDn, + assign: true + ); + + $newDn = !empty($dnInfo['newdn']) ? (string)$dnInfo['newdn'] : null; + + // Guard: no DN means we cannot provision. + if (empty($newDn)) { + return $result( + ProvisioningStatusEnum::NotProvisioned, + !empty($dnInfo['newdnerr']) ? (string)$dnInfo['newdnerr'] : '', + null + ); + } + + // Operation hint for schema assembly (do not probe LDAP for existence). + if (empty($oldDn)) { + $opHint = 'add'; + } else { + $opHint = (strcasecmp((string)$oldDn, $newDn) !== 0) ? 'rename' : 'modify'; + } + + // Assemble attributes once, based on the operation hint. + // Keep legacy parity: GroupOfNamesSchemasTable may throw UnderflowException('member'). + try { + $ldapProvisionerId = (int)($provisioningTarget->ldap_provisioner->id ?? 0); + + $attributes = $this->assembleAttributesFromEnabledSchemas( + provisioningTarget: $provisioningTarget, + ldapProvisionerId: $ldapProvisionerId, + className: 'Groups', + data: $data, + op: $opHint + ); + } catch (\UnderflowException $e) { + // Legacy parity: groupOfNames requires at least one member -> deprovision the group in LDAP. + if ($e->getMessage() === 'member') { + if (!empty($oldDn)) { + $cxn = $CoreLdapServers->getLdapConnection($serverId); + + $CoreLdapServers->applyProvisioningPlan( + $cxn, + oldDn: (string)$oldDn, + newDn: null, + baseDn: $groupBaseDn, + attributes: [], + options: [ + 'delete' => true, + 'ignoreNoSuchObjectOnDelete' => true, + ] + ); + } + + return $result( + ProvisioningStatusEnum::NotProvisioned, + __d('ldap_connector', 'groupOfNames requires at least one member; record removed from LDAP'), + null + ); + } + + throw $e; + } + + // Guard: if no schemas contribute anything, do not touch LDAP. + if (empty($attributes)) { + return $result( + ProvisioningStatusEnum::NotProvisioned, + __d('ldap_connector', 'No enabled LDAP schema attributes for provisioning'), + null + ); + } + + // Execute provisioning via centralized plan (handles add/modify/rename fallbacks). + $cxn = $CoreLdapServers->getLdapConnection($serverId); + + $plan = $CoreLdapServers->applyProvisioningPlan( + $cxn, + oldDn: (!empty($oldDn) ? (string)$oldDn : null), + newDn: $newDn, + baseDn: $groupBaseDn, + attributes: $attributes, + options: [ + 'allowRename' => true, + 'applyAttributesAfterRename' => true, + ] + ); + + $action = (string)($plan['action'] ?? 'unknown'); + $dn = (string)($plan['dn'] ?? $newDn); + + return $result( + ProvisioningStatusEnum::Provisioned, + '[' . $action . '] ' . $dn, + $dn + ); + } + + /** + * Queries the backend system to determine live provisioning status. + * + * Verifies if the record exists in the backend and retrieves its current provisioning status. + * + * @param ProvisioningTarget $cfg The provisioning target entity to check. + * @param int|null $groupId Optional group ID for the query. + * @param int|null $personId Optional person ID for the query. + * @return array Array with provisioning status keys: status, comment, and optional timestamp. + * @since COmanage Registry v5.3.0 + */ + public function status( + ProvisioningTarget $cfg, + ?int $groupId, + ?int $personId + ): array { + $serverId = (int)$cfg->ldap_provisioner->server_id; + + $dn = $this->fetchProvisionedDn($cfg, $groupId, $personId); + + // No DN on file means not provisioned + if (empty($dn)) { + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'timestamp' => null, + 'comment' => "" + ]; + } + + $CoreLdapServers = TableRegistry::getTableLocator()->get('CoreServer.LdapServers'); + + $cxn = $CoreLdapServers->getLdapConnection($serverId); + if (!$cxn) { + return [ + 'status' => ProvisioningStatusEnum::Unknown, + 'timestamp' => null, + 'comment' => __d('ldap_connector', 'Failed to obtain LDAP connection for server {0}', [$serverId]) + ]; + } + + try { + $ldapRecord = $CoreLdapServers->queryLdap( + $cxn, + $dn, + "(objectclass=*)", + ['modifytimestamp'] + ); + } catch (\RuntimeException $e) { + // LDAP_NO_SUCH_OBJECT (32) means not provisioned + if ($e->getCode() === LdapCommonCodesEnum::LDAP_NO_SUCH_OBJECT) { + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'timestamp' => null, + 'comment' => $dn + ]; + } + + // Any other LDAP/runtime error -> unknown + return [ + 'status' => ProvisioningStatusEnum::Unknown, + 'timestamp' => null, + 'comment' => $e->getMessage() + ]; + } catch (\Throwable $e) { + // Unexpected error -> unknown + return [ + 'status' => ProvisioningStatusEnum::Unknown, + 'timestamp' => null, + 'comment' => $e->getMessage() + ]; + } + + // Entry not found (empty result set) -> not provisioned + if (empty($ldapRecord['count']) || (int)$ldapRecord['count'] < 1) { + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'timestamp' => null, + 'comment' => $dn + ]; + } + + // Provisioned: entry exists in LDAP + $ret = [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'timestamp' => null, + 'comment' => $dn + ]; + + if (!empty($ldapRecord[0]['modifytimestamp'][0])) { + $ret['timestamp'] = strtotime($ldapRecord[0]['modifytimestamp'][0]); + } + + return $ret; + } + + /** + * Fetch the currently provisioned DN for a person or group for this LDAP provisioner. + * + * @param ProvisioningTarget $provisioningTarget Provisioning target (contains ldap_provisioner). + * @param int|null $groupId Group ID (if checking group provisioning status). + * @param int|null $personId Person ID (if checking person provisioning status). + * @return string|null DN if on file, else null. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function fetchProvisionedDn( + ProvisioningTarget $provisioningTarget, + ?int $groupId, + ?int $personId + ): ?string { + if (empty($personId) && empty($groupId)) { + return null; + } + + if (empty($provisioningTarget->ldap_provisioner) || empty($provisioningTarget->ldap_provisioner->id)) { + return null; + } + + $ldapProvisionerId = (int)$provisioningTarget->ldap_provisioner->id; + + $LdapProvisionerDns = TableRegistry::getTableLocator()->get('LdapConnector.LdapProvisionerDns'); + + $where = [ + 'LdapProvisionerDns.ldap_provisioner_id' => $ldapProvisionerId, + ]; + + if (!empty($personId)) { + $where['LdapProvisionerDns.person_id'] = $personId; + } else { + $where['LdapProvisionerDns.group_id'] = (int)$groupId; + } + + $dnRecord = $LdapProvisionerDns->find() + ->select(['dn']) + ->where($where) + ->enableHydration(false) + ->first(); + + return !empty($dnRecord['dn']) ? (string)$dnRecord['dn'] : null; + } + + + /** + * Assemble LDAP attributes by iterating enabled LdapSchemas for this provisioner. + * + * Only schema instances that are Active are considered. Each schema plugin table + * is expected to implement: + * assemblePluginAttributes(ProvisioningTarget $provisioningTarget, string $className, object $data): array + * + * Returned attributes from all enabled schemas are merged. + * + * @param ProvisioningTarget $provisioningTarget + * @param int $ldapProvisionerId + * @param string $className 'People'|'Groups' + * @param object $data Person|Group + * @return array + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function assembleAttributesFromEnabledSchemas( + ProvisioningTarget $provisioningTarget, + int $ldapProvisionerId, + string $className, + object $data, + ?string $op = null + ): array { + if ($ldapProvisionerId < 1) { + return []; + } + + $LdapSchemas = TableRegistry::getTableLocator()->get('LdapConnector.LdapSchemas'); + + $schemas = $LdapSchemas->find() + ->select(['id', 'plugin', 'status']) + ->where([ + 'LdapSchemas.ldap_provisioner_id' => $ldapProvisionerId, + 'LdapSchemas.status' => SuspendableStatusEnum::Active, + ]) + ->orderBy(['LdapSchemas.id' => 'ASC']) + ->all(); + + $attributes = []; + + foreach ($schemas as $schema) { + $pluginModel = (string)($schema->plugin ?? ''); + if ($pluginModel === '') { + continue; + } + + // Each schema plugin is a Table class (eg "LdapConnector.EduMemberSchemas") + $SchemaTable = TableRegistry::getTableLocator()->get($pluginModel); + + if (!method_exists($SchemaTable, 'assemblePluginAttributes')) { + // Skip misconfigured/legacy schemas gracefully + continue; + } + + $schemaAttrs = (array)$SchemaTable->assemblePluginAttributes( + provisioningTarget: $provisioningTarget, + className: $className, + data: $data, + op: $op + ); + + if (empty($schemaAttrs)) { + continue; + } + + // Merge attributes; if the same attribute appears in multiple schemas, + // combine values (dedupe) when both are arrays, else last-one-wins. + foreach ($schemaAttrs as $attr => $value) { + if ($attr === '' || $value === null) { + continue; + } + + if (!array_key_exists($attr, $attributes)) { + $attributes[$attr] = $value; + continue; + } + + $existing = $attributes[$attr]; + + if (is_array($existing) && is_array($value)) { + $attributes[$attr] = array_values(array_unique(array_merge($existing, $value))); + } else { + $attributes[$attr] = $value; + } + } + } + + return $attributes; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapPublicKeySchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapPublicKeySchemasTable.php new file mode 100644 index 000000000..9c58e053e --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/LdapPublicKeySchemasTable.php @@ -0,0 +1,232 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'ldapPublicKey'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for ldapPublicKey. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + return [ + 'ldapPublicKey' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'sshPublicKey' => [ + 'required' => true, + 'multiple' => true + ] + ] + ] + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * This method is called during provisioning while building the outbound LDAP + * attribute set for an entry. Schema-specific implementations should: + * - return only attributes that are enabled for export in the schema instance + * configuration (required OR export), + * - optionally emit empty arrays (`attr => []`) on modify/rename to request + * attribute removal (deletion semantics depend on the caller). + * + * Stub behavior: + * - Returns an empty array (no attributes contributed by this schema). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'ldap_public_key_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate LDAP Public Key Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate LDAP Public Key Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'ldap_public_key_schema_id'], + 'Duplicate LDAP Public Key Schema definition for this LDAP Schema' + ), + 'uniqueLdapPublicKeySchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string { + return 'ldapPublicKey'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapSchemaAttributesTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapSchemaAttributesTable.php new file mode 100644 index 000000000..6cb7c7917 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/LdapSchemaAttributesTable.php @@ -0,0 +1,174 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->belongsTo('Types'); + + // Attribute rows are always scoped to an LdapSchema instance + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + $this->setViewContains(['LdapSchemas', 'Types']); + $this->setEditContains(['LdapSchemas', 'Types']); + $this->setIndexContains(['LdapSchemas', 'Types']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'], + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + ], + ]); + } + + /** + * Default validation rules. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->scalar('objectclass') + ->maxLength('objectclass', 64) + ->notEmptyString('objectclass'); + + $validator->scalar('attribute') + ->maxLength('attribute', 128) + ->notEmptyString('attribute'); + + $validator->boolean('required') + ->notEmptyString('required'); + + $validator->boolean('multiple') + ->notEmptyString('multiple'); + + $validator->boolean('export') + ->notEmptyString('export'); + + $validator->integer('ordr') + ->allowEmptyString('ordr'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->allowEmptyString('type_id'); + + $validator->boolean('use_org_value') + ->allowEmptyString('use_org_value'); + + // Optional: surface uniqueness earlier (still keep buildRules + DB unique constraint) + $validator->add('attribute', 'uniquePerSchemaObjectclass', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'objectclass', 'attribute', 'revision', 'ldap_schema_attribute_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate attribute definition for this schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * In practice, our data model prevents multiple “current” rows because: + * - archive rows always have a non-null parent FK + * - you never create additional current rows for the same attribute tuple + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + // Attach the error to a specific field for better UI feedback. + // Uniqueness must match the DB unique index (includes revision to allow changelog archives). + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'objectclass', 'attribute', 'revision', 'ldap_schema_attribute_id'], + 'Duplicate attribute definition for this schema' + ), + 'uniqueSchemaObjectclassAttributeRevisionParent', + ['errorField' => 'attribute'] + ); + + return $rules; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapSchemasTable.php new file mode 100644 index 000000000..5ad5cc10f --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/LdapSchemasTable.php @@ -0,0 +1,282 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + $this->belongsTo('LdapConnector.LdapProvisioners'); + $this->hasMany('LdapConnector.LdapSchemaAttributes') + ->setDependent(true) + ->setForeignKey('ldap_schema_id') + ->setCascadeCallbacks(true); + + $this->setPrimaryLink(['LdapConnector.ldap_provisioner_id']); + $this->setRequiresCO(true); + $this->setDisplayField('description'); + + $this->setPluginRelations(); + + $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'ldap_schema' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ], + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ] + ]); + + $this->setPermissions([ + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + 'save' => ['platformAdmin', 'coAdmin'], + ] + ]); + } + + /** + * After saving a new LdapSchema, auto-create the corresponding schema plugin row + * AND initialize ldap_schema_attributes from the plugin's getAttributes() definition. + * + * @param \Cake\Event\EventInterface $event Event + * @param \Cake\Datasource\EntityInterface $entity Entity + * @param \ArrayObject $options Save options + * @return bool + * @since COmanage Registry v5.3.0 + */ + public function localAfterSave( + \Cake\Event\EventInterface $event, + \Cake\Datasource\EntityInterface $entity, + \ArrayObject $options + ): bool { + if (!$entity->isNew() || empty($entity->plugin)) { + return true; + } + + $schemaTable = $this->getSchemaPluginTable((string)$entity->plugin); + $this->createSchemaPluginRow($schemaTable, (int)$entity->id); + + $defs = $this->getSchemaPluginAttributeDefinitions($schemaTable); + if (empty($defs)) { + return true; + } + + $LdapSchemaAttributes = TableRegistry::getTableLocator()->get('LdapConnector.LdapSchemaAttributes'); + + $ordr = 1; + + foreach ($defs as $objectClass => $ocCfg) { + $groupingDefaults = $this->buildGroupingDefaultTypeIds((array)$ocCfg); + + $attrs = (array)($ocCfg['attributes'] ?? []); + foreach ($attrs as $attrName => $attrCfg) { + $required = !empty($attrCfg['required']); + $multiple = !empty($attrCfg['multiple']); + + $attrRow = $LdapSchemaAttributes->newEntity([ + 'ldap_schema_id' => (int)$entity->id, + 'objectclass' => (string)$objectClass, + 'attribute' => (string)$attrName, + 'required' => $required, + 'multiple' => $multiple, + // Only required attributes are exported by default + 'export' => $required, + 'ordr' => $ordr++, + 'type_id' => $this->resolveInitialAttributeTypeId((array)$attrCfg, $groupingDefaults), + 'use_org_value' => !empty($attrCfg['use_org_value']), + ]); + + $LdapSchemaAttributes->saveOrFail($attrRow, ['validate' => false, 'checkRules' => false]); + } + } + + return true; + } + + /** + * Get the schema plugin table instance from the configured plugin model string. + * + * @param string $pluginModel Eg "LdapConnector.PersonSchemas" + * @return \Cake\ORM\Table + * @since COmanage Registry v5.3.0 + */ + protected function getSchemaPluginTable(string $pluginModel): \Cake\ORM\Table + { + return TableRegistry::getTableLocator()->get($pluginModel); + } + + /** + * Create the schema plugin row (eg PersonSchemas row) pointing at this ldap_schema_id. + * + * @param \Cake\ORM\Table $schemaTable Schema plugin table + * @param int $ldapSchemaId LdapSchemas.id + * @return void + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + protected function createSchemaPluginRow(\Cake\ORM\Table $schemaTable, int $ldapSchemaId): void + { + $schemaRow = $schemaTable->newEntity([ + 'ldap_schema_id' => $ldapSchemaId, + ]); + + $schemaTable->saveOrFail($schemaRow, ['validate' => false, 'checkRules' => false]); + } + + /** + * Fetch the schema plugin's attribute definitions. + * + * Convention: schema plugin table implements getAttributes(): array + * + * @param \Cake\ORM\Table $schemaTable Schema plugin table + * @return array Objectclass keyed definitions + * @since COmanage Registry v5.3.0 + */ + protected function getSchemaPluginAttributeDefinitions(\Cake\ORM\Table $schemaTable): array + { + if (!method_exists($schemaTable, 'getAttributes')) { + return []; + } + + /** @var array $defs */ + $defs = (array)$schemaTable->getAttributes(); + + return $defs; + } + + /** + * Build groupingKey => default type_id for a schema objectclass definition. + * + * @param array $ocCfg Objectclass definition + * @return array Eg ['address' => 123] + * @since COmanage Registry v5.3.0 + */ + protected function buildGroupingDefaultTypeIds(array $ocCfg): array + { + $ret = []; + + $groupings = (array)($ocCfg['groupings'] ?? []); + foreach ($groupings as $gkey => $gcfg) { + if (!empty($gcfg['default_type_id'])) { + $ret[(string)$gkey] = (int)$gcfg['default_type_id']; + } + } + + return $ret; + } + + /** + * Resolve initial value for LdapSchemaAttributes.type_id. + * + * Priority: + * (1) attribute-level default_type_id + * (2) grouping-level default_type_id (if attribute declares grouping) + * (3) null ("All Types") + * + * @param array $attrCfg Attribute definition + * @param array $groupingDefaults Grouping defaults (type_id) + * @return int|null type_id or null + * @since COmanage Registry v5.3.0 + */ + protected function resolveInitialAttributeTypeId(array $attrCfg, array $groupingDefaults): ?int + { + if (!empty($attrCfg['default_type_id'])) { + return (int)$attrCfg['default_type_id']; + } + + if (!empty($attrCfg['grouping'])) { + $gkey = (string)$attrCfg['grouping']; + + if (!empty($groupingDefaults[$gkey])) { + return (int)$groupingDefaults[$gkey]; + } + } + + return null; + } + + /** + * Default validation rules + * + * Validates the default schema for the LDAP Schema table. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('ldap_provisioner_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_provisioner_id'); + + $validator->integer('ldap_provisioner_id')->requirePresence('ldap_provisioner_id', 'create'); + $this->registerStringValidation($validator, $schema, 'plugin', true); + $this->registerStringValidation($validator, $schema, 'status', true); + $this->registerStringValidation($validator, $schema, 'description', false); + + return $validator; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/OrganizationalPersonSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/OrganizationalPersonSchemasTable.php new file mode 100644 index 000000000..2b924330d --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/OrganizationalPersonSchemasTable.php @@ -0,0 +1,295 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + + // Allow the schema edit view to post LdapSchemaAttributes[*] and have a clear association path. + // (Even if you persist exports via controller trait, this keeps the model consistent.) + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'organizationalPerson'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setAutoViewVars([ + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ] + ]); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'], + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + ], + ]); + } + + /** + * Generates a display field for the given entity. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string + { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for OrganizationalPerson. + * + * @return array + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array + { + // Resolve database-backed default type IDs, if we have a CO context. + // Many Registry tables rely on $this->curCoId (populated by CoIdEventListener). + $coId = !empty($this->curCoId) ? (int)$this->curCoId : null; + + $officeTelTypeId = null; + $faxTelTypeId = null; + $officeAddrTypeId = null; + + if (!empty($coId)) { + $Types = TableRegistry::getTableLocator()->get('Types'); + + $officeTelTypeId = $Types->getTypeId($coId, 'TelephoneNumbers.type', 'office'); + $faxTelTypeId = $Types->getTypeId($coId, 'TelephoneNumbers.type', 'fax'); + $officeAddrTypeId = $Types->getTypeId($coId, 'Addresses.type', 'office'); + } + + return [ + 'organizationalPerson' => [ + 'objectclass' => [ + 'required' => true, + ], + 'attributes' => [ + 'title' => [ + 'required' => false, + 'multiple' => true, + ], + 'ou' => [ + 'required' => false, + 'multiple' => true, + ], + 'telephoneNumber' => [ + 'required' => false, + 'multiple' => true, + 'default_type_id' => $officeTelTypeId, + ], + 'facsimileTelephoneNumber' => [ + 'required' => false, + 'multiple' => true, + 'default_type_id' => $faxTelTypeId, + ], + 'street' => [ + 'required' => false, + 'grouping' => 'address', + ], + 'l' => [ + 'required' => false, + 'grouping' => 'address', + ], + 'st' => [ + 'required' => false, + 'grouping' => 'address', + ], + 'postalCode' => [ + 'required' => false, + 'grouping' => 'address', + ], + ], + 'groupings' => [ + 'address' => [ + 'label' => 'Address', + 'multiple' => true, + 'default_type_id' => $officeAddrTypeId, + ], + ], + ], + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * This method is called during provisioning while building the outbound LDAP + * attribute set for an entry. Schema-specific implementations should: + * - return only attributes that are enabled for export in the schema instance + * configuration (required OR export), + * - optionally emit empty arrays (`attr => []`) on modify/rename to request + * attribute removal (deletion semantics depend on the caller). + * + * Stub behavior: + * - Returns an empty array (no attributes contributed by this schema). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way + * (must include revision + parent FK in the uniqueness definition, as with PersonSchemas). + * + * @param Validator $validator Validator instance. + * @return Validator + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'organizational_person_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate Organizational Person Schema definition for this LDAP Schema', + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * @param RulesChecker $rules Rules checker. + * @return RulesChecker + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'organizational_person_schema_id'], + 'Duplicate Organizational Person Schema definition for this LDAP Schema' + ), + 'uniqueOrgPersonSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string + { + return 'organizationalPerson'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/PersonSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/PersonSchemasTable.php new file mode 100644 index 000000000..20afd742d --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/PersonSchemasTable.php @@ -0,0 +1,362 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName'=> 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'person'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for Person. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + return [ + 'person' => [ + 'objectclass' => [ + 'required' => true + ], + 'attributes' => [ + 'cn' => [ + 'required' => true, + 'multiple' => false + ], + 'sn' => [ + 'required' => true, + 'multiple' => false + ], + 'userPassword' => [ + 'required' => false, + 'multiple' => true + ], + 'pwdAccountLockedTime' => [ + 'required' => false, + 'multiple' => true + ] + ] + ] + ]; + } + + /** + * Hook to assemble LDAP attributes for the "person" objectclass (People only). + * + * Mapping: + * - cn: derived from the Person's primary name (prefer full_name; fallback "." if blank/missing) + * - sn: derived from the Person's primary name family (fallback "." if blank/missing) + * - userPassword: exports only salted SHA1 hashes (password_type = "SH") as "{SSHA}{$hash}" + * - pwdAccountLockedTime: set to "000001010000Z" when Person status is Expired or Suspended; + * on modify/rename, cleared with an empty array when not applicable + * + * Example (modify): + * + * [ + * 'objectClass' => ['person'], + * 'cn' => 'Robert Andrews', + * 'sn' => 'Andrews', + * 'userPassword' => ['{SSHA}...'], + * 'pwdAccountLockedTime' => [], + * ] + * + * + * Notes: + * - When this schema contributes any attributes, it also ensures 'objectClass' includes + * this schema's objectclass (eg 'person'). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity. + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes for this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + $ret = []; + + if ($className !== 'People' || !($data instanceof Person)) { + return $ret; + } + + $isModifyLike = ($op === 'modify' || $op === 'rename'); + + $cfg = $this->resolveSchemaAttributeConfigForProvisioningTarget( + $provisioningTarget, + 'LdapConnector.PersonSchemas', + $this->ldapObjectClass() + ); + + $cfgByAttr = $cfg['cfgByAttr']; + + $enabled = fn(?array $a): bool => $this->ldapSchemaAttributeEnabled($a); + + $cnEnabled = $enabled($cfgByAttr['cn'] ?? null); + $snEnabled = $enabled($cfgByAttr['sn'] ?? null); + $pwEnabled = $enabled($cfgByAttr['userPassword'] ?? null); + $lockEnabled = $enabled($cfgByAttr['pwdAccountLockedTime'] ?? null); + + if (!$cnEnabled && !$snEnabled && !$pwEnabled && !$lockEnabled) { + return $ret; + } + + $primaryName = $data->primary_name ?? null; + + if ($cnEnabled) { + $cn = ''; + + if (is_object($primaryName)) { + if (isset($primaryName->full_name) && (string)$primaryName->full_name !== '') { + $cn = (string)$primaryName->full_name; + } else { + $given = !empty($primaryName->given) ? (string)$primaryName->given : ''; + $family = !empty($primaryName->family) ? (string)$primaryName->family : ''; + $cn = trim($given . ' ' . $family); + } + } + + $ret['cn'] = ($cn !== '' ? $cn : '.'); + } + + if ($snEnabled) { + $sn = ''; + + if (is_object($primaryName) && isset($primaryName->family)) { + $sn = (string)$primaryName->family; + } + + $ret['sn'] = ($sn !== '' ? $sn : '.'); + } + + if ($pwEnabled) { + if ($isModifyLike) { + $ret['userPassword'] = []; + } + + $values = []; + + foreach (($data->passwords ?? []) as $p) { + if (!is_object($p)) { + continue; + } + + $ptype = (string)($p->password_type ?? ''); + $pval = (string)($p->password ?? ''); + + if ($ptype === 'SH' && $pval !== '') { + $values[] = '{SSHA}' . $pval; + } + } + + $values = array_values(array_unique(array_filter($values, static fn($v) => $v !== ''))); + + if (!empty($values)) { + $ret['userPassword'] = $values; + } elseif (!$isModifyLike) { + unset($ret['userPassword']); + } + } + + if ($lockEnabled) { + $status = (string)($data->status ?? ''); + + $isLocked = ( + $status === StatusEnum::Expired + || $status === StatusEnum::Suspended + ); + + if ($isLocked) { + $ret['pwdAccountLockedTime'] = '000001010000Z'; + } elseif ($isModifyLike) { + $ret['pwdAccountLockedTime'] = []; + } + } + + $this->ensureSchemaObjectClass($ret); + + return $ret; + } + + /** + * Default validation rules. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'person_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate Person Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate Person Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'person_schema_id'], + 'Duplicate Person Schema definition for this LDAP Schema' + ), + 'uniquePersonSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string + { + return 'person'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/PosixAccountSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/PosixAccountSchemasTable.php new file mode 100644 index 000000000..5e0f4858d --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/PosixAccountSchemasTable.php @@ -0,0 +1,248 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'posixAccount'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for POSIX Account. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + return [ + 'posixAccount' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'uidNumber' => [ + 'required' => true, + 'multiple' => false + ], + 'gidNumber' => [ + 'required' => true, + 'multiple' => false + ], + 'homeDirectory' => [ + 'required' => true, + 'multiple' => false + ], + 'loginShell' => [ + 'required' => false, + 'multiple' => false + ], + 'gecos' => [ + 'required' => false, + 'multiple' => false + ] + ] + ] + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * This method is called during provisioning while building the outbound LDAP + * attribute set for an entry. Schema-specific implementations should: + * - return only attributes that are enabled for export in the schema instance + * configuration (required OR export), + * - optionally emit empty arrays (`attr => []`) on modify/rename to request + * attribute removal (deletion semantics depend on the caller). + * + * Stub behavior: + * - Returns an empty array (no attributes contributed by this schema). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'posix_account_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate POSIX Account Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate POSIX Account Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'posix_account_schema_id'], + 'Duplicate POSIX Account Schema definition for this LDAP Schema' + ), + 'uniquePosixAccountSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string { + return 'posixAccount'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php new file mode 100644 index 000000000..9dce24b46 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php @@ -0,0 +1,241 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'posixGroup'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for POSIX Group. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + return [ + 'posixGroup' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'cn' => [ + 'required' => true, + 'multiple' => false + ], + 'gidNumber' => [ + 'required' => true, + 'multiple' => false + ], + 'memberUid' => [ + 'required' => false, + 'multiple' => true + ] + ] + ] + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * This method is called during provisioning while building the outbound LDAP + * attribute set for an entry. Schema-specific implementations should: + * - return only attributes that are enabled for export in the schema instance + * configuration (required OR export), + * - optionally emit empty arrays (`attr => []`) on modify/rename to request + * attribute removal (deletion semantics depend on the caller). + * + * Stub behavior: + * - Returns an empty array (no attributes contributed by this schema). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way + * (must include revision + parent FK in the uniqueness definition, as with PersonSchemas). + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'posix_group_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate POSIX Group Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate POSIX Group Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'posix_group_schema_id'], + 'Duplicate POSIX Group Schema definition for this LDAP Schema' + ), + 'uniquePosixGroupSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string { + return 'posixGroup'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/VoPosixAccountSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/VoPosixAccountSchemasTable.php new file mode 100644 index 000000000..965f2e322 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/VoPosixAccountSchemasTable.php @@ -0,0 +1,250 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'voPosixAccount'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for voPosixAccount. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + return [ + 'voPosixAccount' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'voPosixAccountUidNumber' => [ + 'required' => true, + 'multiple' => true + ], + 'voPosixAccountGidNumber' => [ + 'required' => true, + 'multiple' => true + ], + 'voPosixAccountHomeDirectory' => [ + 'required' => true, + 'multiple' => true + ], + 'voPosixAccountLoginShell' => [ + 'required' => false, + 'multiple' => true + ], + 'voPosixAccountGecos' => [ + 'required' => false, + 'multiple' => true + ] + ] + ] + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * This method is called during provisioning while building the outbound LDAP + * attribute set for an entry. Schema-specific implementations should: + * - return only attributes that are enabled for export in the schema instance + * configuration (required OR export), + * - optionally emit empty arrays (`attr => []`) on modify/rename to request + * attribute removal (deletion semantics depend on the caller). + * + * Stub behavior: + * - Returns an empty array (no attributes contributed by this schema). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'vo_posix_account_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate VO POSIX Account Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate VO POSIX Account Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'vo_posix_account_schema_id'], + 'Duplicate VO POSIX Account Schema definition for this LDAP Schema' + ), + 'uniqueVoPosixAccountSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string { + return 'voPosixAccount'; + } +} diff --git a/app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php new file mode 100644 index 000000000..e27b20c9a --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php @@ -0,0 +1,240 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'voPosixGroup'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Generates a display field for the given entity. + * + * This method retrieves a description from the provisioning target + * if available and uses it as the display field. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since COmanage Registry v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for voPosixGroup. + * + * @return array Array of supported attributes + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array { + return [ + 'voPosixGroup' => [ + 'objectclass' => [ + 'required' => false + ], + 'attributes' => [ + 'cn' => [ + 'required' => true, + 'multiple' => true + ], + 'voPosixAccountGidNumber' => [ + 'required' => true, + 'multiple' => true + ], + 'memberUid' => [ + 'required' => false, + 'multiple' => true + ] + ] + ] + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * This method is called during provisioning while building the outbound LDAP + * attribute set for an entry. Schema-specific implementations should: + * - return only attributes that are enabled for export in the schema instance + * configuration (required OR export), + * - optionally emit empty arrays (`attr => []`) on modify/rename to request + * attribute removal (deletion semantics depend on the caller). + * + * Stub behavior: + * - Returns an empty array (no attributes contributed by this schema). + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance to be modified. + * @return Validator Modified validator instance with additional rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'vo_posix_group_schema_id']] + ], + 'provider' => 'table', + 'message' => 'Duplicate VO POSIX Group Schema definition for this LDAP Schema' + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * Ensures that there are no duplicate VO POSIX Group Schema definitions for a given LDAP Schema, + * revision, and parent schema. + * + * @param RulesChecker $rules Rules checker to be modified. + * @return RulesChecker Modified rules checker with additional integrity rules. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'vo_posix_group_schema_id'], + 'Duplicate VO POSIX Group Schema definition for this LDAP Schema' + ), + 'uniqueVoPosixGroupSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string Objectclass name managed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string { + return 'voPosixGroup'; + } +} diff --git a/app/plugins/LdapConnector/templates/EduMemberSchemas/fields.inc b/app/plugins/LdapConnector/templates/EduMemberSchemas/fields.inc new file mode 100644 index 000000000..f55e9eeb5 --- /dev/null +++ b/app/plugins/LdapConnector/templates/EduMemberSchemas/fields.inc @@ -0,0 +1,53 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'eduMember', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes', + + // Type selector for Identifier-backed attribute(s), eg hasMember + 'typeOptionsByAttribute' => [ + 'hasMember' => $identifierTypes ?? [] + ], + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.EduMemberSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.EduMemberSchemas' => ['edit'], + ] +]; diff --git a/app/plugins/LdapConnector/templates/EduPersonSchemas/fields.inc b/app/plugins/LdapConnector/templates/EduPersonSchemas/fields.inc new file mode 100644 index 000000000..d1c9d44b0 --- /dev/null +++ b/app/plugins/LdapConnector/templates/EduPersonSchemas/fields.inc @@ -0,0 +1,65 @@ + $nameTypes ?? [], + + 'eduPersonPrincipalName' => $identifierTypes ?? [], + 'eduPersonPrincipalNamePrior' => $identifierTypes ?? [], + 'eduPersonUniqueId' => $identifierTypes ?? [], +]; + +// Attributes that should render the "use_org_value" toggle. +// Semantics in v5: "Use value from External Identity Source". +$orgValueAttributes = [ + 'eduPersonOrcid' => true, + 'eduPersonPrincipalName' => true, +]; + +$fields = [ + 'SUBTITLE' => ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'eduPerson', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes', + 'typeOptionsByAttribute' => $typeOptionsByAttribute, + 'orgValueAttributes' => $orgValueAttributes, + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.EduPersonSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.EduPersonSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/GroupOfNamesSchemas/fields.inc b/app/plugins/LdapConnector/templates/GroupOfNamesSchemas/fields.inc new file mode 100644 index 000000000..aa2c7bf8a --- /dev/null +++ b/app/plugins/LdapConnector/templates/GroupOfNamesSchemas/fields.inc @@ -0,0 +1,48 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'groupOfNames', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes' + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.GroupOfNamesSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.GroupOfNamesSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/InetOrgPersonSchemas/fields.inc b/app/plugins/LdapConnector/templates/InetOrgPersonSchemas/fields.inc new file mode 100644 index 000000000..4e2e50be4 --- /dev/null +++ b/app/plugins/LdapConnector/templates/InetOrgPersonSchemas/fields.inc @@ -0,0 +1,86 @@ + $nameTypes ?? [], + 'displayName' => $nameTypes ?? [], + + 'employeeNumber' => $identifierTypes ?? [], + 'uid' => $identifierTypes ?? [], + + 'labeledURI' => $urlTypes ?? [], + 'mail' => $emailAddressTypes ?? [], + 'mobile' => $telephoneNumberTypes ?? [], +]; + +// Attributes that should render the "use_org_value" toggle. +// Semantics in v5: "Use value from External Identity Source". +$orgValueAttributes = [ + 'uid' => true, +]; + +// Groupings for related attributes (primarily for UI layout). +$groupings = [ + 'address' => [ + 'label' => __d('field', 'Address'), + 'members' => ['roomNumber'], + 'options' => $addressTypes ?? [], + 'dataKey' => 'address', + ], +]; + +$fields = [ + 'SUBTITLE' => ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'inetOrgPerson', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes', + 'typeOptionsByAttribute' => $typeOptionsByAttribute, + 'orgValueAttributes' => $orgValueAttributes, + 'groupings' => $groupings, + // If you've set this in the controller (recommended), keep it. + // Otherwise it will default to null and show "All Types". + 'groupingSelections' => [ + 'address' => $vv_address_grouping_type_id ?? null + ], + ], + ], + ], +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.InetOrgPersonSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.InetOrgPersonSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/LdapProvisioners/fields-links.inc b/app/plugins/LdapConnector/templates/LdapProvisioners/fields-links.inc new file mode 100644 index 000000000..74b2542aa --- /dev/null +++ b/app/plugins/LdapConnector/templates/LdapProvisioners/fields-links.inc @@ -0,0 +1,37 @@ + 'history', + 'order' => 'Default', + 'label' => __d('ldap_connector', 'operation.resync'), + 'link' => [ + 'action' => 'resync', + $vv_obj->id + ], + 'class' => '' +]; diff --git a/app/plugins/LdapConnector/templates/LdapProvisioners/fields-nav.inc b/app/plugins/LdapConnector/templates/LdapProvisioners/fields-nav.inc new file mode 100644 index 000000000..9a6701bdf --- /dev/null +++ b/app/plugins/LdapConnector/templates/LdapProvisioners/fields-nav.inc @@ -0,0 +1,31 @@ + 'plugin', + 'active' => 'plugin' + ]; \ No newline at end of file diff --git a/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc b/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc new file mode 100644 index 000000000..192e0166c --- /dev/null +++ b/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc @@ -0,0 +1,56 @@ + ['ProvisioningTargets', 'LdapConnector.LdapProvisioners', 'LdapConnector.LdapSchemas'], + 'action' => [ + 'ProvisioningTargets' => ['edit'], + 'LdapConnector.LdapProvisioners' => ['edit'], + 'LdapConnector.LdapSchemas' => ['index'], + ], + 'counter' => ['LdapConnector.LdapSchemas'], +]; + +// Top Links +$topLinks = [ + [ + 'icon' => 'history', + 'order' => 'Default', + 'label' => __d('ldap_connector', 'operation.resync'), + 'link' => [ + 'action' => 'resync', + $vv_obj->id + ], + 'class' => '' + ] +]; \ No newline at end of file diff --git a/app/plugins/LdapConnector/templates/LdapPublicKeySchemas/fields.inc b/app/plugins/LdapConnector/templates/LdapPublicKeySchemas/fields.inc new file mode 100644 index 000000000..8eba74527 --- /dev/null +++ b/app/plugins/LdapConnector/templates/LdapPublicKeySchemas/fields.inc @@ -0,0 +1,48 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'ldapPublicKey', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes' + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.LdapPublicKeySchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.LdapPublicKeySchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/LdapSchemaAttributes/.keep b/app/plugins/LdapConnector/templates/LdapSchemaAttributes/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/plugins/LdapConnector/templates/LdapSchemas/columns.inc b/app/plugins/LdapConnector/templates/LdapSchemas/columns.inc new file mode 100644 index 000000000..7d7b5cca5 --- /dev/null +++ b/app/plugins/LdapConnector/templates/LdapSchemas/columns.inc @@ -0,0 +1,71 @@ + [ + 'type' => 'link', + 'sortable' => true, + 'class' => 'cm-modal-link nospin', + 'dataAttrs' => [ + ['data-cm-modal-title', __d('ldap_connector', 'controller.LdapSchemas', [99])] + ] + ], + 'plugin' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum', + 'sortable' => true + ] +]; + +$subnav = [ + 'tabs' => ['ProvisioningTargets', 'LdapConnector.LdapProvisioners', 'LdapConnector.LdapSchemas'], + 'action' => [ + 'ProvisioningTargets' => ['edit'], + 'LdapConnector.LdapProvisioners' => ['edit'], + 'LdapConnector.LdapSchemas' => ['index'], + ], + 'counter' => ['LdapConnector.LdapSchemas'], +]; + +// $rowActions appear as row-level menu items in the index view gear icon +$rowActions = [ + [ + 'controller' => 'ldap_schemas', + 'plugin' => 'LdapConnector', + 'action' => 'configure', + 'label' => __d('operation', 'configure.plugin'), + 'icon' => 'electrical_services', + 'class' => 'cm-modal-link nospin', + 'dataAttrs' => [ + ['data-cm-modal-title', __d('ldap_connector', 'controller.LdapSchemas', [99])] + ] + ], +]; diff --git a/app/plugins/LdapConnector/templates/LdapSchemas/fields.inc b/app/plugins/LdapConnector/templates/LdapSchemas/fields.inc new file mode 100644 index 000000000..c380d5ac7 --- /dev/null +++ b/app/plugins/LdapConnector/templates/LdapSchemas/fields.inc @@ -0,0 +1,36 @@ + [ + 'labelIsTextOnly' => true + ] +]; + +$subnav = 'standard'; diff --git a/app/plugins/LdapConnector/templates/OrganizationalPersonSchemas/fields.inc b/app/plugins/LdapConnector/templates/OrganizationalPersonSchemas/fields.inc new file mode 100644 index 000000000..558a1a23c --- /dev/null +++ b/app/plugins/LdapConnector/templates/OrganizationalPersonSchemas/fields.inc @@ -0,0 +1,76 @@ +Html->css('LdapConnector.ldap-connector', ['block' => true]); + +$fields = [ + 'SUBTITLE' => ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'organizationalPerson', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes', + + // Use AutoViewVars from LdapSchemasTable: + // - telephoneNumberTypes => Types for TelephoneNumbers.type (keyed for select) + // - addressTypes => Types for Addresses.type (keyed for select) + 'typeOptionsByAttribute' => [ + 'telephoneNumber' => $telephoneNumberTypes ?? [], + 'facsimileTelephoneNumber' => $telephoneNumberTypes ?? [], + ], + + 'groupings' => [ + 'address' => [ + 'label' => __d('field', 'Address'), + 'options' => $addressTypes ?? [], + 'members' => ['street', 'l', 'st', 'postalCode'], + 'dataKey' => 'address', + ] + ], + + // If you've set this in the controller (recommended), keep it. + // Otherwise it will default to null and show "All Types". + 'groupingSelections' => [ + 'address' => $vv_address_grouping_type_id ?? null + ], + ] + ] + ] +]; + + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.OrganizationalPersonSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.OrganizationalPersonSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/PersonSchemas/fields.inc b/app/plugins/LdapConnector/templates/PersonSchemas/fields.inc new file mode 100644 index 000000000..0be9aa166 --- /dev/null +++ b/app/plugins/LdapConnector/templates/PersonSchemas/fields.inc @@ -0,0 +1,48 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'person', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes' + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.PersonSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.PersonSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/PosixAccountSchemas/fields.inc b/app/plugins/LdapConnector/templates/PosixAccountSchemas/fields.inc new file mode 100644 index 000000000..fe42bb774 --- /dev/null +++ b/app/plugins/LdapConnector/templates/PosixAccountSchemas/fields.inc @@ -0,0 +1,48 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'posixAccount', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes' + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.PosixAccountSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.PosixAccountSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/PosixGroupSchemas/fields.inc b/app/plugins/LdapConnector/templates/PosixGroupSchemas/fields.inc new file mode 100644 index 000000000..20dcd508c --- /dev/null +++ b/app/plugins/LdapConnector/templates/PosixGroupSchemas/fields.inc @@ -0,0 +1,48 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'posixGroup', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes' + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.PosixGroupSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.PosixGroupSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/VoPosixAccountSchemas/fields.inc b/app/plugins/LdapConnector/templates/VoPosixAccountSchemas/fields.inc new file mode 100644 index 000000000..dc6e842c0 --- /dev/null +++ b/app/plugins/LdapConnector/templates/VoPosixAccountSchemas/fields.inc @@ -0,0 +1,48 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'voPosixAccount', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes' + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.VoPosixAccountSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.VoPosixAccountSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/VoPosixGroupSchemas/fields.inc b/app/plugins/LdapConnector/templates/VoPosixGroupSchemas/fields.inc new file mode 100644 index 000000000..ed6916613 --- /dev/null +++ b/app/plugins/LdapConnector/templates/VoPosixGroupSchemas/fields.inc @@ -0,0 +1,48 @@ + ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'voPosixGroup', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes' + ] + ] + ] +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.VoPosixGroupSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.VoPosixGroupSchemas' => ['edit'], + ], +]; diff --git a/app/plugins/LdapConnector/templates/element/schemaAttributes.php b/app/plugins/LdapConnector/templates/element/schemaAttributes.php new file mode 100644 index 000000000..b51af5d36 --- /dev/null +++ b/app/plugins/LdapConnector/templates/element/schemaAttributes.php @@ -0,0 +1,273 @@ +,
  • rows, .field/.field-name/.field-info), + * but renders the dynamic controls via plugin-local elements. + * + * Expects: + * - $attributes array of LdapSchemaAttribute entities (or arrays) + * - $objectclass string objectclass name (eg: 'person') + * - $fieldPrefix string form prefix (default: 'LdapSchemaAttributes') + * - $typeOptionsByAttribute (optional) array attrName => optionsArray (type_id => display_name) + * - $orgValueAttributes (optional) array attrName => bool (true => render use_org_value checkbox) + * - $groupings (optional) array groupingKey => [ + * 'label' => string, + * 'options' => optionsArray (type_id => display_name), + * 'members' => string[], + * 'dataKey' => string (posted key under LdapSchemaGroupings) + * ] + * - $groupingSelections (optional) array groupingKey => selected type_id|null + */ + +declare(strict_types=1); + +$this->Html->css('/ldap-connector/css/ldap-connector.css', ['block' => true]); + +$attributes = $attributes ?? []; +$objectclass = $objectclass ?? ''; +$fieldPrefix = $fieldPrefix ?? 'LdapSchemaAttributes'; + +$typeOptionsByAttribute = $typeOptionsByAttribute ?? []; +if (!is_array($typeOptionsByAttribute)) { + $typeOptionsByAttribute = []; +} + +$orgValueAttributes = $orgValueAttributes ?? []; +if (!is_array($orgValueAttributes)) { + $orgValueAttributes = []; +} + +$groupings = $groupings ?? []; +if (!is_array($groupings)) { + $groupings = []; +} + +$groupingSelections = $groupingSelections ?? []; +if (!is_array($groupingSelections)) { + $groupingSelections = []; +} + +if (empty($objectclass)) { + return; +} + +// Build lookup attr => grouping key for quick membership tests +$attrToGroupingKey = []; +foreach ($groupings as $gkey => $gcfg) { + $members = (array)($gcfg['members'] ?? []); + foreach ($members as $m) { + if (is_string($m) && $m !== '') { + $attrToGroupingKey[$m] = (string)$gkey; + } + } +} + +// Track which grouping selector has been rendered +$renderedGroupingSelector = []; +foreach ($groupings as $gkey => $gcfg) { + $renderedGroupingSelector[(string)$gkey] = false; +} + +// Buffer grouped attribute "subfields" so they render inside ONE grouped
  • +$groupedRows = []; +foreach ($groupings as $gkey => $gcfg) { + $groupedRows[(string)$gkey] = ''; +} + +// Buffer grouping selector html separately so it renders once, inside the grouped row +$groupedSelectors = []; +foreach ($groupings as $gkey => $gcfg) { + $groupedSelectors[(string)$gkey] = ''; +} + +// Buffer ungrouped rows (these remain
  • rows) +$ungroupedRows = ''; +?> + +
    + +

    + + +
      + $a): ?> + id ?? null) : ($a['id'] ?? null); + $attr = is_object($a) ? ($a->attribute ?? '') : ($a['attribute'] ?? ''); + $required = is_object($a) ? (!empty($a->required)) : (!empty($a['required'])); + $export = is_object($a) ? (!empty($a->export)) : (!empty($a['export'])); + $typeId = is_object($a) ? ($a->type_id ?? null) : ($a['type_id'] ?? null); + $useOrgValue = is_object($a) ? (!empty($a->use_org_value)) : (!empty($a['use_org_value'] ?? false)); + + if (!is_string($attr) || $attr === '') { + continue; + } + + // Force required attributes to appear enabled + if ($required) { + $export = true; + } + + // Field paths that Standard/Cake will post as an array + $idField = "{$fieldPrefix}.{$i}.id"; + $exportField = "{$fieldPrefix}.{$i}.export"; + $typeIdField = "{$fieldPrefix}.{$i}.type_id"; + $useOrgValueField = "{$fieldPrefix}.{$i}.use_org_value"; + + // Generate a stable, unique id so
    + + +
    diff --git a/app/plugins/LdapConnector/templates/element/schemaAttributes/attributeControls.php b/app/plugins/LdapConnector/templates/element/schemaAttributes/attributeControls.php new file mode 100644 index 000000000..6d5d1cce7 --- /dev/null +++ b/app/plugins/LdapConnector/templates/element/schemaAttributes/attributeControls.php @@ -0,0 +1,74 @@ + + +
    + +
    + + +
    + + + Form->hidden($useOrgValueField, ['value' => 0]) ?> + +
    + +
    +
    diff --git a/app/plugins/LdapConnector/templates/element/schemaAttributes/attributeRow.php b/app/plugins/LdapConnector/templates/element/schemaAttributes/attributeRow.php new file mode 100644 index 000000000..d35cc87e9 --- /dev/null +++ b/app/plugins/LdapConnector/templates/element/schemaAttributes/attributeRow.php @@ -0,0 +1,200 @@ + don't render selector) + * - $checkboxId string + * - $description string + * - $renderMode string 'li'|'subfield' (default 'li') + * - $objectclass string (required for namespaced i18n key) + */ + +declare(strict_types=1); + +use Cake\Utility\Inflector; + +$idField = $idField ?? ''; +$exportField = $exportField ?? ''; +$typeIdField = $typeIdField ?? ''; +$useOrgValueField = $useOrgValueField ?? ''; + +$attr = $attr ?? ''; +$required = $required ?? false; +$export = $export ?? false; + +$typeId = $typeId ?? null; +$typeOptions = $typeOptions ?? null; + +$allowOrgValue = $allowOrgValue ?? false; +$useOrgValue = $useOrgValue ?? false; + +$checkboxId = $checkboxId ?? ''; +$renderMode = (string)($renderMode ?? 'li'); + +$objectclass = trim((string)($objectclass ?? '')); + +$liKey = preg_replace('/[^a-zA-Z0-9\-_]/', '-', $attr); +$descId = $checkboxId !== '' ? ($checkboxId . '-desc') : ('schema-attr-desc-' . $liKey); + +// Prefer translation with objectclass namespace ONLY: +// field.LdapConnector.{objectclass}.{attr} +// If missing, fall back to humanized attribute. +$title = ''; +$desc = ''; +if ($objectclass !== '') { + $msgid = 'field.LdapConnector.' . $objectclass . '.' . $attr; + $title = __d('ldap_connector', $msgid); + if ($title === $msgid) { + $title = ''; + } + + $descMsgid = $msgid . '.desc'; + $desc = __d('ldap_connector', $descMsgid); + if ($desc === $descMsgid) { + $desc = ''; + } +} + +if ($title === '') { + $title = Inflector::humanize(Inflector::underscore($attr)); + if (mb_strlen($title) === 2) { + $title = mb_strtoupper($title); + } +} + +// Normalize $typeOptions: null means "don't render", array means "render" +if ($typeOptions !== null) { + $typeOptions = is_array($typeOptions) ? $typeOptions : []; +} + +// Stable id for the type select (used only if type selector is rendered) +$safeOc = preg_replace('/[^a-zA-Z0-9\-_]/', '-', ($objectclass !== '' ? $objectclass : 'oc')); +$safeAttr = preg_replace('/[^a-zA-Z0-9\-_]/', '-', (string)$attr); +$typeSelectId = "ldap-schema-attr-{$safeOc}-{$safeAttr}-type_id"; + +// Render the "field-info" inner controls once, then wrap depending on renderMode +ob_start(); +?> + +Form->hidden($idField, ['value' => $id ?? null]) ?> + +Form->hidden($exportField, ['value' => 1]); +} else { + echo $this->Form->hidden($exportField, ['value' => 0]); +} +?> + +
    + + + + +
    + Form->select($typeIdField, $typeOptions, [ + 'id' => $typeSelectId, + 'empty' => __d('ldap_connector', 'types.all'), + 'value' => $typeId ?? null, + 'class' => 'form-select', + 'aria-label' => __d('ldap_connector', 'schema.type_for', [$attr]), + 'aria-describedby' => (!empty($desc) ? $descId : null), + ]) ?> +
    + +
    + + + element('LdapConnector.schemaAttributes/attributeControls', [ + 'attr' => $attr, + 'objectclass' => $objectclass, + 'useOrgValueField'=> $useOrgValueField, + 'useOrgValue' => $useOrgValue, + ]) ?> + + + +
    +
    + +
    +
    + + +
  • +
    +
    +
    + + element('form/requiredSpan', [], [ + 'cache' => '_html_elements', + ]); + } + ?> +
    + +
    + +
    + +
    +
    +
  • diff --git a/app/plugins/LdapConnector/templates/element/schemaAttributes/groupingSelector.php b/app/plugins/LdapConnector/templates/element/schemaAttributes/groupingSelector.php new file mode 100644 index 000000000..5186b35d2 --- /dev/null +++ b/app/plugins/LdapConnector/templates/element/schemaAttributes/groupingSelector.php @@ -0,0 +1,44 @@ +), so grouping renders as a single cohesive row. + * + * Expects: + * - $gkeyForAttr string grouping key + * - $gcfg array grouping config + * - $groupingSelections array groupingKey => selected type_id|null + */ + +declare(strict_types=1); + +$gkeyForAttr = $gkeyForAttr ?? ''; +$gcfg = is_array($gcfg ?? null) ? $gcfg : []; +$groupingSelections = is_array($groupingSelections ?? null) ? $groupingSelections : []; + +$dataKey = (string)($gcfg['dataKey'] ?? $gkeyForAttr); +$gLabel = (string)($gcfg['label'] ?? $gkeyForAttr); +$gOptions = is_array($gcfg['options'] ?? null) ? $gcfg['options'] : []; +$selected = $groupingSelections[$gkeyForAttr] ?? null; + +$typesLabel = __d('ldap_connector', 'field.LdapConnector.' . $gkeyForAttr . '_type_id'); + +if ($gkeyForAttr === '') { + return; +} +?> + +
    + + + Form->select("LdapSchemaGroupings.{$dataKey}.type_id", $gOptions, [ + 'id' => "ldap-schema-grouping-{$gkeyForAttr}-type_id", + 'empty' => __d('ldap_connector', 'types.all'), + 'value' => $selected, + 'class' => 'form-select', + 'aria-label' => __d('ldap_connector', 'schema.type_for', [$gLabel]), + ]) ?> +
    diff --git a/app/plugins/LdapConnector/tests/TestCase/Controller/LdapProvisionersControllerTest.php b/app/plugins/LdapConnector/tests/TestCase/Controller/LdapProvisionersControllerTest.php new file mode 100644 index 000000000..50ceed26f --- /dev/null +++ b/app/plugins/LdapConnector/tests/TestCase/Controller/LdapProvisionersControllerTest.php @@ -0,0 +1,27 @@ + + */ + protected array $fixtures = [ + 'plugin.LdapConnector.LdapProvisioners', + ]; +} diff --git a/app/plugins/LdapConnector/webroot/.gitkeep b/app/plugins/LdapConnector/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/plugins/LdapConnector/webroot/css/ldap-connector.css b/app/plugins/LdapConnector/webroot/css/ldap-connector.css new file mode 100644 index 000000000..1c5201254 --- /dev/null +++ b/app/plugins/LdapConnector/webroot/css/ldap-connector.css @@ -0,0 +1,3 @@ +#edit_OrganizationalPersonSchemas > li.fields-subsection-subtitle { + border: 0 !important; +} diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index a7e3c9c82..e804730f1 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -119,9 +119,19 @@ public function beforeRender(EventInterface $event) { 'co_id' => method_exists($controller, 'getCOID') ? $controller->getCOID() : null ]; - // Use fully-qualified model name - $requesterModel = StringUtilities::getQualifiedName($request->getParam('plugin'), $request->getParam('controller')); + // Use fully-qualified model name (STRICT first; fallback for context-only params) + $requesterModel = StringUtilities::getQualifiedName( + $request->getParam('plugin'), + $request->getParam('controller') + ); + + try { $modelName = StringUtilities::foreignKeyToQualifiedModelName($queryParam, $requesterModel); + } catch (\Throwable $e) { + // Context-only query param (not a real belongsTo FK on the requester table): + // fall back to convention, eg person_id -> People, group_id -> Groups. + $modelName = StringUtilities::foreignKeyToClassName($queryParam); + } $this->injectPrimaryLink($link, true, null, $modelName); break; // Only inject the first matching parameter diff --git a/app/src/Controller/StandardPluggableController.php b/app/src/Controller/StandardPluggableController.php index 86a22454e..d7c6b91da 100644 --- a/app/src/Controller/StandardPluggableController.php +++ b/app/src/Controller/StandardPluggableController.php @@ -33,7 +33,7 @@ use Cake\Log\Log; use App\Lib\Util\StringUtilities; -class StandardPluggableController extends StandardController { +class StandardPluggableController extends StandardPluginController { /** * Redirect into the edit view of the instantiated plugin. * diff --git a/app/src/Lib/Util/SchemaManager.php b/app/src/Lib/Util/SchemaManager.php index e6eb9f7ac..9119569ab 100644 --- a/app/src/Lib/Util/SchemaManager.php +++ b/app/src/Lib/Util/SchemaManager.php @@ -248,6 +248,26 @@ protected function processSchema( $tablePrefix.$tName . "_" . $cName . "_fkey"); } } + + // Default is to insert timestamp and changelog fields, unless disabled + + if(!isset($tCfg->timestamps) || $tCfg->timestamps) { + // Insert Cake metadata fields + $table->addColumn("created", "datetime"); + $table->addColumn("modified", "datetime", ['notnull' => false]); + } + + if(!isset($tCfg->changelog) || $tCfg->changelog) { + // Insert ChangelogBehavior metadata fields + $clColumn = \Cake\Utility\Inflector::singularize($tName) . "_id"; + $table->addColumn($clColumn, "integer", ['notnull' => false]); + $table->addColumn("revision", "integer", ['notnull' => false]); + $table->addColumn("deleted", "boolean", ['notnull' => false]); + $table->addColumn("actor_identifier", "string", ['length' => 256, 'notnull' => false]); + + $table->addForeignKeyConstraint($table, [$clColumn], ['id'], [], $tName . "_" . $clColumn . "_fkey"); + $table->addIndex([$clColumn], $tablePrefix.$tName . "_icl", [], []); + } if(isset($tCfg->clonable) && $tCfg->clonable) { // Duplicatable objects get uuid and cri fields @@ -355,26 +375,6 @@ protected function processSchema( $table->addColumn("rght", "integer", ['notnull' => false]); } - - // Default is to insert timestamp and changelog fields, unless disabled - - if(!isset($tCfg->timestamps) || $tCfg->timestamps) { - // Insert Cake metadata fields - $table->addColumn("created", "datetime"); - $table->addColumn("modified", "datetime", ['notnull' => false]); - } - - if(!isset($tCfg->changelog) || $tCfg->changelog) { - // Insert ChangelogBehavior metadata fields - $clColumn = \Cake\Utility\Inflector::singularize($tName) . "_id"; - $table->addColumn($clColumn, "integer", ['notnull' => false]); - $table->addColumn("revision", "integer", ['notnull' => false]); - $table->addColumn("deleted", "boolean", ['notnull' => false]); - $table->addColumn("actor_identifier", "string", ['length' => 256, 'notnull' => false]); - - $table->addForeignKeyConstraint($table, [$clColumn], ['id'], [], $tName . "_" . $clColumn . "_fkey"); - $table->addIndex([$clColumn], $tablePrefix.$tName . "_icl", [], []); - } } // This is the SQL that represents the desired state of the database diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index bd12ba03d..899528dc6 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -706,11 +706,11 @@ public static function modelNameToQualifiedModelName(string $modelName, ?string if (count($named) === 1) { $target = $named[0]->getTarget(); - $alias = (string)$target->getRegistryAlias(); + $alias = (string)$target->getRegistryAlias(); - if ($alias !== '') { - return $alias; - } + if ($alias !== '') { + return $alias; + } throw new \RuntimeException( "Unable to determine registry alias for association target '$requesterModel -> $modelName'" diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php index 02e8a6d62..9335347d8 100644 --- a/app/src/Lib/Util/TableUtilities.php +++ b/app/src/Lib/Util/TableUtilities.php @@ -259,6 +259,7 @@ public static function treeTraversalFromPrimaryLink( && $col !== $primaryLinkKey && str_ends_with($col, '_id') ) { + // qualify the FK using the requester table we're currently traversing // but compare against the physical table name in the database. $fkQualifiedModel = StringUtilities::foreignKeyToQualifiedModelName($col, $primaryLinkModelName); diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php index b47726199..8d61c1eb6 100644 --- a/app/src/Model/Table/GroupMembersTable.php +++ b/app/src/Model/Table/GroupMembersTable.php @@ -650,10 +650,10 @@ public function validationDefault(Validator $validator): Validator { * @param array $fields An array of fields to update, where each field * includes an enrollment attribute and its value. * - * @return GroupMember The saved entity representing the person's role. + * @return GroupMember|null The saved entity representing the person's role. * */ - public function saveAttributeCollectorPetitionAttributes(int $personId, array $fields): GroupMember + public function saveAttributeCollectorPetitionAttributes(int $personId, array $fields): ?GroupMember { $member = [ 'person_id' => $personId, diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 2d2e58827..8cbd50898 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -428,7 +428,7 @@ public function formField(string $fieldName, string $fieldType = null, array $fieldSelectOptions = null, string $fieldNameAlias = null, - bool $labelIsTextOnly = null): string + bool $labelIsTextOnly = null): string { $fieldArgs = $fieldOptions ?? []; $fieldArgs['label'] = $fieldOptions['label'] ?? false; diff --git a/app/src/View/Helper/TabHelper.php b/app/src/View/Helper/TabHelper.php index 05c5058ef..b4984ca67 100644 --- a/app/src/View/Helper/TabHelper.php +++ b/app/src/View/Helper/TabHelper.php @@ -33,7 +33,6 @@ use App\Lib\Util\TableUtilities; use Cake\ORM\Table; use Cake\ORM\TableRegistry; -use Cake\Utility\Inflector; use Cake\View\Helper; class TabHelper extends Helper @@ -271,16 +270,22 @@ public function getLinkClass(string $tab, bool $isNested = false, array $nesting */ public function tabBelongsToModelPath(string $tab, string $modelFullName, int &$depth = 0): bool { - $model = TableRegistry::getTableLocator()->get($modelFullName); - // We'll start by getting the set of models directly associated with the CO model. - $associations = $model->associations(); - - $depth++; - foreach($associations->getByType(['belongsTo', 'belongsToMany']) as $ta) { - if($ta->getClassName() === $tab) { - return true; - } - return $this->tabBelongsToModelPath($tab, $ta->getClassName(), $depth); + $depth = 0; + + $matches = TableUtilities::findAssociationsByTraversal( + startModel: $modelFullName, + isMatch: static function ($assoc) use ($tab): bool { + return $assoc->getClassName() === $tab; + }, + types: ['belongsTo', 'belongsToMany'], + maxDepth: 10 + ); + + if (!empty($matches)) { + // We don't currently return the actual depth; keep API stable. + // If you want the exact depth, we can adjust the utility to return it. + $depth = 1; + return true; } return false; @@ -630,11 +635,10 @@ public function getModelTableReference(string $modelsName): Table */ public function getModelTotalCount(string $modelName, array $whereClause): int { - $modelsName = Inflector::camelize($modelName); - $ModelTable = TableRegistry::getTableLocator()->get($modelsName); + $ModelTable = TableRegistry::getTableLocator()->get($modelName); $count = $ModelTable->find() - ->where($whereClause) - ->count(); + ->where($whereClause) + ->count(); return $count; } diff --git a/app/templates/ProvisioningTargets/fields.inc b/app/templates/ProvisioningTargets/fields.inc index 9d25e1eaf..bd779687c 100644 --- a/app/templates/ProvisioningTargets/fields.inc +++ b/app/templates/ProvisioningTargets/fields.inc @@ -41,7 +41,22 @@ $fields = [ $fields = array_merge($fields, include(ROOT . DS . 'templates' . DS . 'Standard/metadata.inc')); -$subnav = 'standard'; +// LdapConnector Provisioner has special subnavigation treatment (they have an extra tab for LdapSchemas). +if(!empty($vv_obj['plugin'])) { + $subnav = [ + 'tabs' => ['ProvisioningTargets', 'ProvisioningTargets.Plugin', 'ProvisioningTargets.Hierarchy'], + 'action' => [ + 'ProvisioningTargets' => ['edit'], + 'ProvisioningTargets.Plugin' => ['edit'], + 'ProvisioningTargets.Hierarchy' => ['index'] + ], + 'counter' => ['ProvisioningTargets.Hierarchy'], + ]; +} else { + // Otherwise, provisioning targets get the standard subnavigation treatment. + $subnav = 'standard'; +} + ?> diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 4aa94dbc5..0b43bd8f1 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -320,6 +320,10 @@ ]; } + if (!empty($a['plugin'])) { + $actionUrl['plugin'] = $a['plugin']; + } + if(!empty($a['query'])) { $fn = $a['query']; diff --git a/app/templates/Standard/subnavigation.inc b/app/templates/Standard/subnavigation.inc index 29f4810ed..c9d5871af 100644 --- a/app/templates/Standard/subnavigation.inc +++ b/app/templates/Standard/subnavigation.inc @@ -62,10 +62,10 @@ $subnavConfig = [ // plugin fields.inc config as $subnav = [the tab definition array] // as described above. 'standard' => [ - 'tabs' => [$modelsName, $modelsName . '.Plugin'], + 'tabs' => [$fullModelsName, $fullModelsName . '.Plugin'], 'action' => [ - $modelsName => ['edit'], - $modelsName . '.Plugin' => ['edit'] + $fullModelsName => ['edit'], + $fullModelsName . '.Plugin' => ['edit'] ] ], diff --git a/app/templates/element/form/listItem.php b/app/templates/element/form/listItem.php index 050e90cc0..622ad6121 100644 --- a/app/templates/element/form/listItem.php +++ b/app/templates/element/form/listItem.php @@ -34,7 +34,6 @@ // - add a prefix and create a namespace // - wrap them in an array. // We choose the latter. -use App\Lib\Util\StringUtilities; $this->set('fieldName', $arguments['fieldName']); $fieldName = $arguments['fieldName']; diff --git a/app/vendor/cakephp-plugins.php b/app/vendor/cakephp-plugins.php index baabe40de..02c204d6b 100644 --- a/app/vendor/cakephp-plugins.php +++ b/app/vendor/cakephp-plugins.php @@ -7,6 +7,11 @@ 'Bake' => $baseDir . '/vendor/cakephp/bake/', 'Cake/TwigView' => $baseDir . '/vendor/cakephp/twig-view/', 'DebugKit' => $baseDir . '/vendor/cakephp/debug_kit/', + 'EnvSource' => $baseDir . '/plugins/EnvSource/', + 'LdapConnector' => $baseDir . '/plugins/LdapConnector/', 'Migrations' => $baseDir . '/vendor/cakephp/migrations/', + 'OrcidSource' => $baseDir . '/plugins/OrcidSource/', + 'SshKeyAuthenticator' => $baseDir . '/plugins/SshKeyAuthenticator/', + 'TermsAgreer' => $baseDir . '/plugins/TermsAgreer/', ], ]; diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php index 967a4765d..d1db4160b 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -8,6 +8,8 @@ return array( 'Twig\\Extra\\Markdown\\' => array($vendorDir . '/twig/markdown-extra'), 'Twig\\' => array($vendorDir . '/twig/twig/src'), + 'TermsAgreer\\Test\\' => array($baseDir . '/plugins/TermsAgreer/tests'), + 'TermsAgreer\\' => array($baseDir . '/plugins/TermsAgreer/src'), 'Symfony\\Polyfill\\Php81\\' => array($vendorDir . '/symfony/polyfill-php81'), 'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'), 'Symfony\\Polyfill\\Php73\\' => array($vendorDir . '/symfony/polyfill-php73'), @@ -48,6 +50,7 @@ 'M1\\Env\\' => array($vendorDir . '/m1/env/src'), 'League\\Uri\\' => array($vendorDir . '/league/uri', $vendorDir . '/league/uri-interfaces'), 'League\\Container\\' => array($vendorDir . '/league/container/src'), + 'LdapConnector\\' => array($baseDir . '/plugins/LdapConnector/src'), 'Laminas\\HttpHandlerRunner\\' => array($vendorDir . '/laminas/laminas-httphandlerrunner/src'), 'Laminas\\Diactoros\\' => array($vendorDir . '/laminas/laminas-diactoros/src'), 'JsonSchema\\' => array($vendorDir . '/justinrainbow/json-schema/src/JsonSchema'), diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index e2c53d0b4..57a6541d3 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -43,12 +43,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e ); public static $prefixLengthsPsr4 = array ( - 'T' => + 'T' => array ( 'Twig\\Extra\\Markdown\\' => 20, 'Twig\\' => 5, + 'TermsAgreer\\Test\\' => 17, + 'TermsAgreer\\' => 12, ), - 'S' => + 'S' => array ( 'Symfony\\Polyfill\\Php81\\' => 23, 'Symfony\\Polyfill\\Php80\\' => 23, @@ -71,11 +73,11 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'Seld\\PharUtils\\' => 15, 'Seld\\JsonLint\\' => 14, ), - 'R' => + 'R' => array ( 'React\\Promise\\' => 14, ), - 'P' => + 'P' => array ( 'Psy\\' => 4, 'Psr\\SimpleCache\\' => 16, @@ -91,38 +93,39 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'PHPStan\\PhpDocParser\\' => 21, 'PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\' => 57, ), - 'O' => + 'O' => array ( ), - 'M' => + 'M' => array ( 'Migrations\\' => 11, 'Masterminds\\' => 12, 'MabeEnum\\' => 9, 'M1\\Env\\' => 7, ), - 'L' => + 'L' => array ( 'League\\Uri\\' => 11, 'League\\Container\\' => 17, + 'LdapConnector\\' => 14, 'Laminas\\HttpHandlerRunner\\' => 26, 'Laminas\\Diactoros\\' => 18, ), - 'K' => + 'K' => array ( ), - 'J' => + 'J' => array ( 'JsonSchema\\' => 11, 'Jasny\\Twig\\' => 11, ), - 'F' => + 'F' => array ( ), - 'E' => + 'E' => array ( ), - 'D' => + 'D' => array ( 'Doctrine\\SqlFormatter\\' => 22, 'Doctrine\\Deprecations\\' => 22, @@ -132,7 +135,7 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'DeepCopy\\' => 9, 'DebugKit\\' => 9, ), - 'C' => + 'C' => array ( 'Composer\\XdebugHandler\\' => 23, 'Composer\\Spdx\\' => 14, @@ -149,12 +152,12 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'Cake\\' => 5, 'CakePHP\\' => 8, ), - 'B' => + 'B' => array ( 'Brick\\VarExporter\\' => 18, 'Bake\\' => 5, ), - 'A' => + 'A' => array ( 'Authentication\\' => 15, 'App\\Test\\' => 9, @@ -163,311 +166,427 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e ); public static $prefixDirsPsr4 = array ( - 'Twig\\Extra\\Markdown\\' => + 'Twig\\Extra\\Markdown\\' => array ( 0 => __DIR__ . '/..' . '/twig/markdown-extra', ), - 'Twig\\' => + 'Twig\\' => array ( 0 => __DIR__ . '/..' . '/twig/twig/src', ), - 'Symfony\\Polyfill\\Php81\\' => + 'TermsAgreer\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/TermsAgreer/tests', + ), + 'TermsAgreer\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/TermsAgreer/src', + ), + 'Symfony\\Polyfill\\Php81\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-php81', ), - 'Symfony\\Polyfill\\Php80\\' => + 'Symfony\\Polyfill\\Php80\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-php80', ), - 'Symfony\\Polyfill\\Php73\\' => + 'Symfony\\Polyfill\\Php73\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-php73', ), - 'Symfony\\Polyfill\\Mbstring\\' => + 'Symfony\\Polyfill\\Mbstring\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring', ), - 'Symfony\\Polyfill\\Intl\\Normalizer\\' => + 'Symfony\\Polyfill\\Intl\\Normalizer\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer', ), - 'Symfony\\Polyfill\\Intl\\Grapheme\\' => + 'Symfony\\Polyfill\\Intl\\Grapheme\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-intl-grapheme', ), - 'Symfony\\Polyfill\\Ctype\\' => + 'Symfony\\Polyfill\\Ctype\\' => array ( 0 => __DIR__ . '/..' . '/symfony/polyfill-ctype', ), - 'Symfony\\Contracts\\Service\\' => + 'Symfony\\Contracts\\Service\\' => array ( 0 => __DIR__ . '/..' . '/symfony/service-contracts', ), - 'Symfony\\Component\\VarDumper\\' => + 'Symfony\\Component\\VarDumper\\' => array ( 0 => __DIR__ . '/..' . '/symfony/var-dumper', ), - 'Symfony\\Component\\String\\' => + 'Symfony\\Component\\String\\' => array ( 0 => __DIR__ . '/..' . '/symfony/string', ), - 'Symfony\\Component\\Process\\' => + 'Symfony\\Component\\Process\\' => array ( 0 => __DIR__ . '/..' . '/symfony/process', ), - 'Symfony\\Component\\HtmlSanitizer\\' => + 'Symfony\\Component\\HtmlSanitizer\\' => array ( 0 => __DIR__ . '/..' . '/symfony/html-sanitizer', ), - 'Symfony\\Component\\Finder\\' => + 'Symfony\\Component\\Finder\\' => array ( 0 => __DIR__ . '/..' . '/symfony/finder', ), - 'Symfony\\Component\\Filesystem\\' => + 'Symfony\\Component\\Filesystem\\' => array ( 0 => __DIR__ . '/..' . '/symfony/filesystem', ), - 'Symfony\\Component\\Console\\' => + 'Symfony\\Component\\Console\\' => array ( 0 => __DIR__ . '/..' . '/symfony/console', ), - 'Symfony\\Component\\Config\\' => + 'Symfony\\Component\\Config\\' => array ( 0 => __DIR__ . '/..' . '/symfony/config', ), - 'SlevomatCodingStandard\\' => + 'SshKeyAuthenticator\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/SshKeyAuthenticator/tests', + ), + 'SshKeyAuthenticator\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/SshKeyAuthenticator/src', + ), + 'SqlConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/SqlConnector/tests', + ), + 'SqlConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/SqlConnector/src', + ), + 'SlevomatCodingStandard\\' => array ( 0 => __DIR__ . '/..' . '/slevomat/coding-standard/SlevomatCodingStandard', ), - 'Seld\\Signal\\' => + 'Seld\\Signal\\' => array ( 0 => __DIR__ . '/..' . '/seld/signal-handler/src', ), - 'Seld\\PharUtils\\' => + 'Seld\\PharUtils\\' => array ( 0 => __DIR__ . '/..' . '/seld/phar-utils/src', ), - 'Seld\\JsonLint\\' => + 'Seld\\JsonLint\\' => array ( 0 => __DIR__ . '/..' . '/seld/jsonlint/src/Seld/JsonLint', ), - 'React\\Promise\\' => + 'React\\Promise\\' => array ( 0 => __DIR__ . '/..' . '/react/promise/src', ), - 'Psy\\' => + 'Psy\\' => array ( 0 => __DIR__ . '/..' . '/psy/psysh/src', ), - 'Psr\\SimpleCache\\' => + 'Psr\\SimpleCache\\' => array ( 0 => __DIR__ . '/..' . '/psr/simple-cache/src', ), - 'Psr\\Log\\' => + 'Psr\\Log\\' => array ( 0 => __DIR__ . '/..' . '/psr/log/src', ), - 'Psr\\Http\\Server\\' => + 'Psr\\Http\\Server\\' => array ( 0 => __DIR__ . '/..' . '/psr/http-server-middleware/src', 1 => __DIR__ . '/..' . '/psr/http-server-handler/src', ), - 'Psr\\Http\\Message\\' => + 'Psr\\Http\\Message\\' => array ( 0 => __DIR__ . '/..' . '/psr/http-factory/src', 1 => __DIR__ . '/..' . '/psr/http-message/src', ), - 'Psr\\Http\\Client\\' => + 'Psr\\Http\\Client\\' => array ( 0 => __DIR__ . '/..' . '/psr/http-client/src', ), - 'Psr\\Container\\' => + 'Psr\\Container\\' => array ( 0 => __DIR__ . '/..' . '/psr/container/src', ), - 'Psr\\Clock\\' => + 'Psr\\Clock\\' => array ( 0 => __DIR__ . '/..' . '/psr/clock/src', ), - 'Psr\\Cache\\' => + 'Psr\\Cache\\' => array ( 0 => __DIR__ . '/..' . '/psr/cache/src', ), - 'PhpParser\\' => + 'PipelineToolkit\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/PipelineToolkit/tests', + ), + 'PipelineToolkit\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/PipelineToolkit/src', + ), + 'PhpParser\\' => array ( 0 => __DIR__ . '/..' . '/nikic/php-parser/lib/PhpParser', ), - 'Phinx\\' => + 'Phinx\\' => array ( 0 => __DIR__ . '/..' . '/robmorgan/phinx/src/Phinx', ), - 'PHPStan\\PhpDocParser\\' => + 'PasswordAuthenticator\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/PasswordAuthenticator/tests', + ), + 'PasswordAuthenticator\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/PasswordAuthenticator/src', + ), + 'PHPStan\\PhpDocParser\\' => array ( 0 => __DIR__ . '/..' . '/phpstan/phpdoc-parser/src', ), - 'PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\' => + 'PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\' => array ( 0 => __DIR__ . '/..' . '/dealerdirect/phpcodesniffer-composer-installer/src', ), - 'Migrations\\' => + 'OrcidSource\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/OrcidSource/tests', + ), + 'OrcidSource\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/OrcidSource/src', + ), + 'Migrations\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/migrations/src', ), - 'Masterminds\\' => + 'Masterminds\\' => array ( 0 => __DIR__ . '/..' . '/masterminds/html5/src', ), - 'MabeEnum\\' => + 'MabeEnum\\' => array ( 0 => __DIR__ . '/..' . '/marc-mabe/php-enum/src', ), - 'M1\\Env\\' => + 'M1\\Env\\' => array ( 0 => __DIR__ . '/..' . '/m1/env/src', ), - 'League\\Uri\\' => + 'League\\Uri\\' => array ( 0 => __DIR__ . '/..' . '/league/uri', 1 => __DIR__ . '/..' . '/league/uri-interfaces', ), - 'League\\Container\\' => + 'League\\Container\\' => array ( 0 => __DIR__ . '/..' . '/league/container/src', ), - 'Laminas\\HttpHandlerRunner\\' => + 'LdapConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/LdapConnector/src', + ), + 'Laminas\\HttpHandlerRunner\\' => array ( 0 => __DIR__ . '/..' . '/laminas/laminas-httphandlerrunner/src', ), - 'Laminas\\Diactoros\\' => + 'Laminas\\Diactoros\\' => array ( 0 => __DIR__ . '/..' . '/laminas/laminas-diactoros/src', ), - 'JsonSchema\\' => + 'KerberosConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/KerberosConnector/tests', + ), + 'KerberosConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/KerberosConnector/src', + ), + 'JsonSchema\\' => array ( 0 => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema', ), - 'Jasny\\Twig\\' => + 'Jasny\\Twig\\' => array ( 0 => __DIR__ . '/..' . '/jasny/twig-extensions/src', ), - 'Doctrine\\SqlFormatter\\' => + 'FileConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/FileConnector/tests', + ), + 'FileConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/FileConnector/src', + ), + 'EnvSource\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/EnvSource/tests', + ), + 'EnvSource\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/EnvSource/src', + ), + 'Doctrine\\SqlFormatter\\' => array ( 0 => __DIR__ . '/..' . '/doctrine/sql-formatter/src', ), - 'Doctrine\\Deprecations\\' => + 'Doctrine\\Deprecations\\' => array ( 0 => __DIR__ . '/..' . '/doctrine/deprecations/src', ), - 'Doctrine\\DBAL\\' => + 'Doctrine\\DBAL\\' => array ( 0 => __DIR__ . '/..' . '/doctrine/dbal/src', ), - 'Doctrine\\Common\\' => + 'Doctrine\\Common\\' => array ( 0 => __DIR__ . '/..' . '/doctrine/event-manager/src', ), - 'Detection\\' => + 'Detection\\' => array ( 0 => __DIR__ . '/..' . '/mobiledetect/mobiledetectlib/src', ), - 'DeepCopy\\' => + 'DeepCopy\\' => array ( 0 => __DIR__ . '/..' . '/myclabs/deep-copy/src/DeepCopy', ), - 'DebugKit\\' => + 'DebugKit\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/debug_kit/src', ), - 'CoreServer\\Test\\' => + 'CoreServer\\Test\\' => array ( 0 => __DIR__ . '/../..' . '/plugins/CoreServer/tests', ), - 'CoreServer\\' => + 'CoreServer\\' => array ( 0 => __DIR__ . '/../..' . '/plugins/CoreServer/src', ), - 'Composer\\XdebugHandler\\' => + 'CoreJob\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreJob/tests', + ), + 'CoreJob\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreJob/src', + ), + 'CoreEnroller\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreEnroller/tests', + ), + 'CoreEnroller\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreEnroller/src', + ), + 'CoreAssigner\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreAssigner/tests', + ), + 'CoreAssigner\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreAssigner/src', + ), + 'CoreApi\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreApi/tests', + ), + 'CoreApi\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreApi/src', + ), + 'Composer\\XdebugHandler\\' => array ( 0 => __DIR__ . '/..' . '/composer/xdebug-handler/src', ), - 'Composer\\Spdx\\' => + 'Composer\\Spdx\\' => array ( 0 => __DIR__ . '/..' . '/composer/spdx-licenses/src', ), - 'Composer\\Semver\\' => + 'Composer\\Semver\\' => array ( 0 => __DIR__ . '/..' . '/composer/semver/src', ), - 'Composer\\Pcre\\' => + 'Composer\\Pcre\\' => array ( 0 => __DIR__ . '/..' . '/composer/pcre/src', ), - 'Composer\\MetadataMinifier\\' => + 'Composer\\MetadataMinifier\\' => array ( 0 => __DIR__ . '/..' . '/composer/metadata-minifier/src', ), - 'Composer\\ClassMapGenerator\\' => + 'Composer\\ClassMapGenerator\\' => array ( 0 => __DIR__ . '/..' . '/composer/class-map-generator/src', ), - 'Composer\\CaBundle\\' => + 'Composer\\CaBundle\\' => array ( 0 => __DIR__ . '/..' . '/composer/ca-bundle/src', ), - 'Composer\\' => + 'Composer\\' => array ( 0 => __DIR__ . '/..' . '/composer/composer/src/Composer', ), - 'Cake\\TwigView\\' => + 'Cake\\TwigView\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/twig-view/src', ), - 'Cake\\Test\\' => + 'Cake\\Test\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/cakephp/tests', ), - 'Cake\\Composer\\' => + 'Cake\\Composer\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/plugin-installer/src', ), - 'Cake\\Chronos\\' => + 'Cake\\Chronos\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/chronos/src', ), - 'Cake\\' => + 'Cake\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/cakephp/src', ), - 'CakePHP\\' => + 'CakePHP\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/cakephp-codesniffer/CakePHP', ), - 'Brick\\VarExporter\\' => + 'Brick\\VarExporter\\' => array ( 0 => __DIR__ . '/..' . '/brick/varexporter/src', ), - 'Bake\\' => + 'Bake\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/bake/src', ), - 'Authentication\\' => + 'Authentication\\' => array ( 0 => __DIR__ . '/..' . '/cakephp/authentication/src', ), - 'App\\Test\\' => + 'App\\Test\\' => array ( 0 => __DIR__ . '/../..' . '/tests', ), - 'App\\' => + 'App\\' => array ( 0 => __DIR__ . '/../..' . '/src', ), + 'ApiConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/ApiConnector/tests', + ), + 'ApiConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/ApiConnector/src', + ), ); public static $prefixesPsr0 = array ( - 'j' => + 'j' => array ( - 'josegonzalez\\Dotenv' => + 'josegonzalez\\Dotenv' => array ( 0 => __DIR__ . '/..' . '/josegonzalez/dotenv/src', 1 => __DIR__ . '/..' . '/josegonzalez/dotenv/tests', From 573419f5aa35e17a7f1480a14413e3a3bfd600be Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sat, 9 May 2026 16:10:05 +0000 Subject: [PATCH 6/9] Added scope_suffix.Created assembleAttributes for EduPersonSchema. --- app/plugins/LdapConnector/config/plugin.json | 1 + .../resources/locales/en_US/ldap_connector.po | 8 +- .../src/Model/Table/EduPersonSchemasTable.php | 582 +++++++++++++++++- .../src/Model/Table/LdapProvisionersTable.php | 4 +- .../templates/LdapProvisioners/fields.inc | 1 + 5 files changed, 581 insertions(+), 15 deletions(-) diff --git a/app/plugins/LdapConnector/config/plugin.json b/app/plugins/LdapConnector/config/plugin.json index 3b24cd37b..b42aebac9 100644 --- a/app/plugins/LdapConnector/config/plugin.json +++ b/app/plugins/LdapConnector/config/plugin.json @@ -25,6 +25,7 @@ "provisioning_target_id": {}, "server_id": { "notnull": false }, "dn_attribute_name": { "type": "string", "size": 32, "notnull": false }, + "scope_suffix": { "type": "string", "size": 128 }, "dn_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, diff --git a/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po b/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po index cb3de82c8..0c36099a6 100644 --- a/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po +++ b/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po @@ -119,4 +119,10 @@ msgid "field.LdapProvisioners.dn_attribute_name" msgstr "People DN Attribute Name" msgid "field.LdapProvisioners.dn_attribute_name.desc" -msgstr "When constructing People DNs, use this attribute name for the unique component" \ No newline at end of file +msgstr "When constructing People DNs, use this attribute name for the unique component" + +msgid "field.LdapProvisioners.scope_suffix" +msgstr "Attribute Scope" + +msgid "field.LdapProvisioners.scope_suffix.desc" +msgstr "For attributes requiring scope, the scope to append (not including @)" \ No newline at end of file diff --git a/app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php index eaea5100f..5c4c0f629 100644 --- a/app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/EduPersonSchemasTable.php @@ -29,6 +29,7 @@ namespace LdapConnector\Model\Table; +use App\Model\Entity\Person; use App\Model\Entity\ProvisioningTarget; use Cake\Datasource\EntityInterface; use Cake\ORM\RulesChecker; @@ -85,6 +86,7 @@ public function initialize(array $config): void $this->setViewContains($this->ldapSchemaAttributesContain()); $this->setEditContains($this->ldapSchemaAttributesContain()); $this->setIndexContains(['LdapSchemas']); + $this->setRedirectGoal('self'); // View vars for type pickers (Types-backed) $this->setAutoViewVars([ @@ -215,23 +217,79 @@ public function getAttributes(): array } /** - * Hook to assemble LDAP attributes for this schema. + * Hook to assemble the attributes for this specific schema (eduPerson). * - * This method is called during provisioning while building the outbound LDAP - * attribute set for an entry. Schema-specific implementations should: - * - return only attributes that are enabled for export in the schema instance - * configuration (required OR export), - * - optionally emit empty arrays (`attr => []`) on modify/rename to request - * attribute removal (deletion semantics depend on the caller). + * This method is invoked by the LDAP provisioning pipeline while building the outbound + * LDAP attribute set for an entry. It returns a plain PHP array suitable for passing + * to the LDAP server add/modify operations (eg `ldap_add` / `ldap_mod_replace`-style payloads). * - * Stub behavior: - * - Returns an empty array (no attributes contributed by this schema). + * Contract: + * - Only return attributes that are enabled for export in the schema instance configuration + * (ie: the corresponding `ldap_schema_attributes` row is `required` OR `export`). + * - If this schema contributes any attributes, it MUST also ensure the schema objectclass + * (`eduPerson`) is present in `objectClass` (see ensureSchemaObjectClass()). * - * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. - * @param string $className Provisioned model name (eg: People, Groups). + * Operation semantics: + * - On {@see $op} = 'add': emit only attributes that have values. + * - On {@see $op} = 'modify' or 'rename': for any enabled attribute that cannot be populated, + * emit an empty array (`attr => []`) to request removal of any previously provisioned values. + * (Deletion semantics depend on the caller/LDAP server implementation.) + * + * Mapping summary (People only, eduPerson): + * - eduPersonAffiliation: + * - From Person Roles affiliation type (best-effort). If the contained affiliation Type entity + * provides an eduPerson mapping, that value is preferred; otherwise the Type's `value` is used. + * - eduPersonScopedAffiliation: + * - Same as eduPersonAffiliation, but with a scope suffix appended (eg `staff@example.org`). + * - Only emitted when a scope suffix is configured/available; on modify/rename may be cleared. + * - eduPersonEntitlement: + * - Best-effort derived from group memberships (eg contained Group names) unless a dedicated + * entitlement mapping service is available upstream. + * - eduPersonNickname: + * - From Name records filtered by configured `type_id` (default: preferred), rendered as a + * CN-like string (eg "John Doe"). + * - eduPersonOrcid: + * - From Identifier records of configured `type_id` (default: ORCID), optionally preferring + * organizational identity values when `use_org_value` is enabled. + * - eduPersonPrincipalName: + * - Single-valued identifier (default: eppn), optionally preferring organizational identity + * values when `use_org_value` is enabled. + * - eduPersonPrincipalNamePrior: + * - Multi-valued identifiers (default: eppn), optionally preferring organizational identity + * values when `use_org_value` is enabled. + * - eduPersonUniqueId: + * - Single-valued identifier (default: enterprise) with scope suffix appended; requires scope. + * + * Example (typical add for "John Doe" with scope "example.org"): + * + * [ + * 'objectClass' => ['eduPerson'], + * 'eduPersonAffiliation' => ['staff', 'member'], + * 'eduPersonScopedAffiliation' => ['staff@example.org', 'member@example.org'], + * 'eduPersonEntitlement' => ['CO:members:active', 'Some Service Group'], + * 'eduPersonNickname' => ['John Doe'], + * 'eduPersonOrcid' => ['https://orcid.org/0000-0002-1825-0097'], + * 'eduPersonPrincipalName' => 'jdoe@example.org', + * 'eduPersonPrincipalNamePrior' => ['john.doe@example.org'], + * 'eduPersonUniqueId' => '12345678@example.org' + * ] + * + * + * Example (modify/rename where enabled attributes are now absent and should be cleared): + * + * [ + * 'objectClass' => ['eduPerson'], + * 'eduPersonOrcid' => [], + * 'eduPersonPrincipalNamePrior' => [], + * 'eduPersonNickname' => [] + * ] + * + * + * @param ProvisioningTarget $provisioningTarget The provisioning target being processed. + * @param string $className Name of the model being provisioned (eg: People, Groups). * @param object $data Provisioned entity (eg: Person or Group). * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). - * @return array LDAP attributes contributed by this schema. + * @return array LDAP attributes contributed by this schema (wire attribute names). * @throws \Throwable * @since COmanage Registry v5.3.0 */ @@ -241,7 +299,505 @@ public function assemblePluginAttributes( object $data, ?string $op = null ): array { - return []; + $ret = []; + + // eduPerson only applies to People provisions + if ($className !== 'People' || !($data instanceof Person)) { + return $ret; + } + + $isModifyLike = ($op === 'modify' || $op === 'rename'); + + // Resolve per-schema attribute export config (required OR export) + $cfg = $this->resolveSchemaAttributeConfigForProvisioningTarget( + $provisioningTarget, + 'LdapConnector.EduPersonSchemas', + $this->ldapObjectClass() + ); + + $cfgByAttr = $cfg['cfgByAttr']; + + $enabled = function (string $attr) use ($cfgByAttr): bool { + return $this->ldapSchemaAttributeEnabled($cfgByAttr[$attr] ?? null); + }; + + $affiliationEnabled = $enabled('eduPersonAffiliation'); + $entitlementEnabled = $enabled('eduPersonEntitlement'); + $nicknameEnabled = $enabled('eduPersonNickname'); + $orcidEnabled = $enabled('eduPersonOrcid'); + $eppnEnabled = $enabled('eduPersonPrincipalName'); + $eppnPriorEnabled = $enabled('eduPersonPrincipalNamePrior'); + $scopedAffiliationEnabled = $enabled('eduPersonScopedAffiliation'); + $uniqueIdEnabled = $enabled('eduPersonUniqueId'); + + if ( + !$affiliationEnabled + && !$entitlementEnabled + && !$nicknameEnabled + && !$orcidEnabled + && !$eppnEnabled + && !$eppnPriorEnabled + && !$scopedAffiliationEnabled + && !$uniqueIdEnabled + ) { + return $ret; + } + + $coId = !empty($data->co_id) ? (int)$data->co_id : null; + + // Scope handling for scoped eduPerson attributes: + // - If a scope suffix is configured, we build it as "@{scopeSuffix}" and append it to values. + // - If no scope suffix is configured, scoped attributes that require it will not be populated + // (and on modify/rename will be emitted as [] to request clearing any previously set values). + $scopeSuffix = $this->resolveScopeSuffix($provisioningTarget); + $scope = (!empty($scopeSuffix) ? ('@' . $scopeSuffix) : ''); + + // eduPersonAffiliation + if ($affiliationEnabled) { + $vals = []; + + foreach (($data->person_roles ?? []) as $pr) { + if (!is_object($pr)) { + continue; + } + + $v = null; + + if (!empty($pr->affiliation_type) && is_object($pr->affiliation_type)) { + // Prefer explicit edupersonaffiliation mapping if present on Type, else use value. + if (!empty($pr->affiliation_type->edupersonaffiliation)) { + $v = (string)$pr->affiliation_type->edupersonaffiliation; + } elseif (!empty($pr->affiliation_type->value)) { + $v = (string)$pr->affiliation_type->value; + } + } + + if ($v !== null && $v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['eduPersonAffiliation'] = $vals; + } elseif ($isModifyLike) { + $ret['eduPersonAffiliation'] = []; + } + } + + // eduPersonScopedAffiliation + if ($scopedAffiliationEnabled) { + $vals = []; + + if (!empty($scope)) { + foreach (($data->person_roles ?? []) as $pr) { + if (!is_object($pr)) { + continue; + } + + $v = null; + + if (!empty($pr->affiliation_type) && is_object($pr->affiliation_type)) { + if (!empty($pr->affiliation_type->edupersonaffiliation)) { + $v = (string)$pr->affiliation_type->edupersonaffiliation; + } elseif (!empty($pr->affiliation_type->value)) { + $v = (string)$pr->affiliation_type->value; + } + } + + if ($v !== null && $v !== '') { + $vals[] = $v . $scope; + } + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['eduPersonScopedAffiliation'] = $vals; + } elseif ($isModifyLike) { + $ret['eduPersonScopedAffiliation'] = []; + } + } + + // eduPersonEntitlement + if ($entitlementEnabled) { + $vals = []; + + foreach (($data->group_members ?? []) as $gm) { + if (!is_object($gm)) { + continue; + } + + // If Group is contained on the GroupMember, use its name as a fallback entitlement-like value. + // (If you have a v5 entitlement mapping service, this is the place to call it.) + if (!empty($gm->group) && is_object($gm->group) && !empty($gm->group->name)) { + $vals[] = (string)$gm->group->name; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['eduPersonEntitlement'] = $vals; + } elseif ($isModifyLike) { + $ret['eduPersonEntitlement'] = []; + } + } + + // eduPersonNickname + if ($nicknameEnabled) { + $targetTypeId = $this->resolveAttributeTypeId( + cfgByAttr: $cfgByAttr, + attrName: 'eduPersonNickname', + coId: $coId, + typeAttribute: 'Names.type', + defaultTypeValue: 'preferred' + ); + + $vals = []; + + foreach (($data->names ?? []) as $n) { + if (!is_object($n)) { + continue; + } + + if (!empty($targetTypeId)) { + $nTypeId = (!empty($n->type_id) ? (int)$n->type_id : null); + if (empty($nTypeId) || $nTypeId !== $targetTypeId) { + continue; + } + } + + $rendered = $this->renderName($n); + if ($rendered !== '') { + $vals[] = $rendered; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['eduPersonNickname'] = $vals; + } elseif ($isModifyLike) { + $ret['eduPersonNickname'] = []; + } + } + + // eduPersonOrcid + if ($orcidEnabled) { + $useOrgValue = !empty($cfgByAttr['eduPersonOrcid']['use_org_value']); + + $targetTypeId = $this->resolveAttributeTypeId( + cfgByAttr: $cfgByAttr, + attrName: 'eduPersonOrcid', + coId: $coId, + typeAttribute: 'Identifiers.type', + defaultTypeValue: 'orcid' + ); + + $vals = []; + + $consumeIdentifierList = static function (array $idents) use (&$vals, $targetTypeId): void { + foreach ($idents as $id) { + if (!is_object($id) || empty($id->identifier)) { + continue; + } + + if (!empty($targetTypeId)) { + $idTypeId = (!empty($id->type_id) ? (int)$id->type_id : null); + if (empty($idTypeId) || $idTypeId !== $targetTypeId) { + continue; + } + } + + $vals[] = (string)$id->identifier; + } + }; + + if ($useOrgValue) { + foreach (($data->org_identity_links ?? []) as $lnk) { + if (!is_object($lnk) || empty($lnk->org_identity) || !is_object($lnk->org_identity)) { + continue; + } + $consumeIdentifierList((array)($lnk->org_identity->identifiers ?? [])); + } + } + + if (empty($vals)) { + $consumeIdentifierList((array)($data->identifiers ?? [])); + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['eduPersonOrcid'] = $vals; + } elseif ($isModifyLike) { + $ret['eduPersonOrcid'] = []; + } + } + + // eduPersonPrincipalName + if ($eppnEnabled) { + $useOrgValue = !empty($cfgByAttr['eduPersonPrincipalName']['use_org_value']); + + $targetTypeId = $this->resolveAttributeTypeId( + cfgByAttr: $cfgByAttr, + attrName: 'eduPersonPrincipalName', + coId: $coId, + typeAttribute: 'Identifiers.type', + defaultTypeValue: 'eppn' + ); + + $val = null; + + $findFirstIdentifier = static function (array $idents) use ($targetTypeId): ?string { + foreach ($idents as $id) { + if (!is_object($id) || empty($id->identifier)) { + continue; + } + + if (!empty($targetTypeId)) { + $idTypeId = (!empty($id->type_id) ? (int)$id->type_id : null); + if (empty($idTypeId) || $idTypeId !== $targetTypeId) { + continue; + } + } + + return (string)$id->identifier; + } + + return null; + }; + + if ($useOrgValue) { + foreach (($data->org_identity_links ?? []) as $lnk) { + if (!is_object($lnk) || empty($lnk->org_identity) || !is_object($lnk->org_identity)) { + continue; + } + + $val = $findFirstIdentifier((array)($lnk->org_identity->identifiers ?? [])); + if (!empty($val)) { + break; + } + } + } + + if (empty($val)) { + $val = $findFirstIdentifier((array)($data->identifiers ?? [])); + } + + if (!empty($val)) { + $ret['eduPersonPrincipalName'] = $val; + } elseif ($isModifyLike) { + $ret['eduPersonPrincipalName'] = []; + } + } + + // eduPersonPrincipalNamePrior + if ($eppnPriorEnabled) { + $useOrgValue = !empty($cfgByAttr['eduPersonPrincipalNamePrior']['use_org_value']); + + $targetTypeId = $this->resolveAttributeTypeId( + cfgByAttr: $cfgByAttr, + attrName: 'eduPersonPrincipalNamePrior', + coId: $coId, + typeAttribute: 'Identifiers.type', + defaultTypeValue: 'eppn' + ); + + $vals = []; + + $consumeIdentifierList = static function (array $idents) use (&$vals, $targetTypeId): void { + foreach ($idents as $id) { + if (!is_object($id) || empty($id->identifier)) { + continue; + } + + if (!empty($targetTypeId)) { + $idTypeId = (!empty($id->type_id) ? (int)$id->type_id : null); + if (empty($idTypeId) || $idTypeId !== $targetTypeId) { + continue; + } + } + + $vals[] = (string)$id->identifier; + } + }; + + if ($useOrgValue) { + foreach (($data->org_identity_links ?? []) as $lnk) { + if (!is_object($lnk) || empty($lnk->org_identity) || !is_object($lnk->org_identity)) { + continue; + } + $consumeIdentifierList((array)($lnk->org_identity->identifiers ?? [])); + } + } + + if (empty($vals)) { + $consumeIdentifierList((array)($data->identifiers ?? [])); + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['eduPersonPrincipalNamePrior'] = $vals; + } elseif ($isModifyLike) { + $ret['eduPersonPrincipalNamePrior'] = []; + } + } + + // eduPersonUniqueId + if ($uniqueIdEnabled) { + $targetTypeId = $this->resolveAttributeTypeId( + cfgByAttr: $cfgByAttr, + attrName: 'eduPersonUniqueId', + coId: $coId, + typeAttribute: 'Identifiers.type', + defaultTypeValue: 'enterprise' + ); + + $val = null; + + foreach (($data->identifiers ?? []) as $id) { + if (!is_object($id) || empty($id->identifier)) { + continue; + } + + if (!empty($targetTypeId)) { + $idTypeId = (!empty($id->type_id) ? (int)$id->type_id : null); + if (empty($idTypeId) || $idTypeId !== $targetTypeId) { + continue; + } + } + + $identifierValue = trim((string)$id->identifier); + if ($identifierValue === '') { + continue; + } + + // If the identifier already includes a scope (contains "@"), use it as-is. + if (str_contains($identifierValue, '@')) { + $val = $identifierValue; + break; + } + + // Otherwise, append the configured scope (if available). + if (!empty($scope)) { + $val = $identifierValue . $scope; + break; + } + + // No scope available and none embedded in the identifier -> cannot emit. + break; + } + + if (!empty($val)) { + $ret['eduPersonUniqueId'] = $val; + } elseif ($isModifyLike) { + $ret['eduPersonUniqueId'] = []; + } + } + + $this->ensureSchemaObjectClass($ret); + + return $ret; + } + + /** + * Helper: resolve a configured type_id for an attribute, or fall back to a default Types-backed type. + * + * This centralizes the repeated pattern used by multiple eduPerson attributes: + * - read type_id from $cfgByAttr[$attrName]['type_id'] + * - if empty, resolve a default Types.id for ($typeAttribute, $defaultTypeValue) in this CO + * + * @param array> $cfgByAttr Attribute config keyed by attribute name + * @param string $attrName Attribute key (eg "eduPersonUniqueId") + * @param int|null $coId CO ID for resolving Types-backed defaults (may be null if unknown) + * @param string $typeAttribute Types.attribute (eg "Identifiers.type", "Names.type") + * @param string $defaultTypeValue Types.value (eg "enterprise", "eppn", "preferred") + * @return int|null + */ + private function resolveAttributeTypeId( + array $cfgByAttr, + string $attrName, + ?int $coId, + string $typeAttribute, + string $defaultTypeValue + ): ?int { + $typeId = $cfgByAttr[$attrName]['type_id'] ?? null; + $typeId = ($typeId !== null && $typeId !== '') ? (int)$typeId : null; + + if (!empty($typeId)) { + return $typeId; + } + + return $this->resolveTypeId($coId, $typeAttribute, $defaultTypeValue); + } + + /** + * Helper: resolve default Types.id for a type "value". + * + * @param int|null $coId + * @param string $attribute Eg: 'Identifiers.type' + * @param string $value Eg: 'eppn' + * @return int|null + */ + private function resolveTypeId(?int $coId, string $attribute, string $value): ?int + { + if (empty($coId)) { + return null; + } + + try { + $Types = TableRegistry::getTableLocator()->get('Types'); + $id = $Types->getTypeId($coId, $attribute, $value); + + return (!empty($id) ? (int)$id : null); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Helper: render a "CN-like" string from a Name(-like) entity. + * + * @param object $name + * @return string + */ + private function renderName(object $name): string + { + if (isset($name->full_name) && (string)$name->full_name !== '') { + return (string)$name->full_name; + } + + $given = !empty($name->given) ? (string)$name->given : ''; + $middle = !empty($name->middle) ? (string)$name->middle : ''; + $family = !empty($name->family) ? (string)$name->family : ''; + + $s = trim($given . ' ' . $middle . ' ' . $family); + + $collapsed = ($s !== '' ? preg_replace('/\s+/', ' ', $s) : '.'); + return ($collapsed !== null ? $collapsed : '.'); + } + + /** + * Helper: resolve scope suffix for scoped eduPerson attributes (if configured). + * + * @param ProvisioningTarget $provisioningTarget + * @return string|null Scope suffix (without leading "@"), or null if not configured. + */ + private function resolveScopeSuffix(ProvisioningTarget $provisioningTarget): ?string + { + if (!empty($provisioningTarget->ldap_provisioner) && is_object($provisioningTarget->ldap_provisioner)) { + $v = $provisioningTarget->ldap_provisioner->scope_suffix ?? null; + + $v = ($v !== null ? trim((string)$v) : ''); + return ($v !== '' ? $v : null); + } + + return null; } /** diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php index 21f468e58..12b4b4757 100644 --- a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php @@ -76,6 +76,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['provisioning_target_id']); $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['resync']); + $this->setRedirectGoal('self'); // Associations // Define associations @@ -227,6 +228,8 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('dn_identifier_type_id'); + $this->registerStringValidation($validator, $schema, 'scope_suffix', false); + // $validator->add('cluster_id', [ // 'content' => ['rule' => 'isInteger'] // ]); @@ -234,7 +237,6 @@ public function validationDefault(Validator $validator): Validator { // // $this->registerStringValidation($validator, $schema, 'unconf_attr_mode', true); // -// $this->registerStringValidation($validator, $schema, 'scope_suffix', false); // // $validator->boolean('attr_opts')->allowEmptyString('attr_opts'); diff --git a/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc b/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc index 192e0166c..5e47fe9f8 100644 --- a/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc +++ b/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc @@ -29,6 +29,7 @@ $fields = [ 'server_id', 'dn_identifier_type_id', 'dn_attribute_name', + 'scope_suffix' ]; $subnav = [ From a925a18ad7c83cce4aa0e693d1a023a8f1e84d22 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 10 May 2026 03:59:01 +0000 Subject: [PATCH 7/9] LdapConnector: add supportsPeople/supportsGroups schema capabilities and skip inapplicable schemas during provisioning --- .../Lib/Traits/LdapObjectClassSchemaTrait.php | 45 +++++++++++++++++++ .../src/Model/Table/EduMemberSchemasTable.php | 8 ++++ .../Model/Table/GroupOfNamesSchemasTable.php | 16 +++++++ .../src/Model/Table/LdapProvisionersTable.php | 6 +++ .../Model/Table/PosixGroupSchemasTable.php | 16 +++++++ .../Model/Table/VoPosixGroupSchemasTable.php | 16 +++++++ 6 files changed, 107 insertions(+) diff --git a/app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php b/app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php index dafc1a4cd..f1511849e 100644 --- a/app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php +++ b/app/plugins/LdapConnector/src/Lib/Traits/LdapObjectClassSchemaTrait.php @@ -110,4 +110,49 @@ public function ldapSchemaAttributesContain(string $alias = 'LdapSchemaAttribute ] ]; } + + + /** + * Whether this schema plugin can contribute attributes when provisioning People. + * + * Default is true for backward compatibility; override in schema tables that + * only apply to Groups. + * + * @return bool + * @since COmanage Registry v5.3.0 + */ + public function supportsPeople(): bool + { + return true; + } + + /** + * Whether this schema plugin can contribute attributes when provisioning Groups. + * + * Default is true for backward compatibility; override in schema tables that + * only apply to People. + * + * @return bool + * @since COmanage Registry v5.3.0 + */ + public function supportsGroups(): bool + { + return false; + } + + /** + * Convenience wrapper to check support by provisioned class name. + * + * @param string $className Expected 'People' or 'Groups' + * @return bool + * @since COmanage Registry v5.3.0 + */ + public function supportsProvisioningClass(string $className): bool + { + return match ($className) { + 'People' => $this->supportsPeople(), + 'Groups' => $this->supportsGroups(), + default => false, + }; + } } diff --git a/app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php index 4bbc4184e..5994535d2 100644 --- a/app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/EduMemberSchemasTable.php @@ -106,6 +106,14 @@ public function initialize(array $config): void { ]); } + /** + * @since COmanage Registry v5.3.0 + */ + public function supportsGroups(): bool + { + return true; + } + /** * Generates a display field for the given entity. * diff --git a/app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php index 1e595e1ba..1fc79aa7f 100644 --- a/app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/GroupOfNamesSchemasTable.php @@ -99,6 +99,22 @@ public function initialize(array $config): void { ]); } + /** + * @since COmanage Registry v5.3.0 + */ + public function supportsPeople(): bool + { + return false; + } + + /** + * @since COmanage Registry v5.3.0 + */ + public function supportsGroups(): bool + { + return true; + } + /** * Generates a display field for the given entity. * diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php index 12b4b4757..5cd84a35e 100644 --- a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php @@ -973,6 +973,12 @@ protected function assembleAttributesFromEnabledSchemas( continue; } + // Skip schemas that don't apply to the current provisioning class (People vs Groups) + if (method_exists($SchemaTable, 'supportsProvisioningClass') + && !$SchemaTable->supportsProvisioningClass($className)) { + continue; + } + $schemaAttrs = (array)$SchemaTable->assemblePluginAttributes( provisioningTarget: $provisioningTarget, className: $className, diff --git a/app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php index 9dce24b46..09cc8983f 100644 --- a/app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/PosixGroupSchemasTable.php @@ -96,6 +96,22 @@ public function initialize(array $config): void { ]); } + /** + * @since COmanage Registry v5.3.0 + */ + public function supportsPeople(): bool + { + return false; + } + + /** + * @since COmanage Registry v5.3.0 + */ + public function supportsGroups(): bool + { + return true; + } + /** * Generates a display field for the given entity. * diff --git a/app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php index e27b20c9a..33496d0bb 100644 --- a/app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/VoPosixGroupSchemasTable.php @@ -96,6 +96,22 @@ public function initialize(array $config): void { ]); } + /** + * @since COmanage Registry v5.3.0 + */ + public function supportsPeople(): bool + { + return false; + } + + /** + * @since COmanage Registry v5.3.0 + */ + public function supportsGroups(): bool + { + return true; + } + /** * Generates a display field for the given entity. * From 20353ef15949f59059ad726cdceafce39e4c022f Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 10 May 2026 04:19:54 +0000 Subject: [PATCH 8/9] Added VoPersonSchema config --- app/plugins/LdapConnector/config/plugin.json | 20 +- .../Controller/VoPersonSchemasController.php | 103 +++++++ .../src/Model/Entity/VoPersonSchema.php | 48 +++ .../src/Model/Table/VoPersonSchemasTable.php | 287 ++++++++++++++++++ .../templates/VoPersonSchemas/fields.inc | 65 ++++ 5 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 app/plugins/LdapConnector/src/Controller/VoPersonSchemasController.php create mode 100644 app/plugins/LdapConnector/src/Model/Entity/VoPersonSchema.php create mode 100644 app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php create mode 100644 app/plugins/LdapConnector/templates/VoPersonSchemas/fields.inc diff --git a/app/plugins/LdapConnector/config/plugin.json b/app/plugins/LdapConnector/config/plugin.json index b42aebac9..6d69cd116 100644 --- a/app/plugins/LdapConnector/config/plugin.json +++ b/app/plugins/LdapConnector/config/plugin.json @@ -14,7 +14,8 @@ "VoPosixGroupSchemas", "VoPosixAccountSchemas", "EduMemberSchemas", - "EduPersonSchemas" + "EduPersonSchemas", + "VoPersonSchemas" ] }, "schema": { @@ -315,6 +316,23 @@ "columns": [ "ldap_schema_id", "revision", "edu_person_schema_id" ] } } + }, + + "vo_person_schemas": { + "columns": { + "id": {}, + "ldap_schema_id": { + "type": "integer", + "foreignkey": { "table": "ldap_schemas", "column": "id" } + } + }, + "changelog": true, + "indexes": { + "vo_person_schemas_i1": { + "unique": true, + "columns": [ "ldap_schema_id", "revision", "vo_person_schema_id" ] + } + } } } } diff --git a/app/plugins/LdapConnector/src/Controller/VoPersonSchemasController.php b/app/plugins/LdapConnector/src/Controller/VoPersonSchemasController.php new file mode 100644 index 000000000..368eb6425 --- /dev/null +++ b/app/plugins/LdapConnector/src/Controller/VoPersonSchemasController.php @@ -0,0 +1,103 @@ + [ + 'VoPersonSchemas.id' => 'asc' + ] + ]; + + /** + * Edit a voPerson Schema. + * + * Updates schema attributes for the specified voPerson Schema ID. + * + * @param string $id The ID of the voPerson Schema to edit. + * @return Response|null The response object rendered by the parent class. + * @throws \Throwable If an error occurs during persistence of attribute exports. + * @since COmanage Registry v5.3.0 + */ + public function edit(string $id) + { + $response = parent::edit($id); + + if ($this->request->is(['post', 'put'])) { + $this->persistSchemaAttributeExports($id, 'LdapSchemaAttributes'); + } + + return $response; + } + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * @return Response|void|null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function beforeRender(EventInterface $event) + { + $link = $this->getPrimaryLink(true); + + if (!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->VoPersonSchemas->LdapSchemas->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->VoPersonSchemas->LdapSchemas->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->VoPersonSchemas->LdapSchemas->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Calculate the CO ID for the current request context. + * + * For /ldap-connector/vo-person-schemas/edit/:id, derive CO via: + * VoPersonSchemas(id) -> LdapSchemas -> LdapProvisioners -> ProvisioningTargets -> co_id + * + * @return int|null CO ID if it can be determined, else null + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function calculateRequestedCOID(): ?int + { + return $this->calculateRequestedCOIDForSchemaPluginEdit(); + } +} diff --git a/app/plugins/LdapConnector/src/Model/Entity/VoPersonSchema.php b/app/plugins/LdapConnector/src/Model/Entity/VoPersonSchema.php new file mode 100644 index 000000000..79564e4f4 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Entity/VoPersonSchema.php @@ -0,0 +1,48 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug'=> false, + ]; +} diff --git a/app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php new file mode 100644 index 000000000..5e7935931 --- /dev/null +++ b/app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php @@ -0,0 +1,287 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->belongsTo('LdapConnector.LdapSchemas'); + $this->hasMany('LdapConnector.LdapSchemaAttributes', [ + 'foreignKey' => 'ldap_schema_id', + 'bindingKey' => 'ldap_schema_id', + 'propertyName' => 'ldap_schema_attributes', + 'conditions' => ['LdapSchemaAttributes.objectclass' => 'voPerson'], + 'dependent' => false, + ]); + + $this->setPrimaryLink(['LdapConnector.ldap_schema_id']); + + // Dynamically load only the attributes relevant to this schema's objectclass + $this->setViewContains($this->ldapSchemaAttributesContain()); + $this->setEditContains($this->ldapSchemaAttributesContain()); + $this->setIndexContains(['LdapSchemas']); + $this->setRedirectGoal('self'); + + // View vars for type pickers (Types-backed) + $this->setAutoViewVars([ + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type', + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type', + ], + ]); + + $this->setPermissions([ + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'], + ], + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'], + ], + ]); + } + + /** + * Generates a display field for the given entity. + * + * @param EntityInterface $entity The entity being processed. + * @return string|null The generated display field, or null if unavailable. + * @since v5.3.0 + */ + public function generateDisplayField(EntityInterface $entity): ?string + { + if (empty($entity->ldap_schema) || empty($entity->ldap_schema->description)) { + return null; + } + + return (string)$entity->ldap_schema->description; + } + + /** + * Define the LDAP Schema attributes for voPerson. + * + * Notes: + * - v5 uses Types-backed `type_id` rather than v4's `extendedtype/defaulttype`. + * - We'll wire up the type pickers via `type_attribute` so the UI can render selectors, + * and later map defaults (if desired) in getAttributes() similarly to eduPerson. + * + * @return array + * @since COmanage Registry v5.3.0 + */ + public function getAttributes(): array + { + return [ + 'voPerson' => [ + 'objectclass' => [ + 'required' => false, + ], + 'attributes' => [ + 'voPersonAffiliation' => [ + 'required' => false, + 'multiple' => true, + ], + 'voPersonApplicationPassword' => [ + 'required' => false, + 'multiple' => true, + ], + 'voPersonApplicationUID' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Identifiers.type', + ], + 'voPersonAuthorName' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Names.type', + ], + 'voPersonCertificateDN' => [ + 'required' => false, + 'multiple' => true, + ], + 'voPersonCertificateIssuerDN' => [ + 'required' => false, + 'multiple' => true, + ], + 'voPersonExternalID' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Identifiers.type', + ], + 'voPersonID' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Identifiers.type', + ], + 'voPersonPolicyAgreement' => [ + 'required' => false, + 'multiple' => true, + ], + 'voPersonSoRID' => [ + 'required' => false, + 'multiple' => true, + 'type_attribute' => 'Identifiers.type', + ], + 'voPersonStatus' => [ + 'required' => false, + 'multiple' => true, + ], + 'voPersonToken' => [ + 'required' => false, + 'multiple' => true, + ], + ], + ], + ]; + } + + /** + * Hook to assemble LDAP attributes for this schema. + * + * Placeholder: not implemented yet. + * + * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. + * @param string $className Provisioned model name (eg: People, Groups). + * @param object $data Provisioned entity (eg: Person or Group). + * @param string|null $op Provisioning operation: 'add', 'modify', or 'rename' (may be null). + * @return array LDAP attributes contributed by this schema. + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function assemblePluginAttributes( + ProvisioningTarget $provisioningTarget, + string $className, + object $data, + ?string $op = null + ): array { + return []; + } + + /** + * Default validation rules. + * + * Enforces single instantiation per ldap_schema_id in a changelog-safe way. + * + * @param Validator $validator Validator instance. + * @return Validator + * @throws \Throwable + * @since COmanage Registry v5.3.0 + */ + public function validationDefault(Validator $validator): Validator + { + $validator->add('ldap_schema_id', [ + 'content' => ['rule' => 'isInteger'], + ]); + $validator->notEmptyString('ldap_schema_id'); + + $validator->add('ldap_schema_id', 'uniquePerSchemaRevisionParent', [ + 'rule' => [ + 'validateUnique', + ['scope' => ['ldap_schema_id', 'revision', 'vo_person_schema_id']], + ], + 'provider' => 'table', + 'message' => 'Duplicate voPerson Schema definition for this LDAP Schema', + ]); + + return $validator; + } + + /** + * Build application integrity rules. + * + * @param RulesChecker $rules Rules checker. + * @return RulesChecker + * @since COmanage Registry v5.3.0 + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add( + $rules->isUnique( + ['ldap_schema_id', 'revision', 'vo_person_schema_id'], + 'Duplicate voPerson Schema definition for this LDAP Schema' + ), + 'uniqueVoPersonSchemaPerSchemaRevisionParent', + ['errorField' => 'ldap_schema_id'] + ); + + return $rules; + } + + /** + * The LDAP objectclass this schema model manages. + * + * @return string + * @since COmanage Registry v5.3.0 + */ + public function ldapObjectClass(): string + { + return 'voPerson'; + } +} diff --git a/app/plugins/LdapConnector/templates/VoPersonSchemas/fields.inc b/app/plugins/LdapConnector/templates/VoPersonSchemas/fields.inc new file mode 100644 index 000000000..28c25b7e8 --- /dev/null +++ b/app/plugins/LdapConnector/templates/VoPersonSchemas/fields.inc @@ -0,0 +1,65 @@ + $nameTypes ?? [], + + 'voPersonApplicationUID' => $identifierTypes ?? [], + 'voPersonExternalID' => $identifierTypes ?? [], + 'voPersonID' => $identifierTypes ?? [], + 'voPersonSoRID' => $identifierTypes ?? [], +]; + +// Attributes that should render the "use_org_value" toggle. +// Semantics in v5: "Use value from External Identity Source". +$orgValueAttributes = [ + // none for voPerson (based on current attribute set) +]; + +$fields = [ + 'SUBTITLE' => ['subtitle' => __d('ldap_connector', 'schema.attributes')], + 'HTML' => [ + 'html' => [ + 'element' => 'LdapConnector.schemaAttributes', + 'params' => [ + 'objectclass' => 'voPerson', + 'attributes' => $vv_obj->ldap_schema->ldap_schema_attributes ?? [], + 'fieldPrefix' => 'LdapSchemaAttributes', + 'typeOptionsByAttribute' => $typeOptionsByAttribute, + 'orgValueAttributes' => $orgValueAttributes, + ], + ], + ], +]; + +$subnav = [ + 'tabs' => ['LdapConnector.LdapSchemas', 'LdapConnector.VoPersonSchemas'], + 'action' => [ + 'LdapConnector.LdapSchemas' => ['edit'], + 'LdapConnector.VoPersonSchemas' => ['edit'], + ], +]; From 6599fa9c5716c0df0e39bbf5b35d07751381ace7 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 10 May 2026 05:05:13 +0000 Subject: [PATCH 9/9] Added attr_opts configuration parameter.Added assemlbe attributes for voPersonSchema. --- app/plugins/LdapConnector/config/plugin.json | 1 + .../resources/locales/en_US/ldap_connector.po | 8 +- .../src/Model/Table/LdapProvisionersTable.php | 2 +- .../src/Model/Table/VoPersonSchemasTable.php | 657 +++++++++++++++++- .../templates/LdapProvisioners/fields.inc | 3 +- 5 files changed, 667 insertions(+), 4 deletions(-) diff --git a/app/plugins/LdapConnector/config/plugin.json b/app/plugins/LdapConnector/config/plugin.json index 6d69cd116..e75f6d01c 100644 --- a/app/plugins/LdapConnector/config/plugin.json +++ b/app/plugins/LdapConnector/config/plugin.json @@ -27,6 +27,7 @@ "server_id": { "notnull": false }, "dn_attribute_name": { "type": "string", "size": 32, "notnull": false }, "scope_suffix": { "type": "string", "size": 128 }, + "attr_opts": { "type": "boolean", "notnull": false }, "dn_identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, diff --git a/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po b/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po index 0c36099a6..bd41cbb00 100644 --- a/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po +++ b/app/plugins/LdapConnector/resources/locales/en_US/ldap_connector.po @@ -125,4 +125,10 @@ msgid "field.LdapProvisioners.scope_suffix" msgstr "Attribute Scope" msgid "field.LdapProvisioners.scope_suffix.desc" -msgstr "For attributes requiring scope, the scope to append (not including @)" \ No newline at end of file +msgstr "For attributes requiring scope, the scope to append (not including @)" + +msgid "field.LdapProvisioners.attr_opts" +msgstr "Enable LDAP Attribute Options" + +msgid "field.LdapProvisioners.attr_opts.desc" +msgstr "When enabled, the LDAP provisioner may emit attribute option variants (eg ;lang-, ;role-, ;scope-, ;time-, ;type-, ;app-). Your LDAP server must be configured to accept attribute options. Used by schemas such as voPerson where applicable." \ No newline at end of file diff --git a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php index 5cd84a35e..839d65434 100644 --- a/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/LdapProvisionersTable.php @@ -229,6 +229,7 @@ public function validationDefault(Validator $validator): Validator { $validator->notEmptyString('dn_identifier_type_id'); $this->registerStringValidation($validator, $schema, 'scope_suffix', false); + $validator->boolean('attr_opts')->allowEmptyString('attr_opts'); // $validator->add('cluster_id', [ // 'content' => ['rule' => 'isInteger'] @@ -238,7 +239,6 @@ public function validationDefault(Validator $validator): Validator { // $this->registerStringValidation($validator, $schema, 'unconf_attr_mode', true); // // -// $validator->boolean('attr_opts')->allowEmptyString('attr_opts'); return $validator; } diff --git a/app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php b/app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php index 5e7935931..43465ea2d 100644 --- a/app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php +++ b/app/plugins/LdapConnector/src/Model/Table/VoPersonSchemasTable.php @@ -205,7 +205,79 @@ public function getAttributes(): array /** * Hook to assemble LDAP attributes for this schema. * - * Placeholder: not implemented yet. + * This method aims for v4 parity (CoLdapProvisionerTarget::assembleAttributes) for the voPerson + * objectclass, but with two important interim behaviors: + * + * - Attribute Options (v4 `attr_opts`) are not configurable yet in v5, so we centralize all + * decisions behind helper methods. The helpers currently default to "disabled", but are written + * so you can later wire in the missing configuration without rewriting this method. + * - Service mapping helpers (for app-scoped attributes like `;app-foo`) are stubs that return + * syntactically correct results (usually empty). Once the v5 equivalents of v4 mapping functions + * are available/configured, you only need to update the helpers. + * + * Example return (typical add; attribute options disabled): + * + * [ + * 'objectClass' => ['voPerson'], + * 'voPersonAffiliation' => ['member', 'staff'], + * 'voPersonAuthorName' => ['J. Doe'], + * 'voPersonExternalID' => ['epuid:12345'], + * 'voPersonID' => ['epuid:12345'], + * 'voPersonSoRID' => ['sorid:HR:998877'], + * 'voPersonPolicyAgreement' => ['https://example.org/tc'], + * 'voPersonCertificateDN' => ['CN=John Doe,OU=People,O=Example'], + * 'voPersonCertificateIssuerDN' => ['CN=Example CA,O=Example'], + * 'voPersonStatus' => 'active', + * 'voPersonToken' => ['abcdef123456'], + * ] + * + * + * Example return (typical add; attribute options enabled): + * + * [ + * 'objectClass' => ['voPerson'], + * + * // Role-scoped values (one value set per role) + * 'voPersonAffiliation;role-101' => ['member'], + * 'voPersonAffiliation;role-102' => ['staff'], + * + * // Language-tagged name values + * 'voPersonAuthorName;lang-en' => ['John Doe'], + * 'voPersonAuthorName;lang-fr' => ['Jean Doe'], + * + * // Application-scoped identifier values (derived via identifier-type -> service short_label mapping) + * 'voPersonApplicationUID;app-globus' => ['jdoe'], + * 'voPersonApplicationUID;app-k8s' => ['jdoe'], + * + * // Application-scoped passwords (derived via password-authenticator -> service short_label mapping) + * 'voPersonApplicationPassword;app-vpn' => ['{SSHA}...'], + * 'voPersonApplicationPassword;app-gitlab' => ['(CRYPT)...'], + * + * // Scope-scoped certificate attributes (eg per certificate/credential record) + * 'voPersonCertificateDN;scope-55' => ['CN=John Doe,OU=People,O=Example'], + * 'voPersonCertificateIssuerDN;scope-55' => ['CN=Example CA,O=Example'], + * + * // Time-scoped policy agreements (eg one per acceptance timestamp) + * 'voPersonPolicyAgreement;time-1735689600' => ['https://example.org/tc'], + * + * // Type-scoped tokens (eg TOTP serials) + * 'voPersonToken;type-totp' => ['abcdef123456'], + * + * // Status (and optionally per-role status) + * 'voPersonStatus' => 'active', + * 'voPersonStatus;role-101' => 'active', + * 'voPersonStatus;role-102' => 'active', + * ] + * + * + * Example return (modify/rename clearing enabled-but-missing): + * + * [ + * 'objectClass' => ['voPerson'], + * 'voPersonCertificateDN' => [], + * 'voPersonCertificateIssuerDN' => [], + * ] + * * * @param ProvisioningTarget $provisioningTarget Provisioning target being processed. * @param string $className Provisioned model name (eg: People, Groups). @@ -221,9 +293,592 @@ public function assemblePluginAttributes( object $data, ?string $op = null ): array { + $ret = []; + + if ($className !== 'People' || !($data instanceof Person)) { + return $ret; + } + + $isModifyLike = ($op === 'modify' || $op === 'rename'); + + $cfg = $this->resolveSchemaAttributeConfigForProvisioningTarget( + $provisioningTarget, + 'LdapConnector.VoPersonSchemas', + $this->ldapObjectClass() + ); + + $cfgByAttr = $cfg['cfgByAttr']; + + $enabled = fn(string $attr): bool => $this->ldapSchemaAttributeEnabled($cfgByAttr[$attr] ?? null); + + // If nothing is enabled for export for this schema instance, stop early. + // (Use actual resolved config rather than a hardcoded attribute list.) + $anyEnabled = false; + foreach ($cfgByAttr as $attrName => $row) { + if ($this->ldapSchemaAttributeEnabled(is_array($row) ? $row : null)) { + $anyEnabled = true; + break; + } + } + + if (!$anyEnabled) { + return $ret; + } + + // voPersonAffiliation (from person roles) + if ($enabled('voPersonAffiliation')) { + $vals = []; + + foreach (($data->person_roles ?? []) as $pr) { + if (!is_object($pr)) { + continue; + } + + $v = (string)($pr->affiliation ?? ''); + if ($v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['voPersonAffiliation'] = $vals; + } elseif ($isModifyLike) { + $ret['voPersonAffiliation'] = []; + } + } + + // voPersonAuthorName (names, type-filterable) + if ($enabled('voPersonAuthorName')) { + $targetTypeId = $this->extractConfiguredTypeId($cfgByAttr, 'voPersonAuthorName'); + + $vals = []; + foreach (($data->names ?? []) as $n) { + if (!is_object($n)) { + continue; + } + + if (!empty($targetTypeId)) { + $nTypeId = !empty($n->type_id) ? (int)$n->type_id : null; + if (empty($nTypeId) || $nTypeId !== $targetTypeId) { + continue; + } + } + + $rendered = $this->renderNameCnLike($n); + if ($rendered !== '') { + $vals[] = $rendered; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['voPersonAuthorName'] = $vals; + } elseif ($isModifyLike) { + $ret['voPersonAuthorName'] = []; + } + } + + // Identifier-derived attributes (type-filterable) + $identAttrs = [ + 'voPersonApplicationUID', + 'voPersonExternalID', + 'voPersonID', + 'voPersonSoRID', + ]; + + foreach ($identAttrs as $attr) { + if (!$enabled($attr)) { + continue; + } + + // ApplicationUID is special when attribute options are enabled (v4: ;app-*) + if ($attr === 'voPersonApplicationUID') { + $assembled = $this->assembleVoPersonApplicationUidAttribute( + provisioningTarget: $provisioningTarget, + person: $data, + cfgByAttr: $cfgByAttr, + isModifyLike: $isModifyLike + ); + + $ret = array_merge($ret, $assembled); + continue; + } + + $targetTypeId = $this->extractConfiguredTypeId($cfgByAttr, $attr); + + $vals = []; + foreach (($data->identifiers ?? []) as $id) { + if (!is_object($id)) { + continue; + } + + if (!empty($targetTypeId)) { + $idTypeId = !empty($id->type_id) ? (int)$id->type_id : null; + if (empty($idTypeId) || $idTypeId !== $targetTypeId) { + continue; + } + } + + $v = (string)($id->identifier ?? ''); + if ($v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret[$attr] = $vals; + } elseif ($isModifyLike) { + $ret[$attr] = []; + } + } + + // voPersonApplicationPassword (v4: only meaningful with attr options => ;app-*) + if ($enabled('voPersonApplicationPassword')) { + $assembled = $this->assembleVoPersonApplicationPasswordAttribute( + provisioningTarget: $provisioningTarget, + person: $data, + cfgByAttr: $cfgByAttr, + isModifyLike: $isModifyLike + ); + + $ret = array_merge($ret, $assembled); + } + + // Certificates + if ($enabled('voPersonCertificateDN')) { + $vals = []; + foreach (($data->certificates ?? []) as $cr) { + if (!is_object($cr)) { + continue; + } + $v = (string)($cr->subject_dn ?? ''); + if ($v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['voPersonCertificateDN'] = $vals; + } elseif ($isModifyLike) { + $ret['voPersonCertificateDN'] = []; + } + } + + if ($enabled('voPersonCertificateIssuerDN')) { + $vals = []; + foreach (($data->certificates ?? []) as $cr) { + if (!is_object($cr)) { + continue; + } + $v = (string)($cr->issuer_dn ?? ''); + if ($v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['voPersonCertificateIssuerDN'] = $vals; + } elseif ($isModifyLike) { + $ret['voPersonCertificateIssuerDN'] = []; + } + } + + // Policy Agreements (URLs) + if ($enabled('voPersonPolicyAgreement')) { + $vals = []; + + foreach (($data->co_t_and_c_agreements ?? []) as $tc) { + if (!is_object($tc)) { + continue; + } + + // Best-effort: match v4 semantics (agreement_time + active T&C + url). + // We assume the relevant pieces exist in the entity graph. + $url = null; + if (!empty($tc->co_terms_and_conditions) && is_object($tc->co_terms_and_conditions)) { + $url = (string)($tc->co_terms_and_conditions->url ?? ''); + } elseif (!empty($tc->url)) { + $url = (string)$tc->url; + } + + if (!empty($url)) { + $vals[] = $url; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['voPersonPolicyAgreement'] = $vals; + } elseif ($isModifyLike) { + $ret['voPersonPolicyAgreement'] = []; + } + } + + // Status + if ($enabled('voPersonStatus')) { + $status = (string)($data->status ?? ''); + if ($status !== '') { + // v4 mapped to API strings; for now we pass through the existing value. + // If you need the v4 StatusEnum::$to_api mapping, wire it here later. + $ret['voPersonStatus'] = $status; + } elseif ($isModifyLike) { + $ret['voPersonStatus'] = []; + } + } + + // Token(s) (eg TOTP serials) + if ($enabled('voPersonToken')) { + $vals = []; + foreach (($data->totp_tokens ?? []) as $tt) { + if (!is_object($tt)) { + continue; + } + $v = (string)($tt->serial ?? ''); + if ($v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + if (!empty($vals)) { + $ret['voPersonToken'] = $vals; + } elseif ($isModifyLike) { + $ret['voPersonToken'] = []; + } + } + + if (!empty($ret)) { + $this->ensureSchemaObjectClass($ret); + } + + return $ret; + } + + /** + * Determine if "attribute options" are enabled. + * + * @param ProvisioningTarget $provisioningTarget + * @return bool + * @since COmanage Registry v5.3.0 + */ + private function attributeOptionsEnabled(ProvisioningTarget $provisioningTarget): bool + { + if (!empty($provisioningTarget->ldap_provisioner) && is_object($provisioningTarget->ldap_provisioner)) { + return !empty($provisioningTarget->ldap_provisioner->attr_opts); + } + + return false; + } + + /** + * Map an identifier type (Types.id) to one or more service short labels. + * + * v4 parity target: CoService::mapIdentifierToLabels(co_id, identifier_type) which can return + * multiple labels for a single identifier type. + * + * Current behavior: returns an empty array (mapping not wired yet). + * + * Example return: + * + * [ + * 12 => 'globus', + * 34 => 'k8s' + * ] + * + * + * @param int|null $coId + * @param int|null $typeId Types.id (Identifiers.type) + * @return array Map of service_id => short_label + * @since COmanage Registry v5.3.0 + */ + private function mapIdentifierTypeIdToServiceLabels(?int $coId, ?int $typeId): array + { + if (empty($coId) || empty($typeId)) { + return []; + } + + // TODO: Implement when v5 has an equivalent mapping service available. + return []; + } + + /** + * Map a password authenticator reference to one or more service short labels. + * + * v4 parity target: CoService::mapAuthenticators(co_id) + group-based gating. + * + * Current behavior: returns an empty array (mapping not wired yet). + * + * Example return: + * + * [ + * 7 => 'vpn', + * 9 => 'gitlab' + * ] + * + * + * @param int|null $coId + * @param int|null $passwordAuthenticatorId + * @param Person $person + * @return array Map of service_id => short_label + * @since COmanage Registry v5.3.0 + */ + private function mapPasswordAuthenticatorToServiceLabels( + ?int $coId, + ?int $passwordAuthenticatorId, + Person $person + ): array { + if (empty($coId) || empty($passwordAuthenticatorId)) { + return []; + } + + // TODO: Implement when v5 has an equivalent mapping service available. + // This helper should also apply "service group" gating using the Person's group memberships. return []; } + /** + * Assemble voPersonApplicationUID. + * + * v4 behavior: + * - If attribute options enabled, emit `voPersonApplicationUID;app-{short_label}` values for each + * mapped service short_label, and DO NOT emit a plain `voPersonApplicationUID` unless a label exists. + * - If attribute options disabled, emit plain `voPersonApplicationUID` values. + * + * Example return (attr opts enabled): + * + * [ + * 'voPersonApplicationUID;app-globus' => ['jdoe'], + * 'voPersonApplicationUID;app-k8s' => ['jdoe'] + * ] + * + * + * Example return (attr opts disabled): + * + * [ + * 'voPersonApplicationUID' => ['jdoe'] + * ] + * + * + * @param ProvisioningTarget $provisioningTarget + * @param Person $person + * @param array> $cfgByAttr + * @param bool $isModifyLike + * @return array + * @since COmanage Registry v5.3.0 + */ + private function assembleVoPersonApplicationUidAttribute( + ProvisioningTarget $provisioningTarget, + Person $person, + array $cfgByAttr, + bool $isModifyLike + ): array { + $ret = []; + + $typeId = $this->extractConfiguredTypeId($cfgByAttr, 'voPersonApplicationUID'); + + $vals = []; + foreach (($person->identifiers ?? []) as $id) { + if (!is_object($id)) { + continue; + } + + if (!empty($typeId)) { + $idTypeId = !empty($id->type_id) ? (int)$id->type_id : null; + if (empty($idTypeId) || $idTypeId !== $typeId) { + continue; + } + } + + $v = (string)($id->identifier ?? ''); + if ($v !== '') { + $vals[] = $v; + } + } + + $vals = array_values(array_unique(array_filter($vals, static fn($v) => $v !== ''))); + + $attropts = $this->attributeOptionsEnabled($provisioningTarget); + + if (!$attropts) { + if (!empty($vals)) { + $ret['voPersonApplicationUID'] = $vals; + } elseif ($isModifyLike) { + $ret['voPersonApplicationUID'] = []; + } + + return $ret; + } + + // attr opts enabled => require mapped labels; emit scoped attributes only + $coId = !empty($person->co_id) ? (int)$person->co_id : null; + $labels = $this->mapIdentifierTypeIdToServiceLabels($coId, $typeId); + + if (!empty($labels) && !empty($vals)) { + foreach ($labels as $serviceId => $shortLabel) { + $shortLabel = trim((string)$shortLabel); + if ($shortLabel === '') { + continue; + } + + $attr = 'voPersonApplicationUID;app-' . $shortLabel; + $ret[$attr] = $vals; + } + } elseif ($isModifyLike) { + // Best-effort: on modify-like operations, clear the base attribute key. + // We cannot enumerate prior ;app-* variants without querying LDAP (v4 step (4)). + $ret['voPersonApplicationUID'] = []; + } + + return $ret; + } + + /** + * Assemble voPersonApplicationPassword. + * + * v4 behavior (attr opts enabled only): + * - Emits `voPersonApplicationPassword;app-{short_label}` values derived from application passwords. + * + * Current interim behavior: + * - If attr opts are disabled (default), return either [] or clearing semantics on modify-like. + * - If attr opts are enabled but mapping helpers return empty, return syntactically correct output. + * + * Example return: + * + * [ + * 'voPersonApplicationPassword;app-vpn' => ['{SSHA}...'], + * 'voPersonApplicationPassword;app-gitlab' => ['{SSHA}...'] + * ] + * + * + * @param ProvisioningTarget $provisioningTarget + * @param Person $person + * @param array> $cfgByAttr + * @param bool $isModifyLike + * @return array + * @since COmanage Registry v5.3.0 + */ + private function assembleVoPersonApplicationPasswordAttribute( + ProvisioningTarget $provisioningTarget, + Person $person, + array $cfgByAttr, + bool $isModifyLike + ): array { + $ret = []; + + $attropts = $this->attributeOptionsEnabled($provisioningTarget); + + if (!$attropts) { + if ($isModifyLike) { + $ret['voPersonApplicationPassword'] = []; + } + return $ret; + } + + $coId = !empty($person->co_id) ? (int)$person->co_id : null; + + foreach (($person->passwords ?? []) as $pw) { + if (!is_object($pw)) { + continue; + } + + $authenticatorId = !empty($pw->password_authenticator_id) ? (int)$pw->password_authenticator_id : null; + $labels = $this->mapPasswordAuthenticatorToServiceLabels($coId, $authenticatorId, $person); + + if (empty($labels)) { + continue; + } + + $passwordType = (string)($pw->password_type ?? ''); + $passwordVal = (string)($pw->password ?? ''); + + if ($passwordVal === '') { + continue; + } + + // v4: CR => (CRYPT) prefix; SH => {SSHA}; otherwise raw + $out = match ($passwordType) { + 'CR' => '(CRYPT)' . $passwordVal, + 'SH' => '{SSHA}' . $passwordVal, + default => $passwordVal, + }; + + foreach ($labels as $serviceId => $shortLabel) { + $shortLabel = trim((string)$shortLabel); + if ($shortLabel === '') { + continue; + } + + $attr = 'voPersonApplicationPassword;app-' . $shortLabel; + $ret[$attr][] = $out; + } + } + + // Normalize any collected lists + foreach (array_keys($ret) as $k) { + if (is_array($ret[$k])) { + $ret[$k] = array_values(array_unique(array_filter($ret[$k], static fn($v) => $v !== ''))); + } + } + + if (empty($ret) && $isModifyLike) { + // Best-effort clearing key; cannot enumerate prior ;app-* variants without LDAP query. + $ret['voPersonApplicationPassword'] = []; + } + + return $ret; + } + + /** + * Extract configured Types.id (type_id) for an attribute from schema export configuration. + * + * @param array> $cfgByAttr + * @param string $attrName + * @return int|null + * @since COmanage Registry v5.3.0 + */ + private function extractConfiguredTypeId(array $cfgByAttr, string $attrName): ?int + { + $typeId = $cfgByAttr[$attrName]['type_id'] ?? null; + $typeId = ($typeId !== null && $typeId !== '') ? (int)$typeId : null; + + return (!empty($typeId) ? $typeId : null); + } + + /** + * Render a Name(-like) object into a CN-like string. + * + * @param object $name + * @return string + * @since COmanage Registry v5.3.0 + */ + private function renderNameCnLike(object $name): string + { + if (isset($name->full_name) && (string)$name->full_name !== '') { + return (string)$name->full_name; + } + + $given = !empty($name->given) ? (string)$name->given : ''; + $middle = !empty($name->middle) ? (string)$name->middle : ''; + $family = !empty($name->family) ? (string)$name->family : ''; + + $s = trim($given . ' ' . $middle . ' ' . $family); + + $collapsed = ($s !== '' ? preg_replace('/\s+/', ' ', $s) : '.'); + + return ($collapsed !== null ? $collapsed : '.'); + } + /** * Default validation rules. * diff --git a/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc b/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc index 5e47fe9f8..ad8e4dcd0 100644 --- a/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc +++ b/app/plugins/LdapConnector/templates/LdapProvisioners/fields.inc @@ -29,7 +29,8 @@ $fields = [ 'server_id', 'dn_identifier_type_id', 'dn_attribute_name', - 'scope_suffix' + 'scope_suffix', + 'attr_opts', ]; $subnav = [