diff --git a/app/availableplugins/KerberosConnector/config/plugin.json b/app/availableplugins/KerberosConnector/config/plugin.json new file mode 100644 index 000000000..240b44742 --- /dev/null +++ b/app/availableplugins/KerberosConnector/config/plugin.json @@ -0,0 +1,43 @@ +{ + "types": { + "provisioning_target": [ + "KerberosProvisioners" + ], + "server": [ + "KerberosServers" + ] + }, + "schema": { + "tables": { + "kerberos_servers": { + "columns": { + "id": {}, + "server_id": {}, + "hostname": { "type": "string", "size": 256 }, + "port": { "type": "integer" }, + "realm": { "type": "string", "size": 256 }, + "admin_principal": { "type": "string", "size": 256 }, + "keytab_path": { "type": "string", "size": 256 } + }, + "indexes": { + "kerberos_servers_i1": { "columns": [ "server_id" ]} + } + }, + "kerberos_provisioners": { + "columns": { + "id": {}, + "provisioning_target_id": {}, + "server_id": { "notnull": false }, + "type_id": { "notnull": false }, + "authenticator_id": { "notnull": false } + }, + "indexes": { + "kerberos_provisioners_i1": { "columns": [ "provisioning_target_id" ]}, + "kerberos_provisioners_i2": { "columns": [ "server_id" ]}, + "kerberos_provisioners_i3": { "columns": [ "type_id" ]}, + "kerberos_provisioners_i4": { "columns": [ "authenticator_id" ]} + } + } + } + } +} \ No newline at end of file diff --git a/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po b/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po new file mode 100644 index 000000000..b4b0a2455 --- /dev/null +++ b/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po @@ -0,0 +1,96 @@ +# COmanage Registry Localizations (kerberos_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-plugins +# @since COmanage Registry v5.2.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.KerberosServers" +msgstr "{0,plural,=1{Kerberos Server} other{Kerberos Servers}}" + +msgid "error.KerberosServers.admin.cfg" +msgstr "Kerberos Server configuration does not have admin principal or keytab" + +msgid "error.principal.identifier" +msgstr "No Identifier of configured type found, unable to construct principal" + +msgid "field.admin_principal" +msgstr "Admin Principal" + +msgid "field.admin_principal.desc" +msgstr "The admin principal to bind to the KDC as, required for admin operations only" + +msgid "field.authenticator_id" +msgstr "Password Authenticator" + +msgid "field.authenticator_id.desc" +msgstr "The Password Authenticator whose value to use to provision the Kerberos Server." + +msgid "field.keytab_path" +msgstr "Keytab Path" + +msgid "field.keytab_path.desc" +msgstr "The filesystem path to the keytab file holding credentials for the admin principal, required for admin operations only" + +msgid "field.realm" +msgstr "Kerberos Realm" + +msgid "field.realm.desc" +msgstr "The Realm is case sensitive" + +msgid "field.server_id" +msgstr "Kerberos Server" + +msgid "field.server_id.desc" +msgstr "The Kerberos Server must be configured with an Admin Principal and Keytab Path." + +msgid "field.type_id" +msgstr "Principal Identifier Type" + +msgid "field.type_id.desc" +msgstr "The Identifier Type used to construct the subject Principal. The Identifier value should not include the Kerberos Realm." + +msgid "result.created" +msgstr "Created new principal {0}" + +msgid "result.never" +msgstr "Never" + +msgid "result.notprov" +msgstr "Principal {0} does not exist" + +msgid "result.active" +msgstr "Principal active (expires: {0}), password expires: {1}" + +msgid "result.expired" +msgstr "Principal expired {0}, password expires: {1}" + +msgid "result.locked" +msgstr "Principal locked (expires: {0}, password expires {1})" + +msgid "result.locked-p" +msgstr "Principal {0} is locked" + +msgid "result.pwexpired" +# Note we swap the rendering order to make it more obvious, but the _parameter_ order is unchanged +msgstr "Password expired {1}, Principal active (expires: {0})" + +msgid "result.synced" +msgstr "Synced existing principal {0}" diff --git a/app/availableplugins/KerberosConnector/src/Controller/AppController.php b/app/availableplugins/KerberosConnector/src/Controller/AppController.php new file mode 100644 index 000000000..edef26010 --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'KerberosProvisioners.id' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/KerberosConnector/src/Controller/KerberosServersController.php b/app/availableplugins/KerberosConnector/src/Controller/KerberosServersController.php new file mode 100644 index 000000000..089b2ef8e --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Controller/KerberosServersController.php @@ -0,0 +1,40 @@ + [ + 'KerberosServers.hostname' => 'asc' + ] + ]; +} diff --git a/app/availableplugins/KerberosConnector/src/KerberosConnectorPlugin.php b/app/availableplugins/KerberosConnector/src/KerberosConnectorPlugin.php new file mode 100644 index 000000000..01c29527f --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/KerberosConnectorPlugin.php @@ -0,0 +1,98 @@ +plugin( + 'KerberosConnector', + ['path' => '/kerberos-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/availableplugins/KerberosConnector/src/Model/Entity/KerberosProvisioner.php b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosProvisioner.php new file mode 100644 index 000000000..a2b87d65a --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosProvisioner.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosServer.php b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosServer.php new file mode 100644 index 000000000..31314a4d5 --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Entity/KerberosServer.php @@ -0,0 +1,51 @@ + + */ + protected array $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php new file mode 100644 index 000000000..5dbff5d7d --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php @@ -0,0 +1,441 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Authenticators'); + $this->belongsTo('ProvisioningTargets'); + $this->belongsTo('Servers'); + $this->belongsTo('Types'); + + $this->setDisplayField('server_id'); + + $this->setPrimaryLink(['provisioning_target_id']); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'authenticators' => [ + 'type' => 'plugin', + 'model' => 'PasswordAuthenticator.PasswordAuthenticators' + ], + 'servers' => [ + 'type' => 'plugin', + 'model' => 'KerberosConnector.KerberosServers' + ], + 'types' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'reapply' => ['platformAdmin', 'coAdmin'], + 'resync' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, //['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->setProvisionableModels([ + 'People' + ]); + } + + /** + * Provision object data to the provisioning target. + * + * @since COmanage Registry v5.2.0 + * @param ProvisioningTarget $provisioningTarget KerberosProvisioner configuration + * @param string $className Class name of primary object being provisioned + * @param object $data Provisioning data in Entity format (eg: \App\Model\Entity\Person) + * @param ProvisioningEligibilityEnum $eligibility Provisioning Eligibility Enum + * @return array Array of status, comment, and optional identifier + */ + + public function provision( + \App\Model\Entity\ProvisioningTarget $provisioningTarget, + string $className, + object $data, // $data is currently only \App\Model\Entity\Person, but that might change + string $eligibility + ): array { + // We need to have an Identifier of the configured type and a Password associated + // with the configured Authenticator. + + // We need the Kerberos Server configuration for the realm + $server = $this->Servers->get( + $provisioningTarget->kerberos_provisioner->server_id, + contain: ['KerberosServers'] + ); + + // Establish a connection to kadmin + + $cxn = $this->Servers->KerberosServers->connect( + serverId: $provisioningTarget->kerberos_provisioner->server_id, + admin: true + ); + + // First look for an Identifier of our configured Type. If we can't find one, we can't + // do anything at all (including deprovisioning). + + $identifier = null; + + if(!empty($data->identifiers)) { + $identifier = Hash::extract($data->identifiers, '{n}[type_id='.$provisioningTarget->kerberos_provisioner->type_id.']'); + } + + if(empty($identifier)) { + throw new \RuntimeException(__d('kerberos_connector', 'error.principal.identifier')); + } + + // We construct a principal with the realm for completeness and predictability, + // but in general the KDC would just append the default realm if we only sent + // the lefthand side + + $principal = $identifier[0]->identifier . "@" . $server->kerberos_server->realm; + + // Map the Authenticator ID (configured on the Provisioning Target in line with the + // pattern of configurations pointing to the Pluggable Model) to the Password + // Authenticator ID (the foreign key on the Password entity). + + $authenticator = $this->Authenticators->get( + $provisioningTarget->kerberos_provisioner->authenticator_id, + contain: ['PasswordAuthenticators'] + ); + + // We always take an action here in order to ensure the KDC is in sync, even though + // in many cases (ie: when called because some other data changed) we'll just be + // confirming the current state. + + $action = 'unknown'; + $password = null; + + if($eligibility == ProvisioningEligibilityEnum::Eligible) { + // Next try to find a Password entity that matches our configured Authenticotor. + // We also need a Password of type PasswordEncodingEnum::Plain, since that's what + // the Kerberos protocol requires. + + if(!empty($data->passwords)) { + $password = Hash::extract($data->passwords, '{n}[type='.PasswordEncodingEnum::Plain.'][password_authenticator_id='.$authenticator->password_authenticator->id.']'); + } + + if(!empty($password)) { + $action = 'update'; + } else { + // If we don't find a Password we lock the principal. We will typically get here + // if the Authenticator is Locked (Person is Active but Password is Locked), so we + // don't need to check the Authenticator Status specifically. There may be other + // edge cases that get us here as well. + + $action = 'lock'; + } + } elseif($eligibility == ProvisioningEligibilityEnum::Ineligible) { + // Check to see if the principal exists in the KDC, and if so lock it + + $action = 'lock'; + } elseif($eligibility == ProvisioningEligibilityEnum::Deleted) { + // Check to see if the principal exists in the KDC, and if so lock it. + // It's plausible we should remove it instead, but for now we'll start with + // the "safer" operation. + + $action = 'lock'; + // $action = 'remove'; + } + + // Before we perform any action, retrieve the current state of the principal + // (if any) from the KDC. + + $curprinc = null; + + try { + $curprinc = $cxn->getPrincipal($principal); + } + catch(\Exception $e) { + // This is most likely that the principal does not exist on the KDC. + } + + if($curprinc) { + // We have an existing principal, ensure it is in sync + + if($action == 'lock') { + // We lock the principal by adding 64 to the attribute mask. This isn't + // documented anywhere, but DISALLOW_ALL_TIX = 64, and is the setting that + // will prevent authentication. + + $attributes = $curprinc->getAttributes(); + + if(!($attributes & 64)) { + // Add the locked bit + $curprinc->setAttributes($curprinc->getAttributes() | 64); + $curprinc->save(); + } + // else the principal is already locked + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.locked-p', [$principal]), + 'identifier' => $principal + ]; + } elseif($action == 'update') { + // Make sure we aren't currently DISALLOWING_ALL_TIX -- if we are clear the flag. + + $attributes = $curprinc->getAttributes(); + + if($attributes & 64) { + // Remove the locked bit + $curprinc->setAttributes($curprinc->getAttributes() ^ 64); + $curprinc->save(); + } + // else the principal is already unlocked + + // We submit a change password request even though the password might not have changed. + // This will show up as a password change on the KDC, which may or may not be OK + // depending on what policies the deploying site might have. Use Pass Through + // Provisioning to avoid this, or maybe set a default policy of -history 1 (though + // that will cause this call will fail, creating "Cannot reuse password" noise in + // Provisioning History Records). + $curprinc->changePassword($password[0]->password); + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.synced', [$principal]), + 'identifier' => $principal + ]; + } + } else { + // No existing principal, the only operation we'll perform is 'update' + + if($action == 'update') { + // From here, we'll just let errors bubble up + + $curprinc = new \KADM5Principal($principal); + + $cxn->createPrincipal(principal: $curprinc, password: $password[0]->password); + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.created', [$principal]), + 'identifier' => $principal + ]; + } + + return [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('kerberos_connector', 'result.notprov', [$principal]) + ]; + } + } + + /** + * Obtain status information for the requested provisioned subject. + * + * @since COmanage Registry v5.2.0 + * @param ProvisioningTarget $cfg Provisioning Target configuration + * @param int $groupId Group ID to retrieve status for + * @param int $personId Person ID to retrieve status for + * @return array Array of status information: status, comment, timestamp + */ + + public function status( + \App\Model\Entity\ProvisioningTarget $cfg, + ?int $groupId, + ?int $personId + ): array { + $ret = [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('enumeration', 'ProvisioningStatusEnum.N'), + 'timestamp' => null + ]; + + // We only support provisioning People. We won't treat a $groupId as an error, + // we can simply return (accurately) that the record is Not Provisioned. + + if($personId) { + // Rather than reconstruct the Principal, we'll just pull it from the Identifiers table + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $typeId = $Identifiers->Types->getTypeId( + coId: $cfg->co_id, + attribute: 'Identifiers.type', + // Although we now call these "Provisioning Keys", we reuse the database value from v4 + value: 'provisioningtarget' + ); + + $principal = $Identifiers->find() + ->where([ + 'person_id' => $personId, + 'type_id' => $typeId, + 'provisioning_target_id' => $cfg->id + ]) + ->firstOrFail(); + + // Establish a connection to kadmin + + $cxn = $this->Servers->KerberosServers->connect( + serverId: $cfg->kerberos_provisioner->server_id, + admin: true + ); + + // Look for the principal + + try { + $princ = $cxn->getPrincipal($principal->identifier); + + // Construct status based on both the principal and password expiration times (if set) + + $expiry = __d('kerberos_connector', 'result.never'); + $pwexpiry = __d('kerberos_connector', 'result.never'); + $status = 'active'; + + if($princ->getPasswordExpiryTime() > 0) { + $pwExpiryTime = FrozenTime::createFromTimestamp($princ->getPasswordExpiryTime()); + $pwexpiry = $pwExpiryTime->nice(); + + if($pwExpiryTime->isPast()) { + $status = 'pwexpired'; + } + } + + if($princ->getExpiryTime() > 0) { + $expiryTime = FrozenTime::createFromTimestamp($princ->getExpiryTime()); + $expiry = $expiryTime->nice(); + + if($expiryTime->isPast()) { + $status = 'expired'; + } + } + + // Test for locked status last to populate the correct comment + $attributes = $princ->getAttributes(); + + if($attributes & 64) { + $status = 'locked'; + } + + $ret['status'] = ProvisioningStatusEnum::Provisioned; + $ret['comment'] = __d('kerberos_connector', 'result.'.$status, [$expiry, $pwexpiry]); + $ret['timestamp'] = FrozenTime::createFromTimestamp($princ->getLastModificationDate()); + } + catch(\Exception $e) { + // We'll get an Exception on principal not found. We should only get here + // on edge case error conditions, eg a Provisioning Key exists in the Identifiers + // table but we didn't successfully provision (or an admin deleted the entry + // from the KDC). + + $ret['comment'] = __d('kerberos_connector', 'result.notprov', [$principal->identifier]); + } + } + + return $ret; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + 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'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + $validator->add('authenticator_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('authenticator_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/availableplugins/KerberosConnector/src/Model/Table/KerberosServersTable.php b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosServersTable.php new file mode 100644 index 000000000..01bb4ee9e --- /dev/null +++ b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosServersTable.php @@ -0,0 +1,164 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Servers'); + + $this->setDisplayField('hostname'); + + $this->setPrimaryLink('server_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Establish a connection to the specified Kerberos server. + * + * @since COmanage Registry v5.2.0 + * @param int $serverId Server ID (NOT KerberosServer ID) + * @param bool $admin If true, establish a kadmin connetion using the Admin Principal and Keytab + * @return mixed KADM5 object if $admin is true + * @throws Exception + */ + + public function connect(int $serverId, bool $admin): \KADM5 { + // Pull our configuration via the parent Server object. + $server = $this->Servers->get($serverId, contain: ['KerberosServers']); + + if($server->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Servers', [1]), $serverId])); + } + + if(empty($server->kerberos_server->admin_principal) + || empty($server->kerberos_server->keytab_path)) { + throw new \InvalidArgumentException(__d('kerberos_connector', 'error.KerberosServers.admin.cfg')); + } + + // If we omit this configuration, the local krb5.conf values will be used, + // but that would be confusing so we require the settings and check for them above. + $config = [ + 'realm' => $server->kerberos_server->realm, + 'admin_server' => $server->kerberos_server->hostname + ]; + + if(!empty($server->kerberos_server->port) && (int)$server->kerberos_server->port > 0) { + $config['admin_port'] = $server->kerberos_server->port; + } + + if(!is_readable($server->kerberos_server->keytab_path)) { + throw new \InvalidArgumentException(__d('error', 'file', [$server->kerberos_server->keytab_path])); + } + + return new \KADM5( + principal: $server->kerberos_server->admin_principal, + credentials: $server->kerberos_server->keytab_path, + use_keytab: true, + config: $config + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $this->registerStringValidation($validator, $schema, 'hostname', true); + + $validator->add('port', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('port'); + + $this->registerStringValidation($validator, $schema, 'realm', true); + + $this->registerStringValidation($validator, $schema, 'admin_principal', false); + + $this->registerStringValidation($validator, $schema, 'keytab_path', false); + + return $validator; + } +} diff --git a/app/availableplugins/KerberosConnector/templates/KerberosProvisioners/fields.inc b/app/availableplugins/KerberosConnector/templates/KerberosProvisioners/fields.inc new file mode 100644 index 000000000..b51a14878 --- /dev/null +++ b/app/availableplugins/KerberosConnector/templates/KerberosProvisioners/fields.inc @@ -0,0 +1,32 @@ +Passwords->find() + ->where([ + 'Passwords.person_id' => $personId, + 'Passwords.password_authenticator_id' => $cfg->password_authenticator->id + ]) + ->all(); + + return $passwords->toArray(); + } + /** * Set validation rules. * diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php index 6f6a1545d..e5217e727 100644 --- a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php +++ b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php @@ -157,7 +157,7 @@ public function manage( // Enabling SSHA requires PHP 7 for random_bytes. // We could use something like https://multiformats.io/multihash, but the - // password_type column basically accomplishes the same thing. + // type column basically accomplishes the same thing. $pdata = null; @@ -169,7 +169,7 @@ public function manage( 'password_authenticator_id' => $data['password_authenticator_id'], 'person_id' => $personId, 'password' => password_hash($data['password'], PASSWORD_DEFAULT), - 'password_type' => PasswordEncodingEnum::Crypt + 'type' => PasswordEncodingEnum::Crypt ]); $this->saveOrFail($pdata); @@ -187,7 +187,7 @@ public function manage( 'password_authenticator_id' => $data['password_authenticator_id'], 'person_id' => $personId, 'password' => $shapwd, - 'password_type' => PasswordEncodingEnum::SSHA + 'type' => PasswordEncodingEnum::SSHA ]); $this->saveOrFail($pdata); @@ -201,7 +201,7 @@ public function manage( 'password_authenticator_id' => $data['password_authenticator_id'], 'person_id' => $personId, 'password' => $data['password'], - 'password_type' => PasswordEncodingEnum::Plain + 'type' => PasswordEncodingEnum::Plain ]); $this->saveOrFail($pdata); @@ -288,7 +288,7 @@ public function status(Authenticator $cfg, int $personId): array { ]) ->first(); - // We don't know which password_type we have, but they should all have the + // We don't know which password type we have, but they should all have the // same mod time if(!empty($pwd->modified)) { diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php index e9d2a6efa..985b374de 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -386,6 +386,68 @@ public function provision( ); } + /** + * Obtain status information for the requested provisioned subject. + * + * @since COmanage Registry v5.2.0 + * @param ProvisioningTarget $cfg Provisioning Target configuration + * @param int $groupId Group ID to retrieve status for + * @param int $personId Person ID to retrieve status for + * @return array Array of status information: status, comment, timestamp + */ + + public function status( + \App\Model\Entity\ProvisioningTarget $cfg, + ?int $groupId, + ?int $personId + ): array { + $ret = [ + 'status' => ProvisioningStatusEnum::NotProvisioned, + 'comment' => __d('enumeration', 'ProvisioningStatusEnum.N'), + 'timestamp' => null + ]; + + // We just look for the primary in the appropriate table. + + if($personId) { + $mconfig = $this->primaryModels['People']; + $id = $personId; + } else { + $mconfig = $this->primaryModels['Groups']; + $id = $groupId; + } + + // We use the same cxnLabel logic as provision(). + $cxnLabel = "targetdb" . $cfg->sql_provisioner->id; + + $this->Servers->SqlServers->connect($cfg->sql_provisioner->server_id, $cxnLabel); + + $options = [ + 'table' => $cfg->sql_provisioner->table_prefix . $mconfig['table'], + 'alias' => $mconfig['name'] . $cfg->sql_provisioner->id, + 'connection' => ConnectionManager::get($cxnLabel) + ]; + + $SpTable = TableUtilities::getTableFromRegistry(alias: $options['alias'], options: $options); + + try { + $curEntity = $SpTable->get($id); + + $ret['status'] = ProvisioningStatusEnum::Provisioned; + $ret['comment'] = __d('enumeration', 'ProvisioningStatusEnum.P'); + $ret['timestamp'] = $curEntity->modified; + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + // Record not found, the default $ret will suffice + } + catch(\Exception $e) { + $ret['status'] = ProvisioningStatusEnum::Unknown; + $ret['comment'] = $e->getMessage(); + } + + return $ret; + } + /** * Sync an entity to the target database schema. * diff --git a/app/composer.json b/app/composer.json index b1d1e02a1..7265ff27d 100644 --- a/app/composer.json +++ b/app/composer.json @@ -42,6 +42,7 @@ "CoreServer\\": "plugins/CoreServer/src/", "EnvSource\\": "plugins/EnvSource/src/", "FileConnector\\": "availableplugins/FileConnector/src/", + "KerberosConnector\\": "availableplugins/KerberosConnector/src/", "OrcidSource\\": "plugins/OrcidSource/src/", "PasswordAuthenticator\\": "availableplugins/PasswordAuthenticator/src/", "PipelineToolkit\\": "availableplugins/PipelineToolkit/src/", @@ -61,6 +62,7 @@ "CoreServer\\Test\\": "plugins/CoreServer/tests/", "EnvSource\\Test\\": "plugins/EnvSource/tests/", "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", + "KerberosConnector\\Test\\": "availableplugins/KerberosConnector/tests/", "OrcidSource\\Test\\": "plugins/OrcidSource/tests/", "PasswordAuthenticator\\Test\\": "availableplugins/PasswordAuthenticator/tests/", "PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/", diff --git a/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php index 1a6978365..08118e561 100644 --- a/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php +++ b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php @@ -109,6 +109,33 @@ public function generateDisplayField(\SshKeyAuthenticator\Model\Entity\SshKeyAut return $entity->authenticator->description; } + /** + * Assemble Authenticator data for provisioning. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator Configuration + * @param int $personId Person ID + * @return array Array of SshKey entities + */ + + public function marshalProvisioningData( + \App\Model\Entity\Authenticator $cfg, + int $personId + ): array { + // Retrieve any Passwords associated with this Person and the requested configuration. + // We'll include all available Password types (encodings) since we don't know which types + // any specific Provisioner will be interested in. + + $sshKeys = $this->SshKeyis->find() + ->where([ + 'SshKeys.person_id' => $personId, + 'SshKeys.ssh_key_authenticator_id' => $cfg->ssh_key_authenticator->id + ]) + ->all(); + + return $sshKeys->toArray(); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 289f97fa2..642c79ab7 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -126,6 +126,10 @@ public function initialize(array $config): void { 'statuses' => [ 'type' => 'enum', 'class' => 'TemplateableStatusEnum' + ], + 'provisioningTargets' => [ + 'type' => 'select', + 'model' => 'ProvisioningTargets' ] ]); diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index a0f386752..4059c7129 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -33,13 +33,17 @@ use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; +use Cake\Utility\Inflector; use Cake\Validation\Validator; use \App\Lib\Enum\ActionEnum; +use \App\Lib\Enum\AuthenticatorStatusEnum; use \App\Lib\Enum\GroupTypeEnum; use \App\Lib\Enum\ProvisioningEligibilityEnum; use \App\Lib\Enum\StatusEnum; use \App\Lib\Enum\SuspendableStatusEnum; use \App\Lib\Util\PaginatedSqlIterator; +use \App\Lib\Util\StringUtilities; class PeopleTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -584,6 +588,57 @@ public function marshalProvisioningData(int $id): array { } $ret['data']->identifiers = $identifiers; + + // Pull the set of available Authenticator Plugins and query them + // for additional attributes to add to the provisioning data. + + $Authenticators = TableRegistry::getTableLocator()->get('Authenticators'); + + $authenticators = $Authenticators->find() + ->where([ + 'co_id' => $ret['data']->co_id, + 'status' => SuspendableStatusEnum::Active + ]) + // Plugins expect their configuration as part of + // the status call + ->contain($Authenticators->getPluginRelations()) + ->all(); + + foreach($authenticators as $authenticator) { + // Start with the Authenticator Status for this Authenticator for this Person. + // Only Authenticators in Active status are eligible for provisioning. + + $status = $Authenticators->AuthenticatorStatuses->getForPerson($authenticator, $id); + + if($status->status == AuthenticatorStatusEnum::Active) { + // Now ask the Plugin for the Provisioning data. Note a given Plugin may be + // instantiated more than once, in which case we'll call marshallProvisioningData + // more than once (with different configuration information). We'll need to + // merge the results together. + + $APlugin = TableRegistry::getTableLocator()->get($authenticator->plugin); + + // We expect an array of entities rather than a ResultSet (which would be + // easily obtainable by a find()) in order to give Plugins more flexibility + // in how they assemble the records. + + $entityData = $APlugin->marshalProvisioningData($authenticator, $id); + + // Determine the entity name in order to populate the provisiosing data. + // We can calculate this because (unlike other Plugin types) there are + // naming conventions for Authenticators. + + $entityKey = Inflector::tableize(StringUtilities::PluginModel($Authenticators->authenticatorEntityName($authenticator->plugin))); + + if(!empty($ret['data']->$entityKey)) { + // We already have data from a previous instantiation of the same plugin, + // merge the results together + $ret['data']->$entityKey = arary_merge($ret['data']->$entityKey, $entityData); + } else { + $ret['data']->$entityKey = $entityData; + } + } + } } else { $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; // For Ineligible records, we remove the items that may be used for eligibilities, diff --git a/app/src/Model/Table/ProvisioningTargetsTable.php b/app/src/Model/Table/ProvisioningTargetsTable.php index e1075a8e6..0a22054f2 100644 --- a/app/src/Model/Table/ProvisioningTargetsTable.php +++ b/app/src/Model/Table/ProvisioningTargetsTable.php @@ -38,6 +38,7 @@ use App\Lib\Enum\ProvisionerModeEnum; use App\Lib\Enum\ProvisioningContextEnum; use App\Lib\Enum\ProvisioningStatusEnum; +use App\Lib\Enum\SuspendableStatusEnum; use App\Lib\Util\StringUtilities; class ProvisioningTargetsTable extends Table { @@ -230,6 +231,43 @@ public function provision( subjectModel: $provisionedModel, subjectId: $data->id ); + + if(!empty($result['identifier']) && in_array($provisionedModel, ['People', 'Groups'])) { + $this->llog('trace', "Obtained Provisioning Key " . $result['identifier'] . " for $pluginModel", $t->id); + + // Upsert the identifier + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $typeId = $Identifiers->Types->getTypeId( + coId: $data->co_id, + attribute: 'Identifiers.type', + // Although we now call these "Provisioning Keys", we reuse the database value from v4 + value: 'provisioningtarget' + ); + + $pkey = [ + 'type_id' => $typeId, + 'identifier' => $result['identifier'], + 'status' => SuspendableStatusEnum::Active, + 'provisioning_target_id' => $t->id, + 'login' => false, + 'frozen' => false + ]; + + $whereClause = [ + 'type_id' => $typeId + ]; + + if($provisionedModel == 'Group') { + $pkey['group_id'] = $data->id; + $whereClause['group_id'] = $data->id; + } else { + $pkey['person_id'] = $data->id; + $whereClause['person_id'] = $data->id; + } + + $Identifiers->upsertOrFail($pkey, $whereClause); + } } catch(\Exception $e) { $this->llog('error', "Provisioning failure: " . $e->getMessage()); @@ -264,19 +302,59 @@ public function status(int $coId, ?int $groupId=null, ?int $personId=null): arra 'ProvisioningTargets.co_id' => $coId, 'ProvisioningTargets.status <>' => ProvisionerModeEnum::Disabled ]) + ->contain($this->getPluginRelations()) ->all(); if(!empty($targets)) { foreach($targets as $t) { // For each target, get the status of the target for the requested subject. + // We'll also look for a Provisioning Key for the target. + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $typeId = $Identifiers->Types->getTypeId( + coId: $coId, + attribute: 'Identifiers.type', + // Although we now call these "Provisioning Keys", we reuse the database value from v4 + value: 'provisioningtarget' + ); + + $targetField = $groupId ? 'group_id' : 'person_id'; + $targetId = $groupId ?? $personId; + + $pkey = $Identifiers->find() + ->where([ + 'type_id' => $typeId, + 'provisioning_target_id' => $t->id, + $targetField => $targetId, + 'status' => SuspendableStatusEnum::Active + ]) + ->first(); + // If the plugin implements a status() function we'll call it, otherwise // we'll get the status from ProvisioningHistory. - $pluginModel = StringUtilities::pluginModel($t->plugin); + $PluginModel = TableRegistry::getTableLocator()->get($t->plugin); - if(method_exists($this->$pluginModel, 'status')) { - // XXX define interface and call (implement with SqlProvisioner) - throw new \RuntimeException('NOT IMPLEMENTED'); + if(method_exists($PluginModel, 'status')) { + try { + $status = $PluginModel->status(cfg: $t, groupId: $groupId, personId: $personId); + + $ret[] = [ + 'target' => $t, + 'status' => $status['status'], + 'comment' => $status['comment'], + 'timestamp' => $status['timestamp'], + 'identifier' => $pkey ? $pkey->identifier : null + ]; + } + catch(\Exception $e) { + $ret[] = [ + 'target' => $t, + 'status' => ProvisioningStatusEnum::Unknown, + 'comment' => $e->getMessage() + ]; + } } else { $subjectFK = null; $subjectID = null; @@ -304,8 +382,7 @@ public function status(int $coId, ?int $groupId=null, ?int $personId=null): arra 'target' => $t, 'status' => $rec->status, 'comment' => $rec->comment, - // XXX where does identifier come from? - //'identifier' => '?', + 'identifier' => $pkey ? $pkey->identifier : null, 'timestamp' => $rec->created ]; } else { diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index e0a41787d..6a09398f0 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -166,6 +166,16 @@ public function calculateLabelAndDescription(string $fieldName): array $key = (!$core ? 'field.' : '') . "$modelName.$fieldName.desc"; $desc = __d(($core ? 'field' : $pluginDomain), $key); + if($desc === $key) { + $key = (!$core ? 'field.' : '') . "$fieldName.desc"; + $desc = __d(($core ? 'field' : $pluginDomain), $key); + + if($key !== $desc) { + // If we found a description, break the loop + break; + } + } + // If the description is the literal key we just generated, there is no description if($desc === $key) { $desc = null; diff --git a/app/templates/Identifiers/fields.inc b/app/templates/Identifiers/fields.inc index ebd84d9e7..37de366bf 100644 --- a/app/templates/Identifiers/fields.inc +++ b/app/templates/Identifiers/fields.inc @@ -50,6 +50,8 @@ if($vv_action == 'edit' || $vv_action == 'view') { $fields['source'] = [ 'entity' => $vv_obj ]; + + $fields[] = 'provisioning_target_id'; } // Top Links diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php index 3d7c06719..3101e6547 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -60,6 +60,8 @@ 'League\\Container\\' => array($vendorDir . '/league/container/src'), 'Laminas\\HttpHandlerRunner\\' => array($vendorDir . '/laminas/laminas-httphandlerrunner/src'), 'Laminas\\Diactoros\\' => array($vendorDir . '/laminas/laminas-diactoros/src'), + 'KerberosConnector\\Test\\' => array($baseDir . '/availableplugins/KerberosConnector/tests'), + 'KerberosConnector\\' => array($baseDir . '/availableplugins/KerberosConnector/src'), 'JsonSchema\\' => array($vendorDir . '/justinrainbow/json-schema/src/JsonSchema'), 'Jasny\\Twig\\' => array($vendorDir . '/jasny/twig-extensions/src'), 'FileConnector\\Test\\' => array($baseDir . '/availableplugins/FileConnector/tests'), diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index d3bd44735..e4816ef2b 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -118,6 +118,11 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'Laminas\\HttpHandlerRunner\\' => 26, 'Laminas\\Diactoros\\' => 18, ), + 'K' => + array ( + 'KerberosConnector\\Test\\' => 23, + 'KerberosConnector\\' => 18, + ), 'J' => array ( 'JsonSchema\\' => 11, @@ -405,6 +410,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/..' . '/laminas/laminas-diactoros/src', ), + 'KerberosConnector\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/KerberosConnector/tests', + ), + 'KerberosConnector\\' => + array ( + 0 => __DIR__ . '/../..' . '/availableplugins/KerberosConnector/src', + ), 'JsonSchema\\' => array ( 0 => __DIR__ . '/..' . '/justinrainbow/json-schema/src/JsonSchema',