From e1c7cb3db2e4d5c3742b86e6324aad57bbac2ed8 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Thu, 31 Jul 2025 20:06:36 -0400 Subject: [PATCH] Configuration to Require MFA For Login to Registry (CFM-436) --- app/config/schema/schema.json | 7 +- app/resources/locales/en_US/command.po | 3 + app/resources/locales/en_US/enumeration.po | 3 + app/resources/locales/en_US/field.po | 30 ++++++++ app/resources/locales/en_US/information.po | 8 +-- app/src/Command/UpgradeCommand.php | 8 +-- app/src/Controller/AppController.php | 57 ++++++++++++++++ .../Controller/EnrollmentFlowsController.php | 33 +++++++++ app/src/Controller/PagesController.php | 12 ++++ app/src/Controller/PetitionsController.php | 30 ++++++++ app/src/Lib/Enum/GroupTypeEnum.php | 1 + app/src/Model/Entity/MostlyStaticPage.php | 14 ++++ app/src/Model/Table/CoSettingsTable.php | 68 +++++++++++++++++++ app/src/Model/Table/GroupsTable.php | 47 ++++++++++++- .../Model/Table/MostlyStaticPagesTable.php | 9 +++ app/templates/CoSettings/fields.inc | 24 ++++++- 16 files changed, 339 insertions(+), 15 deletions(-) diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 483044454..86ea747fe 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -119,8 +119,6 @@ }, "co_settings": { - "comment": "Table definition not yet complete (CFM-80)", - "columns": { "id": {}, "co_id": {}, @@ -142,7 +140,10 @@ "search_global_limited_models": { "type": "boolean" }, "person_picker_email_address_type_id": { "type": "integer" }, "person_picker_identifier_type_id": { "type": "integer" }, - "person_picker_display_types": { "type": "boolean" } + "person_picker_display_types": { "type": "boolean" }, + "platform_env_mfa": { "type": "string", "size": 80 }, + "platform_env_mfa_value": { "type": "string", "size": 80 }, + "platform_env_mfa_enable_eg": { "type": "boolean" } }, "indexes": { "co_settings_i1": { "columns": [ "co_id" ]}, diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po index 25976181c..635bd06b6 100644 --- a/app/resources/locales/en_US/command.po +++ b/app/resources/locales/en_US/command.po @@ -69,6 +69,9 @@ msgstr "Launching queue runner {0} (of {1}) for CO {2}" msgid "job.run.waiting" msgstr "{0,plural,=1{Waiting for the last queue runner to complete} other{Waiting for # queue runners to complete}}" +msgid "mfa.reset.done" +msgstr "Removed MFA configuration for access to Registry" + msgid "opt.admin-family-name" msgstr "Family Name of initial platform administrator" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 5aa881643..99a9effe2 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -195,6 +195,9 @@ msgstr "Owners" msgid "GroupTypeEnum.S" msgstr "Standard" +msgid "GroupTypeEnum.2X" +msgstr "MFA Exempt" + msgid "GroupedAddressFieldsEnum.street,room" msgstr "Street, Room" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 5ec0e79b6..56d7fb893 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -423,6 +423,24 @@ msgstr "This determines what fields to display alongside the person name when us msgid "CoSettings.person_picker_display_types" msgstr "Display field types in the picker" +msgid "CoSettings.platform_env_mfa" +msgstr "MFA Assertion Indicator" + +msgid "CoSettings.platform_env_mfa.desc" +msgstr "Name of the Environment Variable that indicates if MFA was asserted at login; if set, MFA must be asserted for most access to Registry" + +msgid "CoSettings.platform_env_mfa_enable_eg" +msgstr "Enable MFA Exemption Groups" + +msgid "CoSettings.platform_env_mfa_enable_eg.desc" +msgstr "If enabled, People placed in each CO's MFA Exemption Group will not require MFA when logging into Registry" + +msgid "CoSettings.platform_env_mfa_value" +msgstr "MFA Assertion Indicator Value" + +msgid "CoSettings.platform_env_mfa_value.desc" +msgstr "Expected value of the Environment Variable to indicate that MFA as asserted at login" + msgid "CoSettings.required_fields_address" msgstr "Address Required Fields" @@ -558,6 +576,9 @@ msgstr "{0} Members" msgid "Groups.desc.members.active" msgstr "{0} Active Members" +msgid "Groups.desc.mfaexempt" +msgstr "{0} MFA Exemptions" + msgid "Groups.group_type" msgstr "Group Type" @@ -731,6 +752,15 @@ msgstr "Default Error Landing Page" msgid "MostlyStaticPages.default.el.body" msgstr "An unexpected error occurred. Please contact your administrator for further assistance." +msgid "MostlyStaticPages.default.mr.title" +msgstr "MFA Required" + +msgid "MostlyStaticPages.default.mr.description" +msgstr "MFA Not Provided Default Landing Page" + +msgid "MostlyStaticPages.default.mr.body" +msgstr "MFA is required to access Registry, but was not signaled by your login provider. Please contact your administrator for further assistance." + msgid "MostlyStaticPages.default.pc.title" msgstr "Enrollment Complete" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index f91d17a3e..ba84190c5 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -177,11 +177,11 @@ msgstr "Current version: {0}" msgid "ug.version.target" msgstr "Target version: {0}" -msgid "ug.tasks.createApproverGroups.co" -msgstr "Creating Approver Groups for CO {0}" +msgid "ug.tasks.createDefaultGroups.co" +msgstr "Creating new Default Groups for CO {0}" -msgid "ug.tasks.createApproverGroups.cou" -msgstr "Creating Approver Groups for COU {0}" +msgid "ug.tasks.createDefaultGroups.cou" +msgstr "Creating new Default Groups for COU {0}" msgid "ug.tasks.installMostlyStaticPages.co" msgstr "Installing default Mostly Static Pages for CO {0}" diff --git a/app/src/Command/UpgradeCommand.php b/app/src/Command/UpgradeCommand.php index 6dbc461a2..e341dd8e9 100644 --- a/app/src/Command/UpgradeCommand.php +++ b/app/src/Command/UpgradeCommand.php @@ -70,7 +70,7 @@ class UpgradeCommand extends Command ], "5.2.0" => [ 'block' => false, - 'post' => ['createApproverGroups'] + 'post' => ['createDefaultGroups', 'installMostlyStaticPages'] ] ]; @@ -78,7 +78,7 @@ class UpgradeCommand extends Command // to make them easier to use regardless of context (pre/post/manual). protected $taskParams = [ - 'createApproverGroups' => ['perCO' => true, 'perCOU' => true], + 'createDefaultGroups' => ['perCO' => true, 'perCOU' => true], 'installMostlyStaticPages' => ['perCO' => true] ]; @@ -325,14 +325,14 @@ protected function dispatch(string $task) { } /** - * Create Approver Groups. + * Create Approver and MFA Exemption Groups. * * @since COmanage Registry v5.2.0 * @param int $coId CO ID * @param int $couId COU ID */ - protected function createApproverGroups(int $coId, int $couId=null) { + protected function createDefaultGroups(int $coId, int $couId=null) { $GroupsTable = $this->getTableLocator()->get('Groups'); // Technically this will try to add all the default Groups, which is fine since diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 2efcf0452..975c34ec8 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -138,6 +138,63 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { if(isset($this->RegistryAuth)) { // Components might not be loaded on error, so check + // We check for MFA Indicators here since we need to do this before + // each page load, since different pages may have different requirements. + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $mfaConfig = $CoSettings->getMfaIndicator(); + + if(!empty($mfaConfig['indicator']) && !empty($mfaConfig['value'])) { + $this->llog('trace', "MFA is required for access to Registry"); + + if(!method_exists($this, "skipMfa") + || !$this->skipMfa($this->request->getParam('action'))) { + // MFA is required for access to Registry, and is not skipped for this + // action, so check for it and throw an error if not asserted + + if(getenv($mfaConfig['indicator']) !== $mfaConfig['value']) { + $this->llog('trace', "MFA indicator was not found"); + + $exempt = false; + + if($mfaConfig['exempt_groups']) { + // If MFA exemption groups are enabled, figure out the MFA Exemption Group + // for the CO associated with the current request and then see if the + // Person ID associated with that CO is in that Group. + + if($this->getCOID() !== null) { + $Groups = $CoSettings = TableRegistry::getTableLocator()->get('Groups'); + + $groupId = $Groups->getMfaExemptGroupId($this->getCOID()); + $personId = $this->RegistryAuth->getPersonID($this->getCOID()); + + if($personId && $groupId) { + $exempt = $Groups->GroupMembers->isMember( + groupId: $groupId, + personId: $personId + ); + + if($exempt) { + $this->llog('trace', "Person $personId is exempt from MFA via Group $groupId"); + } + } + // else we might (eg) have a Platform Admin trying to access a CO specific page + } + } + + if(!$exempt) { + // We redirect to the CO's Mostly Static Page if we can figure out which CO, + // otherwise we default to the Platform one. + + return $this->redirect("/" . ($this->getCOID() ?? $mfaConfig['comanage_co_id']) . "/mfa-required"); + } + } + } else { + $this->llog('trace', "MFA check is skipped for this request (" . $this->name . ")"); + } + } + // We need to populate this in beforeFilter (rather than beforeRender) // so it's available to CosController::select $this->populateAvailableCos(); diff --git a/app/src/Controller/EnrollmentFlowsController.php b/app/src/Controller/EnrollmentFlowsController.php index 95c4a4ad2..d0a044b73 100644 --- a/app/src/Controller/EnrollmentFlowsController.php +++ b/app/src/Controller/EnrollmentFlowsController.php @@ -129,6 +129,39 @@ public function copy(string $id) { return $this->generateRedirect(null); } + /** + * Determine if MFA, if otherwise required, is not required for this action. + * + * @since COmanage Registry v5.2.0 + * @param string $action Controller action + * @return bool true if MFA can be skipped, false otherwise + */ + + public function skipMfa(string $action): bool { + if($action == 'start') { + // We allow unregistered identities (any actor type other than "person") + // to skip MFA because they might not have MFA until they finish enrollment. + // (This is similar to PetitionsController::dispatch().) Note that since this + // is the 'start' call we don't yet have a Petition ID. + + $actorInfo = $this->getCurrentActor(); + + if(!empty($actorInfo['type']) && $actorInfo['type'] != 'person') { + // This implies that someone who is registered on the platform but NOT enrolled + // in the current CO (including, potentially, Platform Admins, will be exempt from MFA). + // This is probably ok since these people presumably don't have any authorization, + // and if anything are trying to run an enrollment. One exception is Platform Admins, + // so we check for that specifically. + + if(!in_array('cmpadmin', $actorInfo['roles'])) { + return true; + } + } + } + + return false; + } + /** * Start an Enrollment Flow. * diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 240421c8f..0eced16af 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -147,6 +147,18 @@ public function show(string $coid, string $name) { return $this->render('/MostlyStaticPages/display'); } + /** + * Determine if MFA, if otherwise required, is not required for this action. + * + * @since COmanage Registry v5.2.0 + * @param string $action Controller action + * @return bool true if MFA can be skipped, false otherwise + */ + + public function skipMfa(string $action): bool { + return in_array($action, ['display', 'show']); + } + /** * Indicate whether this Controller will handle some or all authnz. * diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php index c3ff383dc..90f129c35 100644 --- a/app/src/Controller/PetitionsController.php +++ b/app/src/Controller/PetitionsController.php @@ -379,6 +379,36 @@ public function resume(string $id) { } } + /** + * Determine if MFA, if otherwise required, is not required for this action. + * + * @since COmanage Registry v5.2.0 + * @param string $action Controller action + * @return bool true if MFA can be skipped, false otherwise + */ + + public function skipMfa(string $action): bool { + if($action == 'dispatch') { + // We allow unregistered identities (any actor type other than "person") + // to skip MFA because they might not have MFA until they finish enrollment. + // However, we want to enforce MFA for registered People who might (eg) be + // approving a Petition. (There is a similar check in EnrollmentFlowsController::start().) + // We call getActorInfo without a Petition ID because we only need the person type. + + $actorInfo = $this->getCurrentActor(); + + if(!empty($actorInfo['type']) && $actorInfo['type'] != 'person') { + // Perform the same check for Platform admins as PetitionsController. + + if(!in_array('cmpadmin', $actorInfo['roles'])) { + return true; + } + } + } + + return false; + } + /** * Terminate an in-progress Petition. * diff --git a/app/src/Lib/Enum/GroupTypeEnum.php b/app/src/Lib/Enum/GroupTypeEnum.php index 02b719d85..a02ab34ce 100644 --- a/app/src/Lib/Enum/GroupTypeEnum.php +++ b/app/src/Lib/Enum/GroupTypeEnum.php @@ -35,6 +35,7 @@ class GroupTypeEnum extends StandardEnum { const AllMembers = 'M'; // Note that other groups can be used for Approval, this is the _default_ group for the CO/COU const Approvers = 'AP'; + const MfaExempt = '2X'; const Owners = 'O'; const Standard = 'S'; } \ No newline at end of file diff --git a/app/src/Model/Entity/MostlyStaticPage.php b/app/src/Model/Entity/MostlyStaticPage.php index 79061e64b..055e90451 100644 --- a/app/src/Model/Entity/MostlyStaticPage.php +++ b/app/src/Model/Entity/MostlyStaticPage.php @@ -40,6 +40,19 @@ class MostlyStaticPage extends Entity { 'slug' => false, ]; + /** + * Determine if this entity record can be deleted. + * + * @since COmanage Registry v5.2.0 + * @return bool True if the record can be deleted, false otherwise + */ + + public function canDelete(): bool { + // AR-MostlyStaticPage-3 Default Pages can not be deleted, or have their names, status, + // or context changed. + return !$this->isDefaultPage(); + } + /** * Determine if this entity is a default Page (shipped out of the box and relied on by other * parts of the Application). @@ -55,6 +68,7 @@ public function isDefaultPage(): bool { 'default-handoff', 'duplicate-landing', 'error-landing', + 'mfa-required', 'petition-complete' ]); } diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php index 875da642e..3d7680659 100644 --- a/app/src/Model/Table/CoSettingsTable.php +++ b/app/src/Model/Table/CoSettingsTable.php @@ -55,6 +55,7 @@ class CoSettingsTable extends Table { use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; + use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -127,6 +128,8 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setAllowUnkeyedPrimaryCO(['manage']); $this->setRedirectGoal('self'); + + $this->setEditContains(['Cos']); $this->setAutoViewVars([ 'defaultAddressTypes' => [ @@ -288,6 +291,33 @@ public function generateDisplayField(\App\Model\Entity\CoSetting $entity): strin return __d('controller', 'CoSettings', [99]); } + /** + * Get the MFA Indicator configuration. + * + * @since COmanage Registry v5.2.0 + * @return array|false Arrof of configuration data if MFA is required, false otherwise + */ + + public function getMfaIndicator(): array|false { + // The MFA Indicator only applies to the COmanage CO. + + $COmanageCO = $this->Cos->find('COmanageCO')->firstOrFail(); + + $settings = $this->find()->where(['co_id' => $COmanageCO->id])->firstOrFail(); + + if(!empty($settings->platform_env_mfa) && !ctype_space($settings->platform_env_mfa)) { + return [ + 'indicator' => $settings->platform_env_mfa, + 'value' => $settings->platform_env_mfa_value, + 'exempt_groups' => $settings->platform_env_mfa_enable_eg ?? false, + // We return the COmanage CO ID so AppController doesn't have to look it up again + 'comanage_co_id' => $COmanageCO->id + ]; + } + + return false; + } + /** * Get the outgoing SMTP Server for the specified CO. * @@ -322,6 +352,29 @@ public function getSmtpServer(int $coId): ?\CoreServer\Model\Entity\SmtpServer { return null; } + /** + * Reset (disable) the MFA requirement. + * + * @since COmanage Registry v5.2.0 + * @return bool true on success + */ + + public function resetMfaIndicator(): bool { + // The MFA Indicator only applies to the COmanage CO. + + $COmanageCO = $this->Cos->find('COmanageCO')->firstOrFail(); + + $settings = $this->find()->where(['co_id' => $COmanageCO->id])->firstOrFail(); + + $settings->platform_env_mfa = null; + $settings->platform_env_mfa_value = null; + $settings->platform_env_mfa_enable_eg = false; + + $this->saveOrFail($settings); + + return true; + } + /** * Determine if a requested Type is in use as a default via CoSettings. * @@ -357,6 +410,8 @@ public function typeIsDefault(int $id): bool { */ public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + $validator->add('default_address_type_id', [ 'content' => ['rule' => 'isInteger'] ]); @@ -452,6 +507,19 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('search_global_limit'); + // "platform_" prefixed fields are intended to be available in the COmanage CO only. + // We do this rather than create a separate table (like "meta") to leverage the existing + // infrastructure and not have to fight Cake to maintain a table with a single row. + + $this->registerStringValidation($validator, $schema, 'platform_env_mfa', false); + + $this->registerStringValidation($validator, $schema, 'platform_env_mfa_value', false); + + $validator->add('platform_env_mfa_enable_eg', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('platform_env_mfa_enable_eg'); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index 73304dcb2..d66ff20a4 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -315,8 +315,21 @@ public function addDefaults(int $coId, int $couId=null, bool $rename=false): boo 'open' => false, 'status' => SuspendableStatusEnum::Active, 'cou_id' => ($couId ?: null) - ], + ] ]; + + if(!$couId) { + // Registry MFA Exempt group only exists at the CO level + + $defaultGroups[':mfaexempt'] = [ + 'group_type' => GroupTypeEnum::MfaExempt, + 'auto' => false, + 'description' => __d('field', 'Groups.desc.mfaexempt', [$couName]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => null + ]; + }; foreach($defaultGroups as $suffix => $attrs) { // Construct the full group name @@ -502,6 +515,24 @@ public function findAdminGroup(Query $query, array $options): Query { ]); } + /** + * Find a CO's MFA Exemption Group. + * + * @since COmanage Registry v5.2.0 + * @param \Cake\ORM\Query $query Query + * @param array $options Options: co_id (required) + * @return \Cake\ORM\Query Query + */ + + public function findMfaExemptGroup(Query $query, array $options): Query { + return $query->where([ + 'co_id' => $options['co_id'], + 'cou_id IS' => null, + 'status' => SuspendableStatusEnum::Active, + 'group_type' => GroupTypeEnum::MfaExempt + ]); + } + /** * Get the Admin Group for a CO. * @@ -516,6 +547,20 @@ public function getAdminGroupId(int $coId): int { return $g->id; } + /** + * Get the MFA Exemption Group for a CO. + * + * @since COmanage Registry v5.2.0 + * @param int $coId CO ID + * @return int Group ID + */ + + public function getMfaExemptGroupId(int $coId): int { + $g = $this->find('mfaExemptGroup', ['co_id' => $coId])->firstOrFail(); + + return $g->id; + } + /** * Get the Approvers Group for a CO or COU. * diff --git a/app/src/Model/Table/MostlyStaticPagesTable.php b/app/src/Model/Table/MostlyStaticPagesTable.php index b89e90d32..af7b50b6d 100644 --- a/app/src/Model/Table/MostlyStaticPagesTable.php +++ b/app/src/Model/Table/MostlyStaticPagesTable.php @@ -136,6 +136,15 @@ public function addDefaults(int $coId) { 'context' => PageContextEnum::ErrorLanding, 'body' => __d('field', 'MostlyStaticPages.default.el.body') ], + [ + 'co_id' => $coId, + 'name' => 'mfa-required', + 'title' => __d('field', 'MostlyStaticPages.default.mr.title'), + 'description' => __d('field', 'MostlyStaticPages.default.mr.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::ErrorLanding, + 'body' => __d('field', 'MostlyStaticPages.default.mr.body') + ], [ 'co_id' => $coId, 'name' => 'petition-complete', diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc index 09b7b7d14..4b51f4165 100644 --- a/app/templates/CoSettings/fields.inc +++ b/app/templates/CoSettings/fields.inc @@ -24,8 +24,7 @@ * @since COmanage Registry v5.0.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ -?> -element('form/listItem', [ @@ -97,7 +96,7 @@ if($vv_action == 'edit') { 'fieldName' => 'search_global_limited_models' ]]); - print $this->element('form/listItem', [ + print $this->element('form/listItem', [ 'arguments' => [ 'fieldName' => 'person_picker_display_fields', 'labelIsTextOnly' => true, @@ -138,4 +137,23 @@ if($vv_action == 'edit') { 'arguments' => [ 'fieldName' => 'authn_events_api_disable' ]]); + + if($vv_obj->co->isCOmanageCO()) { + // Fields prefixed platform_ are only available within the COmanage CO + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'platform_env_mfa' + ]]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'platform_env_mfa_value' + ]]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'platform_env_mfa_enable_eg' + ]]); + } }