diff --git a/classes/hook/additional_user_restriction.php b/classes/hook/additional_user_restriction.php new file mode 100644 index 0000000..4eb1f06 --- /dev/null +++ b/classes/hook/additional_user_restriction.php @@ -0,0 +1,153 @@ +. + +namespace local_ai_manager\hook; + +use context; +use local_ai_manager\base_purpose; +use local_ai_manager\local\userinfo; + +/** + * Hook for allowing other plugins to further restrict the access to use the AI tools through the local_ai_manager. + * + * This hook will be dispatched whenever a user tries to send a request to the AI tool via local_ai_manager. + * + * @package local_ai_manager + * @copyright 2024 ISB Bayern + * @author Philipp Memmel + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\core\attribute\label('Allows plugins to further restrict the use of AI tools through local_ai_manager.')] +#[\core\attribute\tags('local_ai_manager')] +class additional_user_restriction { + + /** @var bool If access to the AI tool should be granted or not */ + private bool $allowed = true; + /** @var int The corresponding HTTP status code, 200 if access is being granted */ + private int $code = 200; + /** @var string A localized error message that is being shown to the user in case of an error */ + private string $message = ''; + /** @var string Optional debug info */ + private string $debuginfo = ''; + + /** + * Constructor for the hook. + */ + public function __construct( + /** @var userinfo The userinfo object of the user that tries to access an AI tool */ + private readonly userinfo $userinfo, + /** @var ?context The context or null if no context has been specified */ + private readonly ?context $context, + /** @var base_purpose The purpose which is being tried to use */ + private readonly base_purpose $purpose, + ) { + } + + /** + * Getter for the userinfo object. + * + * @return userinfo The userinfo object of the user trying to access the AI tool + */ + public function get_userinfo(): userinfo { + return $this->userinfo; + } + + /** + * Getter for the current context. + * + * @return ?context the context from which the AI tool is being tried to access + */ + public function get_context(): ?context { + return $this->context; + } + + /** + * Getter for the currently used purpose. + * + * @return base_purpose The purpose being used + */ + public function get_purpose(): base_purpose { + return $this->purpose; + } + + /** + * Set if the access for the current user should be denied or not. + * + * If access is granted, you do not need to do anything, because it is the default. + * If access should be restricted, pass $allowed = false and also provide a code !== 200 as well as an + * already localized error message that is being used as feedback to the user + * + * @param bool $allowed true if access is granted, false otherwise + * @param int $code an HTTP status code that should be returned to the user, will be set to 200 in case of + * $allowed = true + * @param string $message localized message that should be shown to the user in case of restricted access + * @param string $debuginfo optional debug info + */ + public function set_access_allowed(bool $allowed, int $code = 0, string $message = '', string $debuginfo = ''): void { + if ($allowed) { + $this->allowed = true; + $this->code = 200; + return; + } + + $this->allowed = false; + if ($code === 200) { + throw new \coding_exception('You have to provide a different code than 200 in case of a denied access.'); + } + $this->code = $code; + if (empty($message)) { + throw new \coding_exception('You have to provide a message in case of a denied access.'); + } + $this->message = $message; + $this->debuginfo = $debuginfo; + } + + /** + * Standard getter for the allowed attribute. + * + * @return bool if access is being granted or not + */ + public function is_allowed(): bool { + return $this->allowed; + } + + /** + * Standard getter for the corresponding HTTP status code. + * + * @return int the HTTP status code + */ + public function get_code(): int { + return $this->code; + } + + /** + * Standard getter for the message in case of an error. + * + * @return string the error message + */ + public function get_message(): string { + return $this->message; + } + + /** + * Standard getter for the optional debug info. + * + * @return string the debug info + */ + public function get_debuginfo(): string { + return $this->debuginfo; + } +} diff --git a/classes/manager.php b/classes/manager.php index 807507c..32295d6 100644 --- a/classes/manager.php +++ b/classes/manager.php @@ -30,6 +30,7 @@ use core_plugin_manager; use local_ai_manager\event\get_ai_response_failed; use local_ai_manager\event\get_ai_response_succeeded; +use local_ai_manager\hook\additional_user_restriction; use local_ai_manager\local\config_manager; use local_ai_manager\local\connector_factory; use local_ai_manager\local\prompt_response; @@ -154,6 +155,17 @@ public function perform_request(string $prompttext, array $options = []): prompt } } + // Provide an additional hook for further limiting access. + $context = empty($options['contextid']) ? null : context::instance_by_id($options['contextid']); + $restrictionhook = new additional_user_restriction($userinfo, $context, $this->purpose); + \core\di::get(\core\hook\manager::class)->dispatch($restrictionhook); + if (!$restrictionhook->is_allowed()) { + return prompt_response::create_from_error( + $restrictionhook->get_code(), + $restrictionhook->get_message(), + $restrictionhook->get_debuginfo()); + } + if (intval($this->configmanager->get_max_requests($this->purpose, $userinfo->get_role())) === 0) { return prompt_response::create_from_error(403, get_string('error_http403usertype', 'local_ai_manager'), ''); } diff --git a/tests/ai_manager_utils_test.php b/tests/ai_manager_utils_test.php index 08d3335..fa336ea 100644 --- a/tests/ai_manager_utils_test.php +++ b/tests/ai_manager_utils_test.php @@ -38,7 +38,7 @@ public function test_get_next_free_itemid(): void { $this->resetAfterTest(); $user = $this->getDataGenerator()->create_user(); - $this->assertEquals(1, ai_manager_utils::get_next_free_itemid('block_ai_chat', 12)); + $this->assertEquals(1, ai_manager_utils::get_next_free_itemid('block_ai_control', 12)); $record = new stdClass(); $record->userid = $user->id; @@ -47,7 +47,7 @@ public function test_get_next_free_itemid(): void { $record->modelinfo = 'testmodel-3.5'; $record->prompttext = 'some prompt'; $record->promptcompletion = 'some prompt response'; - $record->component = 'block_ai_chat'; + $record->component = 'block_ai_control'; $record->contextid = 12; $record->itemid = 5; $record->timecreated = time(); @@ -60,13 +60,13 @@ public function test_get_next_free_itemid(): void { $record->modelinfo = 'anothertestmodel-4.0'; $record->prompttext = 'some other prompt'; $record->promptcompletion = 'some prompt response'; - $record->component = 'block_ai_chat'; + $record->component = 'block_ai_control'; $record->contextid = 12; $record->itemid = 7; $record->timecreated = time(); $DB->insert_record('local_ai_manager_request_log', $record); - $this->assertEquals(8, ai_manager_utils::get_next_free_itemid('block_ai_chat', 12)); + $this->assertEquals(8, ai_manager_utils::get_next_free_itemid('block_ai_control', 12)); $record = new stdClass(); $record->userid = $user->id; @@ -75,16 +75,16 @@ public function test_get_next_free_itemid(): void { $record->modelinfo = 'anothertestmodel-4.0'; $record->prompttext = 'some other prompt'; $record->promptcompletion = 'some prompt response'; - $record->component = 'block_ai_chat'; + $record->component = 'block_ai_control'; // Other context id, so this record should not be relevant. $record->contextid = 23; $record->itemid = 10; $record->timecreated = time(); $DB->insert_record('local_ai_manager_request_log', $record); - $this->assertEquals(8, ai_manager_utils::get_next_free_itemid('block_ai_chat', 12)); + $this->assertEquals(8, ai_manager_utils::get_next_free_itemid('block_ai_control', 12)); $this->assertEquals(1, ai_manager_utils::get_next_free_itemid('mod_ai', 23)); - $this->assertEquals(11, ai_manager_utils::get_next_free_itemid('block_ai_chat', 23)); + $this->assertEquals(11, ai_manager_utils::get_next_free_itemid('block_ai_control', 23)); } /** diff --git a/tests/manager_test.php b/tests/manager_test.php index 7c48180..d7beb37 100644 --- a/tests/manager_test.php +++ b/tests/manager_test.php @@ -141,6 +141,10 @@ public function test_perform_request(array $configuration, int $expectedcode, st \core\di::set(config_manager::class, $configmanager); \core\di::set(connector_factory::class, $connectorfactory); + // We disable the hook here so we have a defined setup for this unit test. + // The hook callbacks should be tested whereever the callback is being implemented. + $this->redirectHook(\local_ai_manager\hook\additional_user_restriction::class, function() {}); + $manager = new manager('chat'); // Now we finally finished our setup. Call the perform_request method and check the result. @@ -156,6 +160,7 @@ public function test_perform_request(array $configuration, int $expectedcode, st } else { $this->assertEquals($result->get_errormessage(), $message); } + $this->stopHookRedirections(); } /** diff --git a/version.php b/version.php index 87c9ece..6eee8d6 100644 --- a/version.php +++ b/version.php @@ -24,7 +24,7 @@ */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024122300; +$plugin->version = 2024122900; $plugin->requires = 2024042200; $plugin->release = '0.0.3'; $plugin->component = 'local_ai_manager';