Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ msgstr "Requested address is already verified"
msgid "error.EnrollmentAttributes.valid_through.default_value"
msgstr "Days After Finalization must be a whole number of zero or more"

msgid "error.EnrollmentAttributes.single_valued_exists"
msgstr "An Enrollment Attribute for {0} is already configured for this Attribute Collector"

msgid "error.PetitionAcceptances.exists"
msgstr "An Invitation for this Petition already exists"

Expand Down Expand Up @@ -218,7 +221,13 @@ msgid "field.AttributeCollectors.valid_through.default.after.desc"
msgstr "Days After Finalization"

msgid "field.AttributeCollectors.valid_through.default.after.hint"
msgstr "If left blank, defaults to {0} days after finalization"
msgstr "This date will be set to {0} days after finalization"

msgid "field.AttributeCollectors.valid_through.default.after.hint.editable"
msgstr "If left blank, this date will be set to {0} days after finalization. If you enter a date, that date will be used instead."

msgid "field.AttributeCollectors.cou_id.locked.hint"
msgstr "The COU has already been set for this Petition and cannot be changed here"

msgid "field.AttributeCollectors.enable_person_find.desc"
msgstr "Enable people picker for self-service enrollments, see <a target=\"_top\" href=\"https://spaces.at.internet2.edu/display/COmanage/Attribute+Collector+Enroller+Plugin\">Attribute Collector documentation</a> for privacy considerations"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,18 @@ public function dispatch(string $id) {

if($this->request->is(['post', 'put'])) {
try {
// Determine before storing, since storing the collected COU changes the comparison
$lockedCouId = $this->AttributeCollectors->lockedCouId((int)$petition->id);

$this->AttributeCollectors->upsert(
id: (int)$id,
petitionId: $petition->id,
// Remove form metadata from the set of attributes we try to upsert
attributes: array_diff_key($this->request->getData(), ['petition_id' => true, 'token' => true])
);

$this->persistPetitionCouIdFromCollectedAttributes((int)$petition->id, (int)$id, $lockedCouId);

// On success, indicate the step is completed and generate a redirect
// to the next step

Expand All @@ -78,6 +83,13 @@ public function dispatch(string $id) {

// Fall through and let the form render

// A COU attached to the Petition by another component (eg the COU Selector) takes
// precedence over any COU collected here, so the form shows it read-only. A COU
// collected by a previous Attribute Collector step is not locked: the field is
// pre-populated with it and can be changed.
$this->set('vv_petition_cou_id',
$this->AttributeCollectors->lockedCouId((int)$petition->id));

// Pull the configured set of attributes
$this->set('vv_enrollment_attributes',
$this->AttributeCollectors
Expand All @@ -96,8 +108,72 @@ public function dispatch(string $id) {
->PetitionAttributes
->find()
->where(['petition_id' => $petition->id])
->contain(['EnrollmentAttributes'])
->all());

$this->render('/Standard/dispatch');
}

/**
* If the Attribute Collector collected a COU for later context (eg approvals),
* persist Petition.cou_id during dispatch (not during finalization).
*
* @since COmanage Registry v5.3.0
* @param int $petitionId
* @param int|null $attributeCollectorId Attribute Collector ID
* @return void
*/
protected function persistPetitionCouIdFromCollectedAttributes(int $petitionId, ?int $attributeCollectorId = null, ?int $lockedCouId = null): void {
$petitionAttributesTable = $this->AttributeCollectors
->EnrollmentAttributes
->PetitionAttributes;

// Fetch petition attributes + enrollment attribute data
$query = $petitionAttributesTable
->find()
->where(['PetitionAttributes.petition_id' => $petitionId])
->contain(['EnrollmentAttributes']);

if($attributeCollectorId !== null) {
$query->matching('EnrollmentAttributes', function($q) use ($attributeCollectorId) {
return $q->where(['EnrollmentAttributes.attribute_collector_id' => $attributeCollectorId]);
});
}

$attrs = $query->toArray();

// Find the first "cou_id" with a non-empty value
// NOTE: Attribute Collector configuration can include multiple COU attributes/values, but the
// Petition record has only a single `cou_id` column. By convention/assumption we therefore
// select the first non-empty collected `cou_id` and ignore any additional values.
$couAttr = null;
foreach ($attrs as $pa) {
if (
!empty($pa->enrollment_attribute)
&& ($pa->enrollment_attribute->attribute ?? null) === 'cou_id'
&& ($pa->value ?? null) !== null
&& ($pa->value ?? null) !== ''
) {
$couAttr = $pa;
break;
}
}

// A COU attached by another component (eg the COU Selector) is never overwritten,
// it also takes precedence at finalization.
if(!$couAttr || !empty($lockedCouId)) {
return;
}

$petitionsTable = $this->fetchTable('Petitions');
$petition = $petitionsTable->get($petitionId);

if((int)$petition->cou_id === (int)$couAttr->value) {
return;
}

// Not locked: the COU was empty or collected by an earlier step, so the latest wins
$petition->cou_id = (int)$couAttr->value;
$petitionsTable->saveOrFail($petition);
}
}
210 changes: 207 additions & 3 deletions app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class AttributeCollectorsTable extends Table {
use \App\Lib\Traits\TableMetaTrait;
use \App\Lib\Traits\ValidationTrait;

// Person Role created per Petition during finalization (Petition ID => PersonRole),
// so all Attribute Collectors of a Petition add to the same single Role
private array $petitionRoles = [];

/**
* Perform Cake Model initialization.
*
Expand Down Expand Up @@ -245,11 +249,20 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool
$attributes = $this->EnrollmentAttributes
->PetitionAttributes
->find()
->where(['petition_id' => $petition->id])
->where(['PetitionAttributes.petition_id' => $petition->id])
->matching('EnrollmentAttributes', function($q) use ($id) {
return $q->where(['EnrollmentAttributes.attribute_collector_id' => $id]);
})
->contain(
['EnrollmentAttributes' => ['AttributeTypes']]
)->toArray();

// A multi-valued attribute (email, phone, ad hoc, ...) collected again by a later
// Attribute Collector of this Petition is superseded by the later value, so that
// finalization creates only one record. The Petition Attributes in the database
// are the shared state between the per-step hydrate() calls.
$attributes = $this->removeSupersededMveaAttributes($cfg, $petition, $attributes);

// Get the collection object
$attributesCollection = new Collection($attributes);

Expand Down Expand Up @@ -284,8 +297,23 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool
}
}

// Petition.cou_id may have been set by another step (eg the COU Selector), so read
// it from the database. If set, it overrides the collected COU.
$petitionCouId = TableRegistry::getTableLocator()->get('Petitions')
->get($petition->id)->cou_id;

$personRoleObj = TableRegistry::getTableLocator()->get('PersonRoles');
$role = $personRoleObj->saveAttributeCollectorPetitionAttributes((int)$person->id, $fieldsForPersonRole);
$role = $personRoleObj->saveAttributeCollectorPetitionAttributes(
(int)$person->id,
$fieldsForPersonRole,
$this->petitionRoles[(int)$petition->id] ?? null,
!empty($petitionCouId) ? (int)$petitionCouId : null
);
// Remember the Role so later Attribute Collectors of this Petition add to it
$this->petitionRoles[(int)$petition->id] = $role;
} else {
// Only a Role created for this Petition by an earlier Attribute Collector
$role = $this->petitionRoles[(int)$petition->id] ?? null;
}

/********* MVEAS **************/
Expand All @@ -307,7 +335,7 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool

// MVEAs for Role
// Save the MVEAs for the PersonRole only if we collected something meaningful for it, and the role was created
if(!empty($fieldsForPersonRole)) {
if(!empty($role)) {
$this->handleMveaAttributes(
$person,
$role,
Expand Down Expand Up @@ -356,6 +384,84 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool
return true;
}

/**
* Remove the multi-valued Petition Attributes of an Attribute Collector that are
* superseded by the same kind of attribute (attribute, type, parent, tag, language)
* collected by a later Attribute Collector (by Enrollment Flow Step order) of the Petition.
*
* @since COmanage Registry v5.3.0
* @param \CoreEnroller\Model\Entity\AttributeCollector $cfg Current Attribute Collector
* @param \App\Model\Entity\Petition $petition Petition
* @param array $attributes Petition Attributes of the current collector
* @return array Remaining Petition Attributes
*/

protected function removeSupersededMveaAttributes($cfg, \App\Model\Entity\Petition $petition, array $attributes): array {
$mveaAttributes = array_keys(array_filter(
$this->EnrollmentAttributes->supportedAttributes(),
fn($a) => isset($a['mveaModel'])
));

$keyOf = function($ea) {
return implode('|', [
$ea->attribute,
$ea->attribute_type,
$ea->attribute_mvea_parent,
$ea->attribute_tag,
$ea->attribute_language
]);
};

// Order of every Attribute Collector in the flow
$order = [];
$collectors = $this->find()
->contain(['EnrollmentFlowSteps'])
->where(['EnrollmentFlowSteps.enrollment_flow_id' => $this->EnrollmentFlowSteps->get($cfg->enrollment_flow_step_id)->enrollment_flow_id])
->toArray();
foreach($collectors as $c) {
$order[(int)$c->id] = [(int)$c->enrollment_flow_step->ordr, (int)$c->id];
}

if(!isset($order[(int)$cfg->id])) {
return $attributes;
}

// Keys collected by later Attribute Collectors
$others = $this->EnrollmentAttributes
->PetitionAttributes
->find()
->where(['PetitionAttributes.petition_id' => $petition->id])
->matching('EnrollmentAttributes', function($q) use ($cfg) {
return $q->where(['EnrollmentAttributes.attribute_collector_id !=' => $cfg->id]);
})
->contain(['EnrollmentAttributes'])
->toArray();

$later = [];
foreach($others as $pa) {
$ea = $pa->enrollment_attribute;
$acId = (int)$ea->attribute_collector_id;

if(!in_array($ea->attribute, $mveaAttributes, true) || !isset($order[$acId])) {
continue;
}

if($order[$acId] > $order[(int)$cfg->id]) {
$later[$keyOf($ea)] = true;
}
}

if(empty($later)) {
return $attributes;
}

return array_values(array_filter($attributes, function($pa) use ($later, $keyOf, $mveaAttributes) {
$ea = $pa->enrollment_attribute;

return !(in_array($ea->attribute, $mveaAttributes, true) && isset($later[$keyOf($ea)]));
}));
}

/**
* Handle MVEA (Multi-Valued Enrollment Attributes) for a model.
*
Expand Down Expand Up @@ -410,6 +516,101 @@ protected function handleMveaAttributes(
}
}

/**
* Obtain the COU that is locked for the Attribute Collector, ie the COU attached to the
* Petition when the Enrollment Flow also contains a COU Selector step.
*
* Collecting a COU in an Attribute Collector next to a COU Selector is an Enrollment Flow
* misconfiguration: the COU Selector owns Petition.cou_id, and the Attribute Collector must
* ignore any COU it collects and use the one on the Petition (the same Actor rule applies
* regardless of who runs the step). Deciding this from the Enrollment Flow configuration,
* rather than by comparing Petition.cou_id with the values collected so far, is
* deterministic: it does not depend on step order, reruns, or on the selector and the
* collector happening to pick the same COU. Without a COU Selector, the Attribute
* Collector fully owns the COU, so nothing is locked and the latest step wins.
*
* @since COmanage Registry v5.3.0
* @param int $petitionId Petition ID
* @return int|null Locked COU ID, or null if the COU can be collected
*/

public function lockedCouId(int $petitionId): ?int {
$petition = TableRegistry::getTableLocator()->get('Petitions')->get($petitionId);

if(empty($petition->cou_id)) {
return null;
}

$hasCouSelector = TableRegistry::getTableLocator()->get('CoreEnroller.CouSelectors')
->find()
->contain(['EnrollmentFlowSteps'])
->where(['EnrollmentFlowSteps.enrollment_flow_id' => $petition->enrollment_flow_id])
->count() > 0;

return $hasCouSelector ? (int)$petition->cou_id : null;
}

/**
* Enforce the COU of the Petition on the submitted COU fields, to prevent form tampering.
*
* If a COU was attached to the Petition by another component (eg the COU Selector), it
* overrides any submitted COU. Otherwise a submitted COU must belong to the CO of the
* Petition, since Petition.cou_id selects the approvers.
*
* @since COmanage Registry v5.3.0
* @param int $id Attribute Collector ID
* @param int $petitionId Petition ID
* @param array $attributes Submitted form fields
* @return array Form fields with the COU enforced
* @throws \InvalidArgumentException
*/

protected function enforceCouAttributes(int $id, int $petitionId, array $attributes): array {
$couAttributeIds = $this->EnrollmentAttributes
->find('list', keyField: 'id', valueField: 'id')
->where([
'attribute_collector_id' => $id,
'attribute' => 'cou_id'
])
->toArray();

if(empty($couAttributeIds)) {
return $attributes;
}

$coId = TableRegistry::getTableLocator()->get('Petitions')->findCoForRecord($petitionId);
$lockedCouId = $this->lockedCouId($petitionId);

foreach($attributes as $label => $value) {
$parts = explode('-', (string)$label);
$eaId = (int)array_pop($parts);

if(!isset($couAttributeIds[$eaId])) {
continue;
}

if(!empty($lockedCouId)) {
$attributes[$label] = (string)$lockedCouId;
continue;
}

if($value === null || $value === '') {
continue;
}

$valid = TableRegistry::getTableLocator()->get('Cous')->exists([
'Cous.id' => (int)$value,
'Cous.co_id' => $coId
]);

if(!$valid) {
throw new \InvalidArgumentException(__d('error', 'notfound', [__d('controller', 'Cous', [1])]));
}
}

return $attributes;
}

/**
* Insert or update a set of Petition Attributes.
*
Expand All @@ -424,6 +625,9 @@ protected function handleMveaAttributes(
public function upsert(int $id, int $petitionId, array $attributes) {
$attributeCollector = $this->get($id);

// Enforce the COU server side, before anything is stored
$attributes = $this->enforceCouAttributes($id, $petitionId, $attributes);

// Do we have existing attributes for this petition? Note this will pull
// _all_ attributes for the Petition, not just those associated with this
// particular Attribute Collector; however we'll only look at the attributes
Expand Down
Loading