From 9305e7ceeae3bd3e4157556f1c22abe8610102fc Mon Sep 17 00:00:00 2001 From: iglocska Date: Tue, 31 Oct 2023 14:54:08 +0100 Subject: [PATCH] chg: [wip] sharing group rework / MISP connector improvements --- config/Migrations/20231012000007_SGExtend.php | 34 ++ src/Controller/SharingGroupsController.php | 20 +- .../CommonConnectorTools.php | 15 + .../local_tool_connectors/MispConnector.php | 330 ++++++++++++++++-- src/Model/Entity/SGO.php | 11 + src/Model/Table/InstanceTable.php | 16 +- src/Model/Table/LocalToolsTable.php | 23 +- src/Model/Table/SGOsTable.php | 53 +++ src/Model/Table/SharingGroupsTable.php | 13 +- templates/SharingGroups/add_org.php | 5 + templates/SharingGroups/list_orgs.php | 7 + 11 files changed, 481 insertions(+), 46 deletions(-) create mode 100644 config/Migrations/20231012000007_SGExtend.php create mode 100644 src/Model/Entity/SGO.php create mode 100644 src/Model/Table/SGOsTable.php diff --git a/config/Migrations/20231012000007_SGExtend.php b/config/Migrations/20231012000007_SGExtend.php new file mode 100644 index 0000000..b49bdba --- /dev/null +++ b/config/Migrations/20231012000007_SGExtend.php @@ -0,0 +1,34 @@ +table('sgo'); + $exists = $table->hasColumn('extend'); + if (!$exists) { + $table + ->addColumn('extend', 'boolean', [ + 'default' => 0, + 'null' => false, + ])->update(); + } + } +} diff --git a/src/Controller/SharingGroupsController.php b/src/Controller/SharingGroupsController.php index 8afd6c6..56f9a52 100644 --- a/src/Controller/SharingGroupsController.php +++ b/src/Controller/SharingGroupsController.php @@ -8,6 +8,7 @@ use Cake\Utility\Text; use \Cake\Database\Expression\QueryExpression; use Cake\Error\Debugger; use Cake\Http\Exception\NotFoundException; +use Cake\ORM\TableRegistry; class SharingGroupsController extends AppController { @@ -171,9 +172,13 @@ class SharingGroupsController extends AppController $input['organisation_id'] = [$input['organisation_id']]; } $result = true; + $this->SGO = TableRegistry::getTableLocator()->get('SGOs'); foreach ($input['organisation_id'] as $org_id) { - $org = $this->SharingGroups->SharingGroupOrgs->get($org_id); - $result &= (bool)$this->SharingGroups->SharingGroupOrgs->link($sharingGroup, [$org]); + $additional_data = []; + if (!empty($input['extend'])) { + $additional_data['extend'] = $input['extend']; + } + $result &= $this->SGO->attach($sharingGroup['id'], $org_id, $additional_data); } if ($result) { $message = __('Organisation(s) added to the sharing group.'); @@ -216,8 +221,8 @@ class SharingGroupsController extends AppController throw new NotFoundException(__('Invalid SharingGroup.')); } if ($this->request->is('post')) { - $org = $this->SharingGroups->SharingGroupOrgs->get($org_id); - $result = (bool)$this->SharingGroups->SharingGroupOrgs->unlink($sharingGroup, [$org]); + $this->SGO = TableRegistry::getTableLocator()->get('SGOs'); + $result = (bool)$this->SharingGroups->SharingGroupOrgs->unlink($sharingGroup['id'], $org_id); if ($result) { $message = __('Organisation(s) removed from the sharing group.'); } else { @@ -253,9 +258,10 @@ class SharingGroupsController extends AppController public function listOrgs($id) { - $sharingGroup = $this->SharingGroups->get($id, [ - 'contain' => 'SharingGroupOrgs' - ]); + $sharingGroup = $this->SharingGroups->find()->where(['id' => $id])->contain(['SharingGroupOrgs'])->first(); + foreach ($sharingGroup['sharing_group_orgs'] as $k => $org) { + $sharingGroup['sharing_group_orgs'][$k]['extend'] = $org['_joinData']['extend']; + } $params = $this->ParamHandler->harvestParams(['quickFilter']); if (!empty($params['quickFilter'])) { foreach ($sharingGroup['sharing_group_orgs'] as $k => $org) { diff --git a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php index cacd34c..6bc7f7b 100644 --- a/src/Lib/default/local_tool_connectors/CommonConnectorTools.php +++ b/src/Lib/default/local_tool_connectors/CommonConnectorTools.php @@ -104,6 +104,16 @@ class CommonConnectorTools return $orgs; } + public function getSharingGroups(): array + { + $sgs = \Cake\ORM\TableRegistry::getTableLocator()->get('SharingGroups'); + $sgs = $sgs->find() + ->contain(['Organisations' => ['fields' => ['uuid']], 'SharingGroupOrgs' => ['fields' => ['uuid']]]) + ->disableHydration() + ->toArray(); + return $sgs; + } + public function getFilteredOrganisations($filters, $returnObjects = false): array { $organisations = \Cake\ORM\TableRegistry::getTableLocator()->get('Organisations'); @@ -219,6 +229,11 @@ class CommonConnectorTools $this->remoteToolConnectionStatus($params, self::STATE_CONNECTED); return false; } + + public function diagnostics(array $params): array + { + return []; + } } ?> diff --git a/src/Lib/default/local_tool_connectors/MispConnector.php b/src/Lib/default/local_tool_connectors/MispConnector.php index 94f89e9..e6ac7c5 100644 --- a/src/Lib/default/local_tool_connectors/MispConnector.php +++ b/src/Lib/default/local_tool_connectors/MispConnector.php @@ -15,6 +15,12 @@ class MispConnector extends CommonConnectorTools public $name = 'MISP'; public $exposedFunctions = [ + 'diagnosticsAction' => [ + 'type' => 'index', + 'scope' => 'child', + 'params' => [ + ] + ], 'serverSettingsAction' => [ 'type' => 'index', 'scope' => 'child', @@ -48,6 +54,11 @@ class MispConnector extends CommonConnectorTools 'direction' ] ], + 'restartWorkersAction' => [ + 'type' => 'formAction', + 'scope' => 'childAction', + 'redirect' => 'diagnosticsAction' + ], 'fetchOrganisationAction' => [ 'type' => 'formAction', 'scope' => 'childAction', @@ -132,7 +143,7 @@ class MispConnector extends CommonConnectorTools 'icon' => 'terminal', 'variant' => 'primary', ] - ] + ], ]; public $version = '0.1'; public $settings = [ @@ -261,7 +272,7 @@ class MispConnector extends CommonConnectorTools if (!empty($params['softError'])) { return $response; } - $errorMsg = __('Could not post to the requested resource for `{0}`. Remote returned:', $url) . PHP_EOL . $response->getStringBody(); + $errorMsg = __('Could not GET from the requested resource for `{0}`. Remote returned:', $url) . PHP_EOL . $response->getStringBody(); $this->logError($errorMsg); throw new NotFoundException($errorMsg); } @@ -312,10 +323,183 @@ class MispConnector extends CommonConnectorTools return $url; } + public function diagnostics(array $params): array + { + $urlParams = h($params['connection']['id']) . '/serverSettingsAction'; + $response = $this->getData('/servers/serverSettings', $params); + if (!$response->isOk()) { + return []; + } + $data = $response->getJson(); + $issues = []; + if ($data['version']['upToDate'] !== 'same') { + $issues['version'] = [ + 'type' => 'danger', + 'message' => __('Outdated ({0}).', $data['version']['current']), + 'remediation' => [ + 'icon' => 'fa-sync', + 'title' => __('Update MISP'), + 'url' => '/localTools/action/' . h($params['connection']['id']) . '/updateMISP' + ] + ]; + } + if ($data['phpSettings']['memory_limit']['value'] < $data['phpSettings']['memory_limit']['recommended']) { + $issues['php_memory'] = [ + 'type' => 'warning', + 'message' => __('Low PHP memory ({0}M).', $data['phpSettings']['memory_limit']['value']) + ]; + } + $worker_issues = []; + foreach ($data['workers'] as $queue => $worker_data) { + if (in_array($queue, ['proc_accessible', 'scheduler', 'controls'])) { + continue; + } + if (empty($worker_data['ok'])) { + $worker_issues['down'][] = $queue; + } + if ($worker_data['jobCount'] > 100) { + $worker_issues['stalled'][] = $queue; + } + } + if (!empty($worker_issues['down'])) { + $issues['workers_down'] = [ + 'type' => 'danger', + 'message' => __('Worker(s) down: {0}', implode(', ', $worker_issues['down'])), + 'remediation' => [ + 'icon' => 'fa-sync', + 'title' => __('Restart workers'), + 'url' => '/localTools/action/' . h($params['connection']['id']) . '/restartWorkersAction' + ] + ]; + } + + if (!empty($worker_issues['stalled'])) { + $issues['workers_stalled'] = [ + 'type' => 'warning', + 'message' => __('Worker(s) stalled: {0}', implode(', ', $worker_issues['stalled'])), + 'remediation' => [ + 'icon' => 'fa-sync', + 'title' => __('Restart workers'), + 'url' => '/localTools/action/' . h($params['connection']['id']) . '/restartWorkersAction' + ] + ]; + } + if (!empty($data['dbConfiguration'])) { + foreach ($data['dbConfiguration'] as $dbConfig) { + if ($dbConfig['name'] === 'innodb_buffer_pool_size' && $dbConfig['value'] < $dbConfig['recommended']) { + $issues['innodb_buffer_pool_size'] = [ + 'type' => 'warning', + 'message' => __('InnoDB buffer pool size is low ({0}M).', (round($dbConfig['value']/1024/1024))) + ]; + } + } + } + if (!empty($data['dbSchemaDiagnostics'])) { + if ($data['dbSchemaDiagnostics']['expected_db_version'] > $data['dbSchemaDiagnostics']['actual_db_version']) + $issues['schema_version'] = [ + 'type' => 'danger', + 'message' => __('DB schame outdated.'), + 'remediation' => [ + 'icon' => 'fa-sync', + 'title' => __('Update DB schema'), + 'url' => '/localTools/action/' . h($params['connection']['id']) . '/updateSchemaAction' + ] + ]; + } + return $issues; + } + + public function restartWorkersAction(array $params): array + { + if ($params['request']->is(['get'])) { + return [ + 'data' => [ + 'title' => __('Restart workers'), + 'description' => __('Would you like to trigger a restart of all attached workers?'), + 'submit' => [ + 'action' => $params['request']->getParam('action') + ], + 'url' => ['controller' => 'localTools', 'action' => 'action', $params['connection']['id'], 'restartWorkersAction'] + ] + ]; + } elseif ($params['request']->is(['post'])) { + $response = $this->postData('/servers/restartWorkers', $params); + if ($response->isOk()) { + return ['success' => 1, 'message' => __('Workers restarted.')]; + } else { + return ['success' => 0, 'message' => __('Could not restart workers.')]; + } + } + + + + $response = $this->postData('/servers/restartWorkers', $params); + if ($response->isOk()) { + return [ + 'type' => 'success', + 'message' => __('Workers restarted.') + ]; + } else { + return [ + 'type' => 'danger', + 'message' => __('Something went wrong.') + ]; + } + } + public function diagnosticsAction(array $params): array { - + $diagnostics = $this->diagnostics($params); + $data = []; + foreach ($diagnostics as $error => $error_data) { + $data[] = [ + 'error' => $error, + 'type' => $error_data['type'], + 'message' => $error_data['message'], + 'remediation' => $error_data['remediation'] ?? false + ]; + } + return [ + 'type' => 'index', + 'data' => [ + 'data' => $data, + 'skip_pagination' => 1, + 'top_bar' => [ + 'children' => [] + ], + 'fields' => [ + [ + 'name' => 'error', + 'data_path' => 'error', + 'name' => __('Error'), + ], + [ + 'name' => 'message', + 'data_path' => 'message', + 'name' => __('Message'), + ], + [ + 'name' => __('Remediation'), + 'element' => 'function', + 'function' => function($row, $context) { + $remediation = $context->Hash->extract($row, 'remediation'); + if (!empty($remediation['title'])) { + echo sprintf( + '', + h($remediation['url']), + h($remediation['title']), + h($remediation['icon']) + ); + } + } + ] + ], + 'title' => false, + 'description' => false, + 'pull' => 'right' + ] + ]; } public function serverSettingsAction(array $params): array @@ -576,6 +760,56 @@ class MispConnector extends CommonConnectorTools } } + private function __compareOrgs(array $data, array $existingOrgs): array + { + foreach ($data as $k => $v) { + $data[$k]['Organisation']['local_copy'] = false; + if (!empty($existingOrgs[$v['Organisation']['uuid']])) { + $remoteOrg = $existingOrgs[$v['Organisation']['uuid']]; + $localOrg = $v['Organisation']; + $same = true; + $fieldsToCheck = [ + 'nationality', 'sector', 'type', 'name' + ]; + foreach (['nationality', 'sector', 'type', 'name'] as $fieldToCheck) { + if ($remoteOrg[$fieldToCheck] != $localOrg[$fieldToCheck]) { + $same = false; + } + } + $data[$k]['Organisation']['local_copy'] = $same ? 'same' : 'different'; + } else { + $data[$k]['Organisation']['local_copy'] = 'not_found'; + } + } + return $data; + } + + private function __compareSgs(array $data, array $existingSgs): array + { + debug($data); + debug($existingSgs); + foreach ($data as $k => $v) { + $data[$k]['SharingGroup']['local_copy'] = false; + if (!empty($existingOrgs[$v['SharingGroup']['uuid']])) { + $remoteOrg = $existingOrgs[$v['SharingGroup']['uuid']]; + $localOrg = $v['SharingGroup']; + $same = true; + $fieldsToCheck = [ + 'nationality', 'sector', 'type', 'name' + ]; + foreach (['nationality', 'sector', 'type', 'name'] as $fieldToCheck) { + if ($remoteOrg[$fieldToCheck] != $localOrg[$fieldToCheck]) { + $same = false; + } + } + $data[$k]['SharingGroup']['local_copy'] = $same ? 'same' : 'different'; + } else { + $data[$k]['SharingGroup']['local_copy'] = 'not_found'; + } + } + return $data; + } + public function organisationsAction(array $params): array { @@ -610,25 +844,7 @@ class MispConnector extends CommonConnectorTools 'icon' => 'exclamation-triangle' ] ]; - foreach ($data as $k => $v) { - $data[$k]['Organisation']['local_copy'] = false; - if (!empty($existingOrgs[$v['Organisation']['uuid']])) { - $remoteOrg = $existingOrgs[$v['Organisation']['uuid']]; - $localOrg = $v['Organisation']; - $same = true; - $fieldsToCheck = [ - 'nationality', 'sector', 'type', 'name' - ]; - foreach (['nationality', 'sector', 'type', 'name'] as $fieldToCheck) { - if ($remoteOrg[$fieldToCheck] != $localOrg[$fieldToCheck]) { - $same = false; - } - } - $data[$k]['Organisation']['local_copy'] = $same ? 'same' : 'different'; - } else { - $data[$k]['Organisation']['local_copy'] = 'not_found'; - } - } + $data = $this->__compareOrgs($data, $existingOrgs); if (!empty($data)) { return [ 'type' => 'index', @@ -764,6 +980,35 @@ class MispConnector extends CommonConnectorTools $urlParams = h($params['connection']['id']) . '/sharingGroupsAction'; $response = $this->getData('/sharing_groups/index', $params); $data = $response->getJson(); + $temp = $this->getSharingGroups(); + $existingOrgs = []; + foreach ($temp as $k => $v) { + $existingSGs[$v['uuid']] = $v; + unset($temp[$k]); + } + $data = $this->__compareSgs($data, $existingSGs); + $existingSGs = []; + foreach ($temp as $k => $v) { + $existingSGs[$v['uuid']] = $v; + unset($temp[$k]); + } + $statusLevels = [ + 'same' => [ + 'colour' => 'success', + 'message' => __('Remote sharing group is the same as local copy'), + 'icon' => 'check-circle' + ], + 'different' => [ + 'colour' => 'warning', + 'message' => __('Local and remote versions of the sharing groups are different.'), + 'icon' => 'exclamation-circle' + ], + 'not_found' => [ + 'colour' => 'danger', + 'message' => __('Local sharing group not found'), + 'icon' => 'exclamation-triangle' + ] + ]; if (!empty($data)) { return [ 'type' => 'index', @@ -773,21 +1018,48 @@ class MispConnector extends CommonConnectorTools 'top_bar' => [ 'children' => [ [ - 'type' => 'search', - 'button' => __('Search'), - 'placeholder' => __('Enter value to search'), - 'data' => '', - 'searchKey' => 'value', - 'additionalUrlParams' => $urlParams - ] + 'type' => 'simple', + 'children' => [ + [ + 'class' => 'hidden mass-select', + 'text' => __('Fetch selected sharing groups'), + 'html' => ' ', + 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/sharingGroupsAction', + 'popover_url' => '/localTools/action/' . h($params['connection']['id']) . '/fetchSelectedSharingGroupsAction' + ], + [ + 'text' => __('Push sharing groups'), + 'html' => ' ', + 'class' => 'btn btn-primary', + 'reload_url' => '/localTools/action/' . h($params['connection']['id']) . '/SharingGroupsAction', + 'popover_url' => '/localTools/action/' . h($params['connection']['id']) . '/pushSharingGroupsAction' + ] + ] + ], ] ], 'fields' => [ + [ + 'element' => 'selector', + 'class' => 'short', + 'data' => [ + 'id' => [ + 'value_path' => 'SharingGroup.uuid' + ] + ] + ], [ 'name' => 'Name', 'sort' => 'SharingGroup.name', 'data_path' => 'SharingGroup.name', ], + [ + 'name' => 'Status', + 'sort' => 'SharingGroup.local_copy', + 'data_path' => 'SharingGroup.local_copy', + 'element' => 'status', + 'status_levels' => $statusLevels + ], [ 'name' => 'uuid', 'sort' => 'SharingGroup.uuid', diff --git a/src/Model/Entity/SGO.php b/src/Model/Entity/SGO.php new file mode 100644 index 0000000..39d900b --- /dev/null +++ b/src/Model/Entity/SGO.php @@ -0,0 +1,11 @@ + $broods, - 'tools' => $LocalToolsModel->extractMeta($connectors, true) + 'tools' => $connections ]; if ($mermaid) { return $this->generateTopologyMermaid($data); @@ -319,8 +319,19 @@ class InstanceTable extends AppTable h($tool['name']) ); foreach ($tool['connections'] as $k2 => $connection) { + $diagnostic_output = ''; + if (!empty($connection['diagnostics'])) { + foreach ($connection['diagnostics'] as $diagnostic => $diagnostic_data) { + $diagnostic_output .= sprintf( + "%s: %s
", + h($diagnostic), + h($diagnostic_data['type']), + h($diagnostic_data['message']) + ); + } + } $tools .= sprintf( - " connection%s[%s
%s
%s]" . PHP_EOL, + " connection%s[\"%s
%s
%s%s\"]" . PHP_EOL, h($k2), h($connection['name']), sprintf( @@ -329,6 +340,7 @@ class InstanceTable extends AppTable $connection['health'] === 1 ? 'text-success' : 'text-danger', $connection['health'] === 1 ? 'fas:fa-check' : 'fas:fa-times' ), + empty($diagnostic_data) ? '' : 'Diagnostics:
' . $diagnostic_output, sprintf( "fas:fa-eye", h($connection['url']) diff --git a/src/Model/Table/LocalToolsTable.php b/src/Model/Table/LocalToolsTable.php index f764ace..3d66c62 100644 --- a/src/Model/Table/LocalToolsTable.php +++ b/src/Model/Table/LocalToolsTable.php @@ -147,14 +147,14 @@ class LocalToolsTable extends AppTable 'connector_settings_placeholder' => $connector_class->settingsPlaceholder ?? [], ]; if ($includeConnections) { - $connector['connections'] = $this->healthCheck($connector_type, $connector_class); + $connector['connections'] = $this->healthCheck($connector_type, $connector_class, true); } $connectors[] = $connector; } return $connectors; } - public function healthCheck(string $connector_type, Object $connector_class): array + public function healthCheck(string $connector_type, Object $connector_class, bool $includeDiagnostics = false): array { $query = $this->find(); $query->where([ @@ -162,11 +162,28 @@ class LocalToolsTable extends AppTable ]); $connections = $query->all()->toList(); foreach ($connections as &$connection) { - $connection = $this->healthCheckIndividual($connection); + $temp = $this->healthCheckIndividual($connection); + if ($includeDiagnostics && !empty($temp['health']) && $temp['health'] === 1) { + $temp['diagnostics'] = $this->diagnosticCheckIndividual($connection); + } + $connection = $temp; } return $connections; } + public function diagnosticCheckIndividual(Object $connection): array + { + $connector_class = $this->getConnectors($connection->connector); + if (empty($connector_class[$connection->connector])) { + return []; + } + $connector_class = $connector_class[$connection->connector]; + return $connector_class->diagnostics([ + 'connection' => $connection, + 'softError' => 1 + ]); + } + public function healthCheckIndividual(Object $connection): array { $connector_class = $this->getConnectors($connection->connector); diff --git a/src/Model/Table/SGOsTable.php b/src/Model/Table/SGOsTable.php new file mode 100644 index 0000000..e8d05ee --- /dev/null +++ b/src/Model/Table/SGOsTable.php @@ -0,0 +1,53 @@ +setTable('sgo'); + parent::initialize($config); + $this->belongsTo('SharingGroups'); + $this->belongsTo('Organisations'); + } + + public function attach(int $sg_id, int $org_id, array $additional_data = []): bool + { + $sgo = $this->find()->where([ + 'sharing_group_id' => $sg_id, + 'organisation_id' => $org_id + ])->first(); + if (empty($sgo)) { + $sgo = $this->newEmptyEntity(); + $sgo->sharing_group_id = $sg_id; + $sgo->organisation_id = $org_id; + } + $sgo->extend = empty($additional_data['extend']) ? 0 : 1; + if ($this->save($sgo)) { + return true; + } + return false; + + } + + public function detach(): bool + { + $sgo = $this->find()->where([ + 'sharing_group_id' => $sg_id, + 'organisation_id' => $org_id + ])->first(); + if (!empty($sgo)) { + if (!$this->delete($sgo)) { + return false; + } + } + return true; + } +} diff --git a/src/Model/Table/SharingGroupsTable.php b/src/Model/Table/SharingGroupsTable.php index e976e8c..25c7efd 100644 --- a/src/Model/Table/SharingGroupsTable.php +++ b/src/Model/Table/SharingGroupsTable.php @@ -25,11 +25,11 @@ class SharingGroupsTable extends AppTable $this->belongsToMany( 'SharingGroupOrgs', [ + 'through' => 'SGOs', 'className' => 'Organisations', 'foreignKey' => 'sharing_group_id', - 'joinTable' => 'sgo', 'targetForeignKey' => 'organisation_id' - ] + ], ); $this->setDisplayField('name'); } @@ -77,11 +77,14 @@ class SharingGroupsTable extends AppTable public function postCaptureActions($savedEntity, $input): void { - $orgs = []; + $additional_data = []; + if (!empty($input['extend'])) { + $additional_data['extend'] = $input['extend']; + } + $this->SGO = TableRegistry::getTableLocator()->get('SGOs'); foreach ($input['sharing_group_orgs'] as $sgo) { $organisation_id = $this->Organisations->captureOrg($sgo); - $orgs[] = $this->SharingGroupOrgs->get($organisation_id); + $this->SGO->attach($savedEntity->id, $organisation_id, $additional_data); } - $this->SharingGroupOrgs->link($savedEntity, $orgs); } } diff --git a/templates/SharingGroups/add_org.php b/templates/SharingGroups/add_org.php index 79ce801..f5e949c 100644 --- a/templates/SharingGroups/add_org.php +++ b/templates/SharingGroups/add_org.php @@ -9,6 +9,11 @@ 'label' => __('Owner organisation'), 'options' => $dropdownData['organisation'] ], + [ + 'field' => 'extend', + 'type' => 'checkbox', + 'label' => __('Can extend/administer') + ], ], 'submit' => [ 'action' => $this->request->getParam('action') diff --git a/templates/SharingGroups/list_orgs.php b/templates/SharingGroups/list_orgs.php index 267677f..77918ec 100644 --- a/templates/SharingGroups/list_orgs.php +++ b/templates/SharingGroups/list_orgs.php @@ -42,6 +42,13 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'sort' => 'uuid', 'class' => 'short', 'data_path' => 'uuid', + ], + [ + 'name' => __('Can extend/administer'), + 'sort' => 'extend', + 'element' => 'boolean', + 'class' => 'short', + 'data_path' => 'extend', ] ], 'pull' => 'right',