diff --git a/NOTICE b/NOTICE
index 38057cd82..962e0147b 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,6 +1,6 @@
COmanage Registry
-Copyright (C) 2010-2020
+Copyright (C) 2010-2024
University Corporation for Advanced Internet Development, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/app/availableplugins/PipelineToolkit/README.md b/app/availableplugins/PipelineToolkit/README.md
new file mode 100644
index 000000000..fcfac335b
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/README.md
@@ -0,0 +1,11 @@
+# PipelineToolkit plugin for CakePHP
+
+## Installation
+
+You can install this plugin into your CakePHP application using [composer](https://getcomposer.org).
+
+The recommended way to install composer packages is:
+
+```
+composer require your-name-here/pipeline-toolkit
+```
diff --git a/app/availableplugins/PipelineToolkit/composer.json b/app/availableplugins/PipelineToolkit/composer.json
new file mode 100644
index 000000000..3d2ec201f
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "your-name-here/pipeline-toolkit",
+ "description": "PipelineToolkit plugin for CakePHP",
+ "type": "cakephp-plugin",
+ "license": "MIT",
+ "require": {
+ "php": ">=7.2",
+ "cakephp/cakephp": "4.5.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "PipelineToolkit\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "PipelineToolkit\\Test\\": "tests/",
+ "Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
+ }
+ }
+}
diff --git a/app/availableplugins/PipelineToolkit/phpunit.xml.dist b/app/availableplugins/PipelineToolkit/phpunit.xml.dist
new file mode 100644
index 000000000..514653539
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+ tests/TestCase/
+
+
+
+
+
+
+
+
+
+
+ src/
+
+
+
diff --git a/app/availableplugins/PipelineToolkit/resources/locales/en_US/pipeline_toolkit.po b/app/availableplugins/PipelineToolkit/resources/locales/en_US/pipeline_toolkit.po
new file mode 100644
index 000000000..36de5007a
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/resources/locales/en_US/pipeline_toolkit.po
@@ -0,0 +1,50 @@
+# COmanage Registry Localizations (pipeline_toolkit domain)
+#
+# Portions licensed to the University Corporation for Advanced Internet
+# Development, Inc. ("UCAID") under one or more contributor license agreements.
+# See the NOTICE file distributed with this work for additional information
+# regarding copyright ownership.
+#
+# UCAID licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with the
+# License. You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# @link https://www.internet2.edu/comanage COmanage Project
+# @package registry
+# @since COmanage Registry v5.0.0
+# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+
+msgid "controller.PersonRoleMappers"
+msgstr "{0,plural,=1{Person Role Mapper} other{Person Role Mappers}}"
+
+msgid "controller.PersonRoleMappings"
+msgstr "{0,plural,=1{Person Role Mapping} other{Person Role Mappings}}"
+
+msgid "field.PersonRoleMappings.ad_hoc_tag"
+msgstr "Ad Hoc Attribute Tag"
+
+msgid "field.PersonRoleMappings.affiliation_type_id"
+msgstr "Affilation Type"
+
+msgid "field.PersonRoleMappings.comparison"
+msgstr "Comparison"
+
+msgid "field.PersonRoleMappings.pattern"
+msgstr "Pattern"
+
+msgid "field.PersonRoleMappings.pattern.desc"
+msgstr "For regular expressions, including leading and trailing slashes (eg: /foo.*/)"
+
+msgid "field.PersonRoleMappings.target_cou_id"
+msgstr "Target COU"
+
+msgid "field.PersonRoleMappings.target_affiliation_type_id"
+msgstr "Target Affilation Type"
diff --git a/app/availableplugins/PipelineToolkit/src/Controller/AppController.php b/app/availableplugins/PipelineToolkit/src/Controller/AppController.php
new file mode 100644
index 000000000..ae09fba1f
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/Controller/AppController.php
@@ -0,0 +1,10 @@
+ [
+ 'PersonRoleMappers.id' => 'asc'
+ ]
+ ];
+}
diff --git a/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappingsController.php b/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappingsController.php
new file mode 100644
index 000000000..00b3b16b3
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/Controller/PersonRoleMappingsController.php
@@ -0,0 +1,40 @@
+ [
+ 'PersonRoleMappings.id' => 'asc'
+ ]
+ ];
+}
diff --git a/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapper.php b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapper.php
new file mode 100644
index 000000000..3629f6a4f
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapper.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapping.php b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapping.php
new file mode 100644
index 000000000..c817649c3
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/Model/Entity/PersonRoleMapping.php
@@ -0,0 +1,112 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+
+ /**
+ * Determine if the provided array of Person Role attributes matches the
+ * conditions of this Mapping.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param array $attributes Array of Person Role attributes, as currently assembled
+ * @param ExternalIdentityRole $eirdata External Identity Role data, from the backend
+ * @return bool true if the conditions match, false otherwise
+ */
+
+ public function matches(array $attributes, ExternalIdentityRole $eirdata): bool {
+ // Some conditions use special tests, most use ComparisonEnum.
+ // In general, we want to use the original EIR data for comparisons.
+
+ if($this->attribute == 'AdHocAttribute.value') {
+ // We first need an AdHocAttribute that matches the configured tag.
+ // Note we only look at AdHocAttributes attached to the EIR, not the
+ // External Identity.
+
+ if(!empty($this->ad_hoc_tag) && !empty($eirdata->ad_hoc_attributes)) {
+ // In the event there is more than one AdHocAttribute with the configured
+ // tag, we'll check each of them, and return true if any match.
+ foreach($eirdata->ad_hoc_attributes as $adhoc) {
+ if(!empty($adhoc->tag) && $this->ad_hoc_tag == $adhoc->tag) {
+ // Correct tag, now compare the value. We only return if compare()
+ // returns true, otherwise we keep iterating.
+
+ if(ComparisonEnum::compare(
+ value: $adhoc->value,
+ comparison: $this->comparison,
+ pattern: $this->pattern
+ )) {
+ return true;
+ }
+ }
+ }
+ }
+ } elseif($this->attribute == 'ExternalIdentityRole.affiliation') {
+ // We should be given the affilation type id as mapped from the inbound EIR data,
+ // in which case we simply compare against the configured value
+ return (!empty($eirdata->affiliation_type_id)
+ && !empty($this->affiliation_type_id)
+ && ($eirdata->affiliation_type_id == $this->affiliation_type_id));
+ } else {
+ // This is something like ExternalIdentityRole.title, etc
+
+ $bits = explode('.', $this->attribute, 2);
+ $attr = $bits[1];
+
+ if(!empty($this->comparison) && !empty($this->pattern)) {
+ return ComparisonEnum::compare(
+ value: $eirdata->$attr,
+ comparison: $this->comparison,
+ pattern: $this->pattern
+ );
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappersTable.php b/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappersTable.php
new file mode 100644
index 000000000..9bdd1bf90
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappersTable.php
@@ -0,0 +1,158 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Flanges');
+
+ $this->hasMany('PipelineToolkit.PersonRoleMappings')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
+
+ $this->setDisplayField('attribute');
+
+ $this->setPrimaryLink(['flange_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' => false,
+ 'index' => ['platformAdmin', 'coAdmin']
+ ],
+ // Related models whose permissions we'll need, typically for table views
+ 'related' => [
+ 'table' => [
+ 'PipelineToolkit.PersonRoleMappings'
+ ]
+ ]
+ ]);
+ }
+
+ /**
+ * Apply mappings for buildPersonRole.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param Flange $flange Flange, including top level plugin configuration
+ * @param array $newdata Array of new Person Role data
+ * @param ExternalIdentityRole $eirdata Original External Identity Role data
+ * @return array Updated array of new Person Role data
+ */
+
+ public function buildPersonRole(
+ \App\Model\Entity\Flange $flange,
+ array $newdata,
+ \App\Model\Entity\ExternalIdentityRole $eirdata
+ ): array {
+ $retdata = $newdata;
+
+ // We need our Mapping configuration, which won't be in $flange
+ $mappings = $this->PersonRoleMappings->find()
+ ->where(['person_role_mapper_id' => $flange->person_role_mapper->id])
+ ->order(['ordr' => 'ASC'])
+ ->all();
+
+ foreach($mappings as $mapping) {
+ if($mapping->matches($retdata, $eirdata)) {
+ // This Mapping matched, so update the return data
+
+ if(!empty($mapping->target_affiliation_type_id)) {
+ $this->llog('trace', $flange->description . " mapping External Identity Role ID "
+ . $eirdata->id . " to Affiliation Type ID "
+ . $mapping->target_affiliation_type_id);
+ $retdata['affiliation_type_id'] = $mapping->target_affiliation_type_id;
+ }
+
+ if(!empty($mapping->target_cou_id)) {
+ $this->llog('trace', $flange->description . " mapping External Identity Role ID "
+ . $eirdata->id . " to COU ID "
+ . $mapping->target_cou_id);
+ $retdata['cou_id'] = $mapping->target_cou_id;
+ }
+ }
+ }
+
+ return $retdata;
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ * @throws InvalidArgumentException
+ * @throws RecordNotFoundException
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('flange_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('flange_id');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappingsTable.php b/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappingsTable.php
new file mode 100644
index 000000000..f2f98d16f
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/Model/Table/PersonRoleMappingsTable.php
@@ -0,0 +1,194 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Orderable');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('PipelineToolkit.PersonRoleMappers');
+
+ $this->belongsTo('AffiliationTypes')
+ ->setClassName('Types')
+ ->setForeignKey('affiliation_type_id')
+ ->setProperty('affiliation_type');
+ $this->belongsTo('TargetCous')
+ ->setClassName('Cous')
+ ->setForeignKey('target_cou_id')
+ ->setProperty('target_cou');
+ $this->belongsTo('TargetAffiliationTypes')
+ ->setClassName('Types')
+ ->setForeignKey('target_affiliation_type_id')
+ ->setProperty('target_affiliation_type');
+
+ $this->setDisplayField('description');
+
+ $this->setPrimaryLink(['PipelineToolkit.person_role_mapper_id']);
+ $this->setRequiresCO(true);
+ $this->setRedirectGoal('index');
+
+ $this->setAutoViewVars([
+ 'affiliationTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'PersonRoles.affiliation_type'
+ ],
+ 'attributes' => [
+ 'type' => 'array',
+ 'array' => $this->getMappableAttributes()
+ ],
+ 'comparisons' => [
+ 'type' => 'enum',
+ 'class' => 'ComparisonEnum'
+ ],
+ 'targetAffiliationTypes' => [
+ 'type' => 'type',
+ 'attribute' => 'PersonRoles.affiliation_type'
+ ],
+ 'targetCous' => [
+ 'type' => 'select',
+ 'model' => 'Cous'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ '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']
+ ]
+ ]);
+ }
+
+ /**
+ * Obtain the set of mappable attributes, in Model.attribute format.
+ *
+ * @since COmanage Registry v5.0.0
+ * @return array Mappable attributes
+ */
+
+ public function getMappableAttributes(): array {
+ return [
+ 'AdHocAttribute.value',
+ 'ExternalIdentityRole.affiliation',
+ 'ExternalIdentityRole.department',
+ 'ExternalIdentityRole.organization',
+ 'ExternalIdentityRole.title',
+ ];
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ * @throws InvalidArgumentException
+ * @throws RecordNotFoundException
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('person_role_mapper_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('person_role_mapper_id');
+
+ $validator->add('attribute', [
+ 'content' => ['rule' => ['inList', $this->getMappableAttributes()]]
+ ]);
+ $validator->notEmptyString('attribute');
+
+ $validator->add('affiliation_type_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('affiliation_type_id');
+
+ // The required fields here aren't ideal, since they vary by attribute
+ $this->registerStringValidation($validator, $schema, 'ad_hoc_tag', false);
+
+ $validator->add('comparison', [
+ 'content' => ['rule' => ['inList', ComparisonEnum::getConstValues()]]
+ ]);
+ $validator->allowEmptyString('comparison');
+
+ $this->registerStringValidation($validator, $schema, 'pattern', false);
+
+ $validator->add('ordr', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('ordr');
+
+ $validator->add('target_cou_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('target_cou_id');
+
+ $validator->add('target_affiliation_type_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('target_affiliation_type_id');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/availableplugins/PipelineToolkit/src/PipelineToolkitPlugin.php b/app/availableplugins/PipelineToolkit/src/PipelineToolkitPlugin.php
new file mode 100644
index 000000000..737262b79
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/PipelineToolkitPlugin.php
@@ -0,0 +1,93 @@
+plugin(
+ 'PipelineToolkit',
+ ['path' => '/pipeline-toolkit'],
+ 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
+
+ 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
+
+ $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/4/en/development/dependency-injection.html#dependency-injection
+ */
+ public function services(ContainerInterface $container): void
+ {
+ // Add your services here
+ }
+}
diff --git a/app/availableplugins/PipelineToolkit/src/config/plugin.json b/app/availableplugins/PipelineToolkit/src/config/plugin.json
new file mode 100644
index 000000000..5ef8ae600
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/src/config/plugin.json
@@ -0,0 +1,40 @@
+{
+ "types": {
+ "pipeline": [
+ "PersonRoleMappers"
+ ]
+ },
+ "schema": {
+ "tables": {
+ "person_role_mappers": {
+ "columns": {
+ "id": {},
+ "flange_id": { "type": "integer", "foreignkey": { "table": "flanges", "column": "id" } }
+ },
+ "indexes": {
+ "person_role_mappers_i1": { "columns": [ "flange_id" ] }
+ }
+ },
+ "person_role_mappings": {
+ "columns": {
+ "id": {},
+ "person_role_mapper_id": { "type": "integer", "foreignkey": { "table": "person_role_mappers", "column": "id" } },
+ "attribute": { "type": "string", "size": 80 },
+ "ad_hoc_tag": { "type": "string", "size": 128 },
+ "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } },
+ "comparison": { "type": "string", "size": 4 },
+ "pattern": { "type": "string", "size": 80 },
+ "target_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } },
+ "target_affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } },
+ "ordr": {}
+ },
+ "indexes": {
+ "person_role_mappings_i1": { "columns": [ "person_role_mapper_id" ] },
+ "person_role_mappings_i2": { "needed": false, "columns": [ "affiliation_type_id" ] },
+ "person_role_mappings_i3": { "needed": false, "columns": [ "target_cou_id" ] },
+ "person_role_mappings_i4": { "needed": false, "columns": [ "target_affiliation_type_id" ] }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-links.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-links.inc
new file mode 100644
index 000000000..d8c2ada5e
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-links.inc
@@ -0,0 +1,39 @@
+ 'transform',
+ 'order' => 'Default',
+ 'label' => __d('pipeline_toolkit', 'controller.PersonRoleMappings', [99]),
+ 'link' => [
+ 'plugin' => 'PipelineToolkit',
+ 'controller' => 'PersonRoleMappings',
+ 'action' => 'index',
+ 'person_role_mapper_id' => $vv_obj->id
+ ],
+ 'class' => ''
+];
diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-nav.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-nav.inc
new file mode 100644
index 000000000..fc4530862
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields-nav.inc
@@ -0,0 +1,31 @@
+ 'plugin',
+ 'active' => 'plugin'
+ ];
diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields.inc
new file mode 100644
index 000000000..c50caa436
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappers/fields.inc
@@ -0,0 +1,30 @@
+
\ No newline at end of file
diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/columns.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/columns.inc
new file mode 100644
index 000000000..8b442abea
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/columns.inc
@@ -0,0 +1,54 @@
+ [
+ 'type' => 'echo'
+ ],
+ 'attribute' => [
+ 'type' => 'link'
+ ],
+ 'comparison' => [
+ 'type' => 'enum',
+ 'class' => 'ComparisonEnum'
+ ],
+ 'pattern' => [
+ 'type' => 'echo'
+ ],
+ 'affiliation_type_id' => [
+ 'type' => 'fk',
+ 'label' => __d('field', 'affiliation'),
+ ]
+];
+
+/*
+// When the $bulkActions variable exists in a columns.inc config, the "Bulk edit" switch will appear in the index.
+$bulkActions = [
+ // TODO: develop bulk actions. For now, use a placeholder.
+ 'delete' => true
+];
+*/
diff --git a/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/fields.inc b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/fields.inc
new file mode 100644
index 000000000..ddd7dd9b0
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/templates/PersonRoleMappings/fields.inc
@@ -0,0 +1,77 @@
+
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'attribute',
+ 'fieldOptions' => [
+ 'onChange' => 'updateGadgets()'
+ ],
+ 'fieldType' => 'select'
+ ]
+ ]);
+
+ foreach (['ad_hoc_tag',
+ 'affiliation_type_id',
+ 'comparison',
+ 'pattern',
+ 'target_cou_id',
+ 'target_affiliation_type_id',
+ 'ordr'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+}
diff --git a/app/availableplugins/PipelineToolkit/tests/bootstrap.php b/app/availableplugins/PipelineToolkit/tests/bootstrap.php
new file mode 100644
index 000000000..737e7a8b4
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/tests/bootstrap.php
@@ -0,0 +1,55 @@
+loadSqlFiles('tests/schema.sql', 'test');
diff --git a/app/availableplugins/PipelineToolkit/tests/schema.sql b/app/availableplugins/PipelineToolkit/tests/schema.sql
new file mode 100644
index 000000000..864af874d
--- /dev/null
+++ b/app/availableplugins/PipelineToolkit/tests/schema.sql
@@ -0,0 +1 @@
+-- Test database schema for PipelineToolkit
diff --git a/app/availableplugins/PipelineToolkit/webroot/.gitkeep b/app/availableplugins/PipelineToolkit/webroot/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/composer.json b/app/composer.json
index 1122a447f..e50a631fd 100644
--- a/app/composer.json
+++ b/app/composer.json
@@ -34,6 +34,7 @@
"CoreAssigner\\": "plugins/CoreAssigner/src/",
"CoreServer\\": "plugins/CoreServer/src/",
"FileConnector\\": "availableplugins/FileConnector/src/",
+ "PipelineToolkit\\": "availableplugins/PipelineToolkit/src/",
"SqlConnector\\": "availableplugins/SqlConnector/src/",
"CoreJob\\": "plugins/CoreJob/src/"
}
@@ -46,6 +47,7 @@
"CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/",
"CoreServer\\Test\\": "plugins/CoreServer/tests/",
"FileConnector\\Test\\": "availableplugins/FileConnector/tests/",
+ "PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/",
"SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/",
"CoreJob\\Test\\": "plugins/CoreJob/tests/"
}
diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json
index 54d0239a2..190826b33 100644
--- a/app/config/schema/schema.json
+++ b/app/config/schema/schema.json
@@ -693,6 +693,20 @@
}
},
+ "flanges": {
+ "columns": {
+ "id": {},
+ "pipeline_id": { "type": "integer", "foreignkey": { "table": "pipelines", "column": "id" } },
+ "description": {},
+ "plugin": {},
+ "status": {},
+ "ordr": {}
+ },
+ "indexes": {
+ "flanges_i1": { "columns": [ "pipeline_id" ] }
+ }
+ },
+
"external_identity_sources": {
"columns": {
"id": {},
diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po
index 747cd44e0..714484a6e 100644
--- a/app/resources/locales/en_US/controller.po
+++ b/app/resources/locales/en_US/controller.po
@@ -63,6 +63,9 @@ msgstr "{0,plural,=1{External Identity Source} other{External Identity Sources}}
msgid "ExtIdentitySourceRecords"
msgstr "{0,plural,=1{External Identity Source Record} other{External Identity Source Records}}"
+msgid "Flanges"
+msgstr "{0,plural,=1{Flange} other{Flanges}}"
+
msgid "GroupMembers"
msgstr "{0,plural,=1{Group Member} other{Group Members}}"
diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po
index fb8519795..2d04220f4 100644
--- a/app/resources/locales/en_US/enumeration.po
+++ b/app/resources/locales/en_US/enumeration.po
@@ -36,6 +36,33 @@ msgstr "False"
msgid "BooleanEnum.1"
msgstr "True"
+msgid "ComparisonEnum.CTS"
+msgstr "Contains"
+
+msgid "ComparisonEnum.CTI"
+msgstr "Contains (case insensitive)"
+
+msgid "ComparisonEnum.EQS"
+msgstr "Is Exactly"
+
+msgid "ComparisonEnum.EQI"
+msgstr "Is Exactly (case insensitive)"
+
+msgid "ComparisonEnum.NCT"
+msgstr "Does Not Contain"
+
+msgid "ComparisonEnum.NCTI"
+msgstr "Does Not Contain (case insensitive)"
+
+msgid "ComparisonEnum.NEQ"
+msgstr "Is Not Exactly"
+
+msgid "ComparisonEnum.NEQI"
+msgstr "Is Not Exactly (case insensitive)"
+
+msgid "ComparisonEnum.REGX"
+msgstr "Matches Regular Expression"
+
msgid "DeletedRoleStatusEnum.D"
msgstr "Archived"
@@ -93,6 +120,15 @@ msgstr "Suspended"
msgid "ExternalIdentityStatusEnum.DX"
msgstr "Deleted"
+msgid "FlangeModeEnum.EI"
+msgstr "Retrieve External Identity"
+
+msgid "FlangeModeEnum.PR"
+msgstr "Build Person Role"
+
+msgid "FlangeModeEnum.X"
+msgstr "Disabled"
+
msgid "GroupTypeEnum.MA"
msgstr "Active Members"
diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po
index fbb338b83..fe8b193b9 100644
--- a/app/resources/locales/en_US/error.po
+++ b/app/resources/locales/en_US/error.po
@@ -271,6 +271,9 @@ msgstr "Permission Denied"
msgid "PersonRoles.valid_from.after"
msgstr "Valid From date must be earlier than Valid Through date"
+msgid "Pipelines.plugin.notimpl"
+msgstr "Pipeline plugin does not implement {0}"
+
msgid "Plugins.inactive"
msgstr "The plugin {0} is not active"
diff --git a/app/src/Controller/FlangesController.php b/app/src/Controller/FlangesController.php
new file mode 100644
index 000000000..6dcffd261
--- /dev/null
+++ b/app/src/Controller/FlangesController.php
@@ -0,0 +1,41 @@
+ [
+ 'Flanges.description' => 'asc'
+ ]
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php
index b15b29a9a..3b132fd14 100644
--- a/app/src/Controller/StandardPluginController.php
+++ b/app/src/Controller/StandardPluginController.php
@@ -31,6 +31,7 @@
// XXX not doing anything with Log yet
use Cake\Log\Log;
+use Cake\ORM\TableRegistry;
use \App\Lib\Util\StringUtilities;
use \App\Lib\Enum\SuspendableStatusEnum;
@@ -86,11 +87,12 @@ public function beforeRender(\Cake\Event\EventInterface $event) {
$link = $this->getPrimaryLink(true);
- if(!empty($link->value)) {
- $parentClassName = StringUtilities::foreignKeyToClassName($link->attr);
-
- $parentObj = $table->$parentClassName->get($link->value);
- $parentDisplayField = $table->$parentClassName->getDisplayField();
+ if(!empty($link->value) && !empty($link->model_name)) {
+ // This might be a plugin table in Plugin.Model notation
+ $parentTable = TableRegistry::getTableLocator()->get($link->model_name);
+
+ $parentObj = $parentTable->get($link->value);
+ $parentDisplayField = $parentTable->getDisplayField();
$this->set('vv_bc_parent_obj', $parentObj);
$this->set('vv_bc_parent_displayfield', $parentDisplayField);
@@ -98,7 +100,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) {
// Override the title set in StandardController. Since that was set in edit()
// which is called before the rendering hooks, this title will take precedence.
- [$title, , ] = StringUtilities::entityAndActionToTitle($parentObj, $parentClassName, 'configure');
+ [$title, , ] = StringUtilities::entityAndActionToTitle($parentObj, $link->model_name, 'configure');
$this->set('vv_title', $title);
}
diff --git a/app/src/Lib/Enum/ComparisonEnum.php b/app/src/Lib/Enum/ComparisonEnum.php
new file mode 100644
index 000000000..a6b1324e4
--- /dev/null
+++ b/app/src/Lib/Enum/ComparisonEnum.php
@@ -0,0 +1,89 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/FlangesTable.php b/app/src/Model/Table/FlangesTable.php
new file mode 100644
index 000000000..3bda87ded
--- /dev/null
+++ b/app/src/Model/Table/FlangesTable.php
@@ -0,0 +1,134 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Orderable');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Pipelines');
+
+ $this->setPluginRelations();
+
+ $this->setDisplayField('description');
+
+ $this->setPrimaryLink('pipeline_id');
+ $this->setRequiresCO(true);
+
+ $this->setAutoViewVars([
+ 'plugins' => [
+ 'type' => 'plugin',
+ 'pluginType' => 'pipeline'
+ ],
+ 'statuses' => [
+ 'type' => 'enum',
+ 'class' => 'FlangeModeEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'configure' => ['platformAdmin', 'coAdmin'],
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ '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']
+ ]
+ ]);
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('pipeline_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('pipeline_id');
+
+ $this->registerStringValidation($validator, $schema, 'description', false);
+
+ $validator->add('status', [
+ 'content' => ['rule' => ['inList', FlangeModeEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('status');
+
+ $this->registerStringValidation($validator, $schema, 'plugin', true);
+
+ $validator->add('ordr', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('ordr');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php
index 94213d9e4..9fb094cc8 100644
--- a/app/src/Model/Table/PipelinesTable.php
+++ b/app/src/Model/Table/PipelinesTable.php
@@ -44,6 +44,7 @@
use \App\Lib\Enum\ActionEnum;
use \App\Lib\Enum\DeletedRoleStatusEnum;
use \App\Lib\Enum\ExternalIdentityStatusEnum;
+use \App\Lib\Enum\FlangeModeEnum;
use \App\Lib\Enum\MatchStrategyEnum;
use \App\Lib\Enum\ProvisioningContextEnum;
use \App\Lib\Enum\StatusEnum;
@@ -108,6 +109,7 @@ public function initialize(array $config): void {
->setProperty('sync_identifier_type');
$this->hasMany('ExternalIdentitySources');
+ $this->hasMany('Flanges');
$this->setDisplayField('description');
@@ -166,6 +168,12 @@ public function initialize(array $config): void {
'table' => [
'add' => ['platformAdmin', 'coAdmin'],
'index' => ['platformAdmin', 'coAdmin']
+ ],
+ // Related models whose permissions we'll need, typically for table views
+ 'related' => [
+ 'table' => [
+ 'Flanges'
+ ]
]
]);
}
@@ -455,6 +463,15 @@ public function execute(
$pipeline = $this->get($id);
$eis = $this->ExternalIdentitySources->get($eisId);
+ // We'll pull Flanges separately to make it a bit clearer what we're doing,
+ // but we'll stuff it into the $pipeline configuration to make it easier to
+ // pass around.
+ $pipeline->flanges = $this->Flanges->find()
+ ->where(['pipeline_id' => $id])
+ ->order(['Flanges.ordr' => 'ASC'])
+ ->contain($this->Flanges->getPluginRelations())
+ ->all();
+
// Start a Transaction
$cxn = $this->getConnection();
$cxn->begin();
@@ -936,8 +953,11 @@ protected function obtainPerson(
* Search for an existing Person using an attribute provided in the EIS Record.
*
* @since COmanage Registry v5.0.0
- * @param ExternalIdentitySource $eis External Identity Source
- * XXX params/return
+ * @param ExternalIdentitySource $eis External Identity Source
+ * @param ExtIdentitySourceRecord $eisRecord External Identity Source Record
+ * @param string $matchStrategy MatchStrategyEnum
+ * @param int $attributeTypeId Attribute Type to search on
+ * @param array $attributes Attributes to use for searching
* @return Person Person if found, null otherwise
* @throws InvalidArgumentException
*/
@@ -1675,6 +1695,27 @@ protected function syncPerson(
$newdata['status'] = StatusEnum::Active;
}
+ if(!empty($pipeline->flanges)) {
+ // These should already be order by ordr. Note that ordr operates a bit
+ // unexpectedly here... the later flanges to get called can override the
+ // values returned by earlier flanges, since we always call all active
+ // flanges, so the later flanges take precedence over the earlier ones.
+ foreach($pipeline->flanges as $flange) {
+ if($flange->status == FlangeModeEnum::BuildPersonRole) {
+ $this->llog('trace', 'Running Flange ' . $flange->description . ' for BuildPersonRole');
+
+ $PluginTable = TableRegistry::getTableLocator()->get($flange->plugin);
+
+ if(!method_exists($PluginTable, 'buildPersonRole')) {
+ throw new \RuntimeException(__d('Pipelines.plugin.notimpl', ['buildPersonRole']));
+ }
+
+ // The plugin should modify $newdata as needed, then return it
+ $newdata = $PluginTable->buildPersonRole($flange, $newdata, $eirentity);
+ }
+ }
+ }
+
// Do we have a corresponding record on the Person?
$found = $curentities->firstMatch([$sourcefk => $eirentity->id]);
diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php
index 544fbb7cd..e4095ca17 100644
--- a/app/src/View/Helper/FieldHelper.php
+++ b/app/src/View/Helper/FieldHelper.php
@@ -322,8 +322,14 @@ public function formField(string $fieldName,
// Selects, Checkboxes, and Radio Buttons use "disabled"
$fieldArgs['disabled'] = $fieldArgs['readonly'];
- $fieldArgs['required'] = $this->isReqField($fieldName)
- || (isset($fieldOptions['required']) && $fieldOptions['required']);
+
+ // required can be overridden by the fields.inc, but start with the default expectation
+ $fieldArgs['required'] = $this->isReqField($fieldName);
+
+ if(isset($fieldOptions['required'])) {
+ // This could be either true or false
+ $fieldArgs['required'] = $fieldOptions['required'];
+ }
// Cause any select (except status) to render with a blank option, even
// if the field is required. This makes it clear when a value needs to be set.
diff --git a/app/templates/Flanges/columns.inc b/app/templates/Flanges/columns.inc
new file mode 100644
index 000000000..e5b405913
--- /dev/null
+++ b/app/templates/Flanges/columns.inc
@@ -0,0 +1,63 @@
+ [
+ 'type' => 'link',
+ 'sortable' => true
+ ],
+ 'plugin' => [
+ 'type' => 'echo',
+ 'sortable' => true
+ ],
+ 'ordr' => [
+ 'type' => 'echo',
+ 'sortable' => true
+ ],
+ 'status' => [
+ 'type' => 'enum',
+ 'class' => 'FlangeModeEnum',
+ 'sortable' => true
+ ]
+];
+
+// $rowActions appear as row-level menu items in the index view gear icon
+$rowActions = [
+ [
+ 'action' => 'configure',
+ 'label' => __d('operation', 'configure.plugin'),
+ 'icon' => 'electrical_services'
+ ]
+];
+
+/*
+// When the $bulkActions variable exists in a columns.inc config, the "Bulk edit" switch will appear in the index.
+$bulkActions = [
+ // TODO: develop bulk actions. For now, use a placeholder.
+ 'delete' => true
+];
+*/
\ No newline at end of file
diff --git a/app/templates/Flanges/fields.inc b/app/templates/Flanges/fields.inc
new file mode 100644
index 000000000..ab041494e
--- /dev/null
+++ b/app/templates/Flanges/fields.inc
@@ -0,0 +1,41 @@
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+}
diff --git a/app/templates/Pipelines/fields-nav.inc b/app/templates/Pipelines/fields-nav.inc
new file mode 100644
index 000000000..c71fcd7d9
--- /dev/null
+++ b/app/templates/Pipelines/fields-nav.inc
@@ -0,0 +1,38 @@
+ 'fork_left', // XXX CFM-404 this should be "valve" once we switch to Material Symbols
+ 'order' => 'Default',
+ 'label' => __d('controller', 'Flanges', [99]),
+ 'link' => [
+ 'controller' => 'flanges',
+ 'action' => 'index',
+ 'pipeline_id' => $vv_obj->id
+ ],
+ 'class' => ''
+];
diff --git a/app/templates/element/form/unorderedList.php b/app/templates/element/form/unorderedList.php
index 4d3f8a9aa..9012de827 100644
--- a/app/templates/element/form/unorderedList.php
+++ b/app/templates/element/form/unorderedList.php
@@ -49,8 +49,12 @@
include($vv_template_path . DS . $fieldsFile);
// Element ID
print $this->element('form/entityID');
- // The Submit element will be printed only if we are adding or updating
- print $this->element('form/submit', ['label' => __d('operation', 'save')]);
+
+ if(!isset($suppress_submit) || !$suppress_submit) {
+ // The Submit element will be printed only if we are adding or updating, and if not
+ // suppressed by the field configuration
+ print $this->element('form/submit', ['label' => __d('operation', 'save')]);
+ }
?>
diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php
index 461bc9bb0..6b5ff9040 100644
--- a/app/vendor/composer/autoload_psr4.php
+++ b/app/vendor/composer/autoload_psr4.php
@@ -40,6 +40,8 @@
'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),
'Psr\\Cache\\' => array($vendorDir . '/psr/cache/src'),
+ 'PipelineToolkit\\Test\\' => array($baseDir . '/availableplugins/PipelineToolkit/tests'),
+ 'PipelineToolkit\\' => array($baseDir . '/availableplugins/PipelineToolkit/src'),
'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'),
'Phinx\\' => array($vendorDir . '/robmorgan/phinx/src/Phinx'),
'PHPStan\\PhpDocParser\\' => array($vendorDir . '/phpstan/phpdoc-parser/src'),
diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php
index ea99e3f5f..5d92df701 100644
--- a/app/vendor/composer/autoload_static.php
+++ b/app/vendor/composer/autoload_static.php
@@ -92,6 +92,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e
'Psr\\Http\\Client\\' => 16,
'Psr\\Container\\' => 14,
'Psr\\Cache\\' => 10,
+ 'PipelineToolkit\\Test\\' => 21,
+ 'PipelineToolkit\\' => 16,
'PhpParser\\' => 10,
'Phinx\\' => 6,
'PHPStan\\PhpDocParser\\' => 21,
@@ -310,6 +312,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e
array (
0 => __DIR__ . '/..' . '/psr/cache/src',
),
+ 'PipelineToolkit\\Test\\' =>
+ array (
+ 0 => __DIR__ . '/../..' . '/availableplugins/PipelineToolkit/tests',
+ ),
+ 'PipelineToolkit\\' =>
+ array (
+ 0 => __DIR__ . '/../..' . '/availableplugins/PipelineToolkit/src',
+ ),
'PhpParser\\' =>
array (
0 => __DIR__ . '/..' . '/nikic/php-parser/lib/PhpParser',