Skip to content

[System:Feature] SAML mapping for registration feed #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Sep 6, 2022
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
3 changes: 3 additions & 0 deletions student_auto_feed/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@
//Header row, if it exists, must be discarded during processing.
define('HEADER_ROW_EXISTS', true);

//Set to true, if Submitty is using SAML for authentication.
define('PROCESS_SAML', true);

//Allows "\r" EOL encoding. This is rare but exists (e.g. Excel for Macintosh).
ini_set('auto_detect_line_endings', true);

Expand Down
14 changes: 13 additions & 1 deletion student_auto_feed/ssaf_db.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ public static function upsert($semester, $course, $rows) : bool {
case self::run_query(sql::LOCK_COURSES_USERS, null) === false:
self::$error .= "\nError during LOCK courses_users table, {$course}";
return false;
case self::run_query(sql::LOCK_SAML_MAPPED_USERS, null) === false:
self::$error .= "\nError during LOCK saml_mapped_users table, {$course}";
return false;
}

// Do upsert of course enrollment data.
Expand Down Expand Up @@ -207,13 +210,22 @@ public static function upsert($semester, $course, $rows) : bool {
}
} // END row by row processing.

// Finish up by checking for dropped students.
// Process students who dropped a course.
if (self::run_query(sql::DROPPED_USERS, $dropped_users_params) === false) {
self::run_query(sql::ROLLBACK, null);
self::$error .= "\nError processing dropped students, {$course}\n";
return false;
}

// Add students to SAML mappings when PROCESS_SAML is set to true in config.php.
if (PROCESS_SAML) {
if (self::run_query(sql::INSERT_SAML_MAP, null) === false) {
self::run_query(sql::ROLLBACK, null);
self::$error .= "\nError processing saml mappings, {$course}\n";
return false;
}
}

// All data has been upserted. Complete transaction and return success or failure.
return self::run_query(sql::COMMIT, null) !== false;
}
Expand Down
20 changes: 19 additions & 1 deletion student_auto_feed/ssaf_sql.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class sql {
public const LOCK_COURSES = "LOCK TABLE courses IN EXCLUSIVE MODE";
public const LOCK_REG_SECTIONS = "LOCK TABLE courses_registration_sections IN EXCLUSIVE MODE";
public const LOCK_COURSES_USERS = "LOCK TABLE courses_users IN EXCLUSIVE MODE";
public const LOCK_SAML_MAPPED_USERS = "LOCK TABLE saml_mapped_users IN EXCLUSIVE MODE";
public const BEGIN = "BEGIN";
public const COMMIT = "COMMIT";
public const ROLLBACK = "ROLLBACK";
Expand Down Expand Up @@ -55,7 +56,11 @@ class sql {
THEN EXCLUDED.user_preferred_firstname
ELSE users.user_preferred_firstname
END,
user_email=EXCLUDED.user_email
user_email=
CASE WHEN COALESCE(EXCLUDED.user_email, '')<>''
THEN EXCLUDED.user_email
ELSE users.user_email
END
/* AUTH: "AUTO_FEED" */
SQL;

Expand Down Expand Up @@ -127,6 +132,19 @@ class sql {
AND courses_users.user_group=4
AND courses_users.manual_registration=FALSE
SQL;

public const INSERT_SAML_MAP = <<<SQL
INSERT INTO saml_mapped_users (
saml_id,
user_id
) SELECT tmp.user_id, tmp.user_id
FROM tmp_enrolled tmp
LEFT OUTER JOIN saml_mapped_users saml1 ON tmp.user_id = saml1.user_id
LEFT OUTER JOIN saml_mapped_users saml2 ON tmp.user_id = saml2.saml_id
WHERE saml1.user_id IS NULL
AND saml2.saml_id IS NULL
SQL;

}

//EOF
Expand Down
36 changes: 25 additions & 11 deletions student_auto_feed/ssaf_validate.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
Q: What is going on with the regex for email validation in
validate::validate_row()?
A: The regex is intending to match an email address as
1. An empty string. Obviously not a real email address, but we are allowing
enrollments in the database with "inactive" or "pending" email addresses.
-- OR --
1. Address recipient may not contain characters (),:;<>@\"[]
2. Address recipient may not start or end with characters !#$%'*+-/=?^_`{|
3. Address recipient and hostname must be delimited with @ character
Expand All @@ -13,6 +16,7 @@
8. The entire email address is case insensitive

Peter Bailie, Oct 29 2021
Last Updated May 12, 2022 by Peter Bailie
----------------------------------------------------------------------------- */

namespace ssaf;
Expand Down Expand Up @@ -48,24 +52,24 @@ public static function validate_row($row, $row_num) : bool {
case is_null(EXPECTED_TERM_CODE) ? true : $row[COLUMN_TERM_CODE] === EXPECTED_TERM_CODE:
self::$error = "Row {$row_num} failed validation for unexpected term code \"{$row[COLUMN_TERM_CODE]}\".";
return false;
// User ID must contain only lowercase alpha, numbers, underscore, and hyphen
case boolval(preg_match("/^[a-z0-9_\-]+$/", $row[COLUMN_USER_ID])):
// User ID must contain only alpha characters, numbers, underscore, hyphen, and period.
case boolval(preg_match("/^[a-z0-9_\-\.]+$/i", $row[COLUMN_USER_ID])):
self::$error = "Row {$row_num} failed user ID validation \"{$row[COLUMN_USER_ID]}\".";
return false;
// First name must be alpha characters, white-space, or certain punctuation.
case boolval(preg_match("/^[a-zA-Z'`\-\. ]+$/", $row[COLUMN_FIRSTNAME])):
case boolval(preg_match("/^[a-z'`\-\. ]+$/i", $row[COLUMN_FIRSTNAME])):
self::$error = "Row {$row_num} failed validation for student first name \"{$row[COLUMN_FIRSTNAME]}\".";
return false;
// Last name must be alpha characters, white-space, or certain punctuation.
case boolval(preg_match("/^[a-zA-Z'`\-\. ]+$/", $row[COLUMN_LASTNAME])):
case boolval(preg_match("/^[a-z'`\-\. ]+$/i", $row[COLUMN_LASTNAME])):
self::$error = "Row {$row_num} failed validation for student last name \"{$row[COLUMN_LASTNAME]}\".";
return false;
// Student registration section must be alphanumeric, '_', or '-'.
case boolval(preg_match("/^[a-zA-Z0-9_\-]+$/", $row[COLUMN_SECTION])):
case boolval(preg_match("/^[a-z0-9_\-]+$/i", $row[COLUMN_SECTION])):
self::$error = "Row {$row_num} failed validation for student section \"{$row[COLUMN_SECTION]}\".";
return false;
// Check email address is properly formed.
case boolval(preg_match("/^(?![!#$%'*+\-\/=?^_`{|])[^(),:;<>@\\\"\[\]]+(?<![!#$%'*+\-\/=?^_`{|])@(?:(?!\-)[a-z0-9\-]+(?<!\-)\.)+[a-z]{2,}$/i", $row[COLUMN_EMAIL])):
// Check email address is properly formed. Blank email addresses are also accepted.
case boolval(preg_match("/^$|^(?![!#$%'*+\-\/=?^_`{|])[^(),:;<>@\\\"\[\]]+(?<![!#$%'*+\-\/=?^_`{|])@(?:(?!\-)[a-z0-9\-]+(?<!\-)\.)+[a-z]{2,}$/i", $row[COLUMN_EMAIL])):
self::$error = "Row {$row_num} failed validation for student email \"{$row[COLUMN_EMAIL]}\".";
return false;
}
Expand All @@ -85,24 +89,34 @@ public static function validate_row($row, $row_num) : bool {
* False, as in error found, otherwise. $user_ids is filled when return
* is FALSE.
*
* @param array $rows Data rows to check (presumably an entire couse)
* @param string[] &$user_id Duplicated user ID, when found
* @param array $rows Data rows to check (presumably an entire couse).
* @param string[] &$user_id Duplicated user ID, when found.
* @param string[] &$d_rows Rows containing duplicate user IDs, indexed by user ID.
* @return bool TRUE when all user IDs are unique, FALSE otherwise.
*/
public static function check_for_duplicate_user_ids(array $rows, &$user_ids) : bool {
public static function check_for_duplicate_user_ids(array $rows, &$user_ids, &$d_rows) : bool {
usort($rows, function($a, $b) { return $a[COLUMN_USER_ID] <=> $b[COLUMN_USER_ID]; });

$user_ids = array();
$d_rows = array();
$are_all_unique = true; // Unless proven FALSE
$length = count($rows);
for ($i = 1; $i < $length; $i++) {
$j = $i - 1;
if ($rows[$i][COLUMN_USER_ID] === $rows[$j][COLUMN_USER_ID]) {
$are_all_unique = false;
$user_ids[] = $rows[$i][COLUMN_USER_ID];
$user_id = $rows[$i][COLUMN_USER_ID];
$user_ids[] = $user_id;
$d_rows[$user_id][] = $j;
$d_rows[$user_id][] = $i;
}
}

foreach($d_rows as &$d_row) {
array_unique($d_row, SORT_REGULAR);
}
unset($d_row);

return $are_all_unique;
}

Expand Down
20 changes: 17 additions & 3 deletions student_auto_feed/submitty_student_auto_feed.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ private function get_csv_data() {
if (array_search($course, $this->course_list) !== false) {
if (validate::validate_row($row, $row_num)) {
$this->data[$course][] = $row;
// Rows with blank emails are allowed, but they are being logged.
if ($row[COLUMN_EMAIL] === "") {
$this->log_it("Blank email found for user {$row[COLUMN_USER_ID]}, row {$row_num}.");
}
} else {
$this->invalid_courses[$course] = true;
$this->log_it(validate::$error);
Expand Down Expand Up @@ -228,9 +232,19 @@ private function get_csv_data() {
private function check_for_duplicate_user_ids() {
foreach($this->data as $course => $rows) {
$user_ids = null;
// returns FALSE (as in there is an error) when duplicate IDs are found.
if (validate::check_for_duplicate_user_ids($rows, $user_ids) === false) {
$this->invalid_courses[$course] = true;
$d_rows = null;
// Returns FALSE (as in there is an error) when duplicate IDs are found.
// However, a duplicate ID does not invalidate a course. Instead, the
// first enrollment is accepted, the other enrollments are discarded,
// and the event is logged.
if (validate::check_for_duplicate_user_ids($rows, $user_ids, $d_rows) === false) {
foreach($d_rows as $user_id => $userid_rows) {
$length = count($userid_rows);
for ($i = 1; $i < $length; $i++) {
unset($this->data[$course][$userid_rows[$i]]);
}
}

$msg = "Duplicate user IDs detected in {$course} data: ";
$msg .= implode(", ", $user_ids);
$this->log_it($msg);
Expand Down