From b72575f406095000bd3bea9e13480ae313fe6cff Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Thu, 28 Nov 2024 17:50:48 -0500 Subject: [PATCH] Implement finalization tasks (CFM-31) --- app/resources/locales/en_US/enumeration.po | 3 + app/resources/locales/en_US/error.po | 3 + app/src/Controller/PetitionsController.php | 68 ++++++++- app/src/Lib/Enum/PetitionStatusEnum.php | 1 + app/src/Lib/Enum/StandardEnum.php | 21 +++ .../Lib/Traits/EnrollmentControllerTrait.php | 9 +- app/src/Model/Entity/Petition.php | 6 +- app/src/Model/Table/PetitionsTable.php | 141 +++++++++++++++--- 8 files changed, 222 insertions(+), 30 deletions(-) diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 610d3394d..691a14338 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -481,6 +481,9 @@ msgstr "Duplicate" msgid "PetitionStatusEnum.F" msgstr "Finalized" +msgid "PetitionStatusEnum.FI" +msgstr "Finalizing" + msgid "PetitionStatusEnum.N" msgstr "Denied" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 7e7e40d7a..dd1ec0969 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -304,6 +304,9 @@ msgstr "Enrollee Person not found in Petition {0}" msgid "Petitions.enrollee_email" msgstr "An Email Address for the Enrollee is required by this Enrollment Flow" +msgid "Petitions.status.finalizing" +msgstr "Petition {0} is not in Finalizing status" + msgid "Pipelines.plugin.notimpl" msgstr "Pipeline plugin does not implement {0}" diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php index 974d63bcf..9ebb4c4f7 100644 --- a/app/src/Controller/PetitionsController.php +++ b/app/src/Controller/PetitionsController.php @@ -122,18 +122,72 @@ public function continue(string $id) { */ public function finalize(string $id) { + // We split finalization up into several tasks, since we are constrained by browser and + // web server timeouts, and each step relies on plugins that might or might not behave + // as expected. We use an 'op' flag rather than separate actions in order to simplify + // the authorization logic (which is already custom for finalize). + + // finalize: Tell all plugins to finalize + // assign: Assign Identifiers (if any) + // provision: Run provisioning, then set petition status to Finalized + + $op = $this->requestParam('op'); + + $baseUrl = [ + 'controller' => 'petitions', + 'action' => 'finalize', + $id + ]; + + $token = $this->injectToken((int)$id); + + if($token) { + $baseUrl['?']['token'] = $token; + } + + if(!$op) { + $op = 'finalize'; + } + try { - $this->Petitions->finalize((int)$id); + if($op == 'finalize') { + // Step 1 + $this->Petitions->finalizePlugins((int)$id); + + // Next operation is assign + $baseUrl['?']['op'] = 'assign'; + + return $this->redirect($baseUrl); + } elseif($op == 'assign') { + // Step 2 + $this->Petitions->assignIdentifiers((int)$id); + + // Next operation is provision + $baseUrl['?']['op'] = 'provision'; - $this->Flash->success(__d('result', 'Petitions.finalized')); + return $this->redirect($baseUrl); + } elseif($op == 'provision') { + // Step 3 + $this->Petitions->provision((int)$id); - // We only use the Redirect on Finalize URL (if specified) on success, - // since otherwise the Flash error won't render + // We're really done now, update the Petition status and redirect appropriately + // (This should be very fast and not require a separate page reload) + $this->Petitions->finalize((int)$id); - $petition = $this->Petitions->get((int)$id, ['contain' => ['EnrollmentFlows']]); + $this->Flash->success(__d('result', 'Petitions.finalized')); + + // We only use the Redirect on Finalize URL (if specified) on success, + // since otherwise the Flash error won't render + + $petition = $this->Petitions->get((int)$id, ['contain' => ['EnrollmentFlows']]); + + if(!empty($petition->enrollment_flow->redirect_on_finalize)) { + return $this->redirect($petition->enrollment_flow->redirect_on_finalize); + } + } else { + // Unknown op, throw error - if(!empty($petition->enrollment_flow->redirect_on_finalize)) { - return $this->redirect($petition->enrollment_flow->redirect_on_finalize); + throw new \InvalidArgumentException(__d('error', 'unknown', $op)); } } catch(\Exception $e) { diff --git a/app/src/Lib/Enum/PetitionStatusEnum.php b/app/src/Lib/Enum/PetitionStatusEnum.php index 936466e88..9fbb0efce 100644 --- a/app/src/Lib/Enum/PetitionStatusEnum.php +++ b/app/src/Lib/Enum/PetitionStatusEnum.php @@ -40,6 +40,7 @@ class PetitionStatusEnum extends StandardEnum { const Duplicate = 'D2'; const Failed = 'XX'; const Finalized = 'F'; + const Finalizing = 'FI'; const PendingAcceptance = 'PC'; const PendingApproval = 'PA'; const PendingVerification = 'PE'; diff --git a/app/src/Lib/Enum/StandardEnum.php b/app/src/Lib/Enum/StandardEnum.php index 77475b469..e007bd821 100644 --- a/app/src/Lib/Enum/StandardEnum.php +++ b/app/src/Lib/Enum/StandardEnum.php @@ -33,6 +33,27 @@ use ReflectionClass; class StandardEnum { + /** + * Get the localized text strings for the specified const. + * + * @since COmanage Registry v5.1.0 + * @return string Localized const string + */ + + public static function getLocalization(string $key): string { + // get_called_class() will return something like App\Lib\Enum\StatusEnum + // or CoreServer\Lib\Enum\RdbmsTypeEnum + $classBits = explode('\\', get_called_class(), 4); + + if($classBits[0] == 'App') { + return __d('enumeration', $classBits[3].'.'.$key); + } else { + $pluginDomain = Inflector::underscore($classBits[0]); + + return __d($pluginDomain, 'enumeration.'.$classBits[3].'.'.$key); + } + } + /** * Get the localized text strings for the constants in the Enumeration. * diff --git a/app/src/Lib/Traits/EnrollmentControllerTrait.php b/app/src/Lib/Traits/EnrollmentControllerTrait.php index bd67e8643..d4bfb5f59 100644 --- a/app/src/Lib/Traits/EnrollmentControllerTrait.php +++ b/app/src/Lib/Traits/EnrollmentControllerTrait.php @@ -32,6 +32,7 @@ use Cake\Datasource\Exception\RecordNotFoundException; use Cake\ORM\TableRegistry; use \App\Lib\Enum\EnrollmentActorEnum; +use \App\Lib\Enum\PetitionStatusEnum; use \App\Lib\Util\DeliveryUtilities; use \App\Model\Entity\Petition; @@ -339,7 +340,10 @@ protected function validateToken(Petition $petition): array|bool { // We can't use $petition->useToken because we don't have a role - if(!empty($petition->token) && ($reqToken == $petition->token)) { + if(!empty($petition->token) + // Completed Petitions no longer accept tokens for authorization + && !$petition->isComplete() + && ($reqToken == $petition->token)) { // Token match. The roles are whichever of petitioner and enrollee // _don't_ have a Petition value. @@ -351,7 +355,8 @@ protected function validateToken(Petition $petition): array|bool { } if(empty($petition->enrollee_identifier) - && empty($petition->enrollee_person_id)) { + && (empty($petition->enrollee_person_id) + || $petition->status == PetitionStatusEnum::Finalizing)) { $roles[] = EnrollmentActorEnum::Enrollee; } diff --git a/app/src/Model/Entity/Petition.php b/app/src/Model/Entity/Petition.php index 43fe9c0c5..161c75285 100644 --- a/app/src/Model/Entity/Petition.php +++ b/app/src/Model/Entity/Petition.php @@ -59,6 +59,8 @@ public function isComplete(): bool { PetitionStatusEnum::Duplicate, PetitionStatusEnum::Failed, PetitionStatusEnum::Finalized + // A Finalizing Petition is NOT complete + // PetitionStatusEnum::Finalizing ]); } @@ -105,7 +107,9 @@ public function useToken(string $actorRole): bool { || ($actorRole == EnrollmentActorEnum::Enrollee && empty($this->enrollee_identifier) - && empty($this->enrollee_person_id))) { + // Once finalization begins, we'll have an enrollee_person_id but they + // most likel won't be able to authenticate + && (empty($this->enrollee_person_id) || $this->status == PetitionStatusEnum::Finalizing))) { // Note presence of a token is not an indicator as to whether a token should be used return true; } diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php index 913029d84..fd760387e 100644 --- a/app/src/Model/Table/PetitionsTable.php +++ b/app/src/Model/Table/PetitionsTable.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -37,6 +37,7 @@ use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\PetitionActionEnum; use \App\Lib\Enum\PetitionStatusEnum; +use \App\Lib\Enum\ProvisioningContextEnum; use \App\Lib\Enum\StatusEnum; use \App\Lib\Enum\SuspendableStatusEnum; use \App\Lib\Random\RandomString; @@ -57,7 +58,7 @@ class PetitionsTable extends Table { /** * Perform Cake Model initialization. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param array $config Configuration options passed to constructor */ @@ -151,12 +152,16 @@ public function initialize(array $config): void { $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ + // We handle assign authorization in the Controller + // 'assign' => true, // We handle continue authorization in the Controller 'continue' => true, 'delete' => false, 'edit' => false, // We handle finalize authorization in the Controller 'finalize' => true, + // We handle provision authorization in the Controller + // 'provision' => true, // result just issues a redirect, so we're generous with permissions 'result' => ['platformAdmin', 'coAdmin'], // resume renders a landing page, the admin can copy a URL and resend it @@ -192,6 +197,50 @@ public function initialize(array $config): void { ); } + /** + * Assign Identifiers for a Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + * @throws InvalidArgumentException + */ + + public function assignIdentifiers(int $id) { + // AR-Petition-1 When a Petition is finalized, any configured Identifier Assignments will be run. + $this->llog('rule', "AR-Petition-1 Running Identifier Assignments for Petition $id"); + + $petition = $this->get($id); + + if($petition->status != PetitionStatusEnum::Finalizing) { + throw new \InvalidArgumentException(__d('error', 'Petitions.status.finalizing', [$id])); + } + + if(!$petition->enrollee_person_id) { + // No Person associated with the Petition, so nothing to do + $this->llog('debug', "No Enrollee Person ID found in Petition $id, so not assigning identifiers"); + return; + } + + $IdentifierAssignments = TableRegistry::getTableLocator()->get('IdentifierAssignments'); + + $ret = $IdentifierAssignments->assign( + entityType: 'People', + entityId: $petition->enrollee_person_id, + provision: false, +// $actorPersonId: XXX + ); + + if(!empty($ret['assigned'])) { + $this->PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: null, + action: PetitionActionEnum::Finalized, + comment: __d('result', 'IdentifierAssignments.assigned.ok', [implode(',', array_keys($ret['assigned']))]) + // actorPersonId + ); + } + } + /** * Define business rules. * @@ -212,11 +261,42 @@ public function buildRules(RulesChecker $rules): RulesChecker { /** * Finalize a Petition. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param int $id Petition ID */ public function finalize(int $id) { + $petition = $this->get($id); + + if($petition->isComplete()) { + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); + } + + // Update the Petition status and create a History Record. + $petition->status = PetitionStatusEnum::Finalized; + + $this->saveOrFail($petition); + + $this->PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: null, + action: PetitionActionEnum::Finalized, + comment: __d('result', 'Petitions.finalized') + // actorPersonId + ); + } + + /** + * Perform Plugin finalization for a Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + */ + + public function finalizePlugins(int $id) { + // This is intended to be the first part of finalization, so we set the Petition status + // to Finalizing. + $petition = $this->get($id, ['contain' => [ 'EnrollmentFlows' => [ 'EnrollmentFlowSteps' => array_merge( @@ -229,6 +309,10 @@ public function finalize(int $id) { throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); } + $petition->status = PetitionStatusEnum::Finalizing; + + $this->saveOrFail($petition); + // If there is no Person attached to this Petition, allocate a new Person now // (with no attributes). @@ -260,6 +344,8 @@ public function finalize(int $id) { $this->llog('trace', 'Created new Person ' . $person->id . ' for Petition ' . $petition->id); } + // Tell each plugin to finalize + if(!empty($petition->enrollment_flow->enrollment_flow_steps)) { foreach($petition->enrollment_flow->enrollment_flow_steps as $step) { if($step->status == SuspendableStatusEnum::Suspended) { @@ -284,25 +370,12 @@ public function finalize(int $id) { } } } - - // Finally, update the Petition status and create a History Record. - $petition->status = PetitionStatusEnum::Finalized; - - $this->saveOrFail($petition); - - $this->PetitionHistoryRecords->record( - petitionId: $petition->id, - enrollmentFlowStepId: null, - action: PetitionActionEnum::Finalized, - comment: __d('result', 'Petitions.finalized') - // actorPersonId - ); } /** * Obtain a Petition's token. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param int $id Petition ID * @return string Petition token */ @@ -324,10 +397,38 @@ public function getToken(int $id): string { return $petition->token; } + /** + * Run Provisioning for a Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + */ + + public function provision(int $id) { + // AR-Petition-2 When a Petition is finalized, any configured Provisioners will run, including those in Enrollment Only mode. + $this->llog('rule', "AR-Petition-2 Running Provisioning for Petition $id"); + + $petition = $this->get($id); + + if($petition->status != PetitionStatusEnum::Finalizing) { + throw new \InvalidArgumentException(__d('error', 'Petitions.status.finalizing', [$id])); + } + + if(!$petition->enrollee_person_id) { + // No Person associated with the Petition, so nothing to do + $this->llog('debug', "No Enrollee Person ID found in Petition $id, so not Provisioning"); + return; + } + + $People = TableRegistry::getTableLocator()->get('People'); + + $People->requestProvisioning(id: $petition->enrollee_person_id, context: ProvisioningContextEnum::Enrollment); + } + /** * Application Rule to determine if an Enrollee Email is required. * - * @since COmanage Registyr v5.0.0 + * @since COmanage Registyr v5.1.0 * @param Entity $entity Entity to be validated * @param array $options Application rule options * @return boolean true if the Rule check passes, false otherwise @@ -359,7 +460,7 @@ public function ruleEnrolleeEmail($entity, $options) { /** * Start a new Petition. * - * @since Registry v5.0.0 + * @since Registry v5.1.0 * @param int $enrollmentFlowId Enrollment Flow ID * @param string $petitionerIdentifier Authenticated Petitioner Identifier (NOT Person ID) * @param int $petitionerPersonId Petitioner Person ID, if known @@ -395,7 +496,7 @@ public function start( /** * Set validation rules. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param Validator $validator Validator * @return Validator Validator */