diff --git a/CHANGELOG.md b/CHANGELOG.md index 28fb6dc5e..cd60a3ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ For details on the changes in each release, see [the Releases page](https://gith - the `overrides` directory has been renamed to `domain_overrides` - the size of the `recipient` column in the `audit_log` SQL table should be increased from 128 to 768 - the `check-ssh-keys.php` worker should be executed and any invalid SSH keys should be manually removed by an admin and any affected users should be notified +- the `pi_group_expiration_dates` SQL table should be created according to `bootstrap.sql` +- expiration dates should be set for all temporary groups ### 1.6 -> 1.7 diff --git a/resources/lib/UnitySQL.php b/resources/lib/UnitySQL.php index 890ae2d02..597739725 100644 --- a/resources/lib/UnitySQL.php +++ b/resources/lib/UnitySQL.php @@ -7,6 +7,7 @@ /** * @phpstan-type user_last_login array{operator: string, last_login: int} + * @phpstan-type pi_group_expiration_date array{gid: string, expiration_date: int} * @phpstan-type request array{request_for: string, uid: string, timestamp: string} */ class UnitySQL @@ -14,6 +15,7 @@ class UnitySQL private const string TABLE_REQS = "requests"; private const string TABLE_AUDIT_LOG = "audit_log"; private const string TABLE_USER_LAST_LOGINS = "user_last_logins"; + private const string TABLE_PI_GROUP_EXPIRATION_DATES = "pi_group_expiration_dates"; // FIXME this string should be changed to something more intuitive, requires production change public const string REQUEST_BECOME_PI = "admin"; private const int TABLE_AUDIT_LOG_RECIPIENT_MAX_MB_STR_LEN = 768; @@ -276,4 +278,69 @@ public function getUserLastLogin(string $uid): ?int $timestamp_str = $result[0]["last_login"]; return strtotime($timestamp_str); } + + /** + * @throws PDOException + * @throws \Exception if multiple records are found (this should never happen) + */ + public function getPIGroupExpirationDate(string $gid): int|null + { + $table = self::TABLE_PI_GROUP_EXPIRATION_DATES; + $stmt = $this->conn->prepare("SELECT * FROM $table WHERE gid=:gid"); + $stmt->bindParam(":gid", $gid); + $stmt->execute(); + $result = $stmt->fetchAll(); + if (count($result) == 0) { + return null; + } + if (count($result) > 1) { + throw new \Exception("multiple records found with gid '$gid'"); + } + $timestamp_str = $result[0]["expiration_date"]; + return strtotime($timestamp_str); + } + + /** @throws PDOException */ + public function setPIGroupExpirationDate(string $gid, int $expiration_date): void + { + $table = self::TABLE_PI_GROUP_EXPIRATION_DATES; + $stmt = $this->conn->prepare(" + INSERT INTO $table + VALUES (:gid, :expiration_date) + ON DUPLICATE KEY + UPDATE expiration_date=:expiration_date + "); + $stmt->bindParam(":gid", $gid); + $expiration_date_str = date("Y-m-d H:i:s", $expiration_date); + $stmt->bindParam(":expiration_date", $expiration_date_str); + $stmt->execute(); + } + + /** @throws PDOException */ + public function removePIGroupExpirationDate(string $gid): void + { + $table = self::TABLE_PI_GROUP_EXPIRATION_DATES; + $stmt = $this->conn->prepare("DELETE FROM $table WHERE gid=:gid"); + $stmt->bindParam(":gid", $gid); + $stmt->execute(); + } + + /** + * @throws PDOException + * @return pi_group_expiration_date[] + */ + public function getAllPIGroupExpirationDates(): array + { + $stmt = $this->conn->prepare("SELECT * FROM " . self::TABLE_PI_GROUP_EXPIRATION_DATES); + $stmt->execute(); + $records = $stmt->fetchAll(); + $output = []; + foreach ($records as $record) { + array_push($output, [ + "gid" => $record["gid"], + "expiration_date" => strtotime($record["expiration_date"]), + ]); + } + return $output; + } } diff --git a/test/functional/WorkerGroupExpiryTest.php b/test/functional/WorkerGroupExpiryTest.php new file mode 100644 index 000000000..8c6e8f70c --- /dev/null +++ b/test/functional/WorkerGroupExpiryTest.php @@ -0,0 +1,66 @@ +switchUser("NormalPI"); + $gid = UnityGroup::ownerUID2GID($USER->uid); + $pi_group_entry = $LDAP->getPIGroupEntry($gid); + $member_uids_before = $pi_group_entry->getAttribute("memberUid"); + sort($member_uids_before); + $manager_uids_before = $pi_group_entry->getAttribute("managerUid"); + $disabled_before = $pi_group_entry->getAttribute("isDisabled")[0] ?? null; + $expiration_date_before = $SQL->getPIGroupExpirationDate($gid); + try { + $time = time(); + $SQL->setPIGroupExpirationDate($gid, $time); + $output_lines = $this->runGroupExpiryWorker($time - 1); + $this->assertEquals([], $output_lines); + $output_lines = $this->runGroupExpiryWorker($time + 1); + $this->assertEquals( + [ + sprintf( + "group '%s' expired on %s, disabling group and removing members %s", + $gid, + date("Y/m/d", $time), + _json_encode($member_uids_before), + ), + ], + $output_lines, + ); + $output_lines = $this->runGroupExpiryWorker($time + 1); + $this->assertEquals([], $output_lines); + } finally { + $pi_group_entry->setAttribute("memberUid", $member_uids_before); + $pi_group_entry->setAttribute("managerUid", $manager_uids_before); + if ($disabled_before === null) { + if ($pi_group_entry->hasAttribute("isDisabled")) { + $pi_group_entry->removeAttribute("isDisabled"); + } + } else { + $pi_group_entry->setAttribute("isDisabled", $disabled_before); + } + if ($expiration_date_before === null) { + $SQL->removePIGroupExpirationDate($gid); + } else { + $SQL->setPIGroupExpirationDate($gid, $expiration_date_before); + } + $LDAP->userFlagGroups["qualified"]->overwriteMemberUIDs( + array_merge( + $LDAP->userFlagGroups["qualified"]->getMemberUIDs(), + $member_uids_before, + ), + ); + } + } +} diff --git a/test/functional/WorkerUnityCourseTest.php b/test/functional/WorkerUnityCourseTest.php index 187c713e9..56ee1f7bb 100644 --- a/test/functional/WorkerUnityCourseTest.php +++ b/test/functional/WorkerUnityCourseTest.php @@ -8,10 +8,11 @@ class WorkerUnityCourseTest extends UnityWebPortalTestCase private static string $manager_uid = "user2_org1_test"; private static string $manager_mail = "user2@org1.test"; private static string $courseOwnerMail = "user2+cs124@org1.test"; + private static string $expirationDate = "1970/01/02"; public function testCreateCourse() { - global $LDAP, $USER; + global $LDAP, $USER, $SQL; $this->switchUser("Blank"); $this->assertEquals(self::$manager_uid, $USER->uid); $this->assertEquals(self::$manager_mail, $USER->getMail()); @@ -25,13 +26,12 @@ public function testCreateCourse() self::$course_owner_name[1], self::$course_owner_uid, self::$manager_uid, + self::$expirationDate, ]); $stdin_file_path = getPathFromFileHandle($stdin_file); try { executeWorker("unity-course.php", stdinFilePath: $stdin_file_path); // error_log(implode("\n", $output_lines)); - // our LDAP conn doesn't know about changes from subprocess - unset($GLOBALS["ldapconn"]); $this->switchUser("Admin"); $pi_group_entry = $LDAP->getPIGroupEntry(self::$course_gid); $owner_user_entry = $LDAP->getUserEntry(self::$course_owner_uid); @@ -46,10 +46,14 @@ public function testCreateCourse() [$manager->uid], $pi_group_entry->getAttribute("manageruid"), ); + $newExpirationDate = $SQL->getPIGroupExpirationDate(self::$course_gid); + $this->assertNotNull($newExpirationDate); + $this->assertEquals(self::$expirationDate, date("Y/m/d", $newExpirationDate)); } finally { ensurePIGroupDoesNotExist(self::$course_gid); ensureUserDoesNotExist(self::$course_owner_uid); unlink($stdin_file_path); + $SQL->removePIGroupExpirationDate(self::$course_gid); } } } diff --git a/tools/docker-dev/sql/bootstrap.sql b/tools/docker-dev/sql/bootstrap.sql index 22dcd1f7c..39adcc02d 100644 --- a/tools/docker-dev/sql/bootstrap.sql +++ b/tools/docker-dev/sql/bootstrap.sql @@ -23,3 +23,8 @@ CREATE TABLE `requests` ( `uid` varchar(128) NOT NULL, `timestamp` timestamp NOT NULL DEFAULT current_timestamp() ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `pi_group_expiration_dates` ( + `gid` varchar(131) NOT NULL PRIMARY KEY, + `expiration_date` timestamp NOT NULL +) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; diff --git a/workers/group-expiry.php b/workers/group-expiry.php new file mode 100755 index 000000000..cad905a81 --- /dev/null +++ b/workers/group-expiry.php @@ -0,0 +1,36 @@ +#!/usr/bin/env php +description("Disable any PI groups which are past their expiration date.") + ->opt("dry-run", "Print actions without actually doing anything.", type: "boolean") + ->opt("timestamp", "Use this unix timestamp instead of right now", type: "int"); +$args = $cli->parse($argv, true); +$dry_run = $args->getOpt("dry-run", false); +$now = $args->getOpt("timestamp", time()); + +foreach ($SQL->getAllPIGroupExpirationDates() as $record) { + $expiration_date = $record["expiration_date"]; + if ($expiration_date <= $now) { + $group = new UnityGroup($record["gid"], $LDAP, $SQL, $MAILER); + if (!$group->getIsDisabled()) { + printf( + "group '%s' expired on %s, disabling group and removing members %s\n", + $group->gid, + date("Y/m/d", $expiration_date), + _json_encode($group->getMemberUIDs()), + ); + if (!$dry_run) { + $group->disable(); + } + } + } +} + +if ($dry_run) { + echo "[DRY RUN]\n"; +} + diff --git a/workers/unity-course.php b/workers/unity-course.php index 889f6d00e..dd80edb38 100755 --- a/workers/unity-course.php +++ b/workers/unity-course.php @@ -28,6 +28,9 @@ function flatten_attributes(array $attributes): array $manager_uid = trim( readline("Enter the UID of the group manager (example: simonleary_umass_edu): "), ); +$expiration = strtotime(trim( + readline("Enter the expiration date for the course (example: 2026/6/11): "), +)); $org_gid = cn2org($cn); $manager = new UnityUser($manager_uid, $LDAP, $SQL, $MAILER); @@ -62,6 +65,8 @@ function flatten_attributes(array $attributes): array $course_pi_group->approveUser($manager); $course_pi_group->addManagerUID($manager_uid); +$SQL->setPIGroupExpirationDate($course_pi_group->gid, $expiration); + print "LDAP entries created:\n"; print _json_encode( [