[FEATURE] LTI: Add AGS consumer services and deep linking content selection - #11835
[FEATURE] LTI: Add AGS consumer services and deep linking content selection#11835Saaweel wants to merge 20 commits into
Conversation
(cherry picked from commit 9f2e884)
(cherry picked from commit 0252213) # Conflicts: # components/ILIAS/LTIConsumer/resources/ltidlreturn.php
(cherry picked from commit 0be2f32)
(cherry picked from commit 892e28a)
(cherry picked from commit 13d7d22)
AGS get-score results were returning the stored usr_ident (e.g. email) as userId instead of the LTI sub sent during launch, causing tools to fail to match grades to users. Additionally, scores were returned on a 0-1 scale instead of the original scale submitted by the tool.
6747652 to
4584211
Compare
|
|
||
| $output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'iss', ilObjLTIConsumer::getIliasHttpPath()) . "\n"; | ||
| $output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', $this->object->getProvider()->getProviderUrl()) . "\n"; | ||
| $output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', htmlspecialchars($this->getTargetLinkUri(), ENT_QUOTES)) . "\n"; |
There was a problem hiding this comment.
Can't we use a Refinery transformation instead?
| $output = '<form id="lti_launch_form" name="lti_launch_form" action="' . $this->object->getProvider()->getInitiateLogin() . '" method="post" target="' . $target . '" encType="application/x-www-form-urlencoded">'; | ||
| $output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'iss', ilObjLTIConsumer::getIliasHttpPath()) . "\n"; | ||
| $output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', $this->object->getProvider()->getProviderUrl()) . "\n"; | ||
| $output .= sprintf('<input type="hidden" name="%s" value="%s" />', 'target_link_uri', htmlspecialchars($this->getTargetLinkUri(), ENT_QUOTES)) . "\n"; |
There was a problem hiding this comment.
Can't we use a Refinery transformation instead?
There was a problem hiding this comment.
Is it ensured that all the existing values here surrounding the new lines are already trustworthy (checked, escaped)?
| $DIC->http()->saveResponse($DIC->http()->response()->withStatus(400)); | ||
| $DIC->http()->sendResponse(); | ||
| $DIC->http()->close(); | ||
| exit; |
There was a problem hiding this comment.
The exit is not needed here.
| ); | ||
| $DIC->http()->sendResponse(); | ||
| $DIC->http()->close(); | ||
| exit; |
There was a problem hiding this comment.
The exit is not needed here.
| $DIC->http()->close(); | ||
| } | ||
|
|
||
| exit; |
There was a problem hiding this comment.
The exit is not needed here.
| return true; | ||
| } | ||
|
|
||
| if (strpos($filter, '@') === false && strpos($candidate, $filter . '@') === 0) { |
There was a problem hiding this comment.
We can use str_starts_with here.
| . ' AND usr_id = ' . $ilDB->quote($userId, 'integer'); | ||
| $res = $ilDB->query($query); | ||
| while ($row = $ilDB->fetchAssoc($res)) { | ||
| $user = new ilObjUser($userId); |
There was a problem hiding this comment.
Move new ilObjUser($userId) out of the loop, since it does not depend on $row.
| return $res; | ||
| } | ||
|
|
||
| protected function startDeepLinkingCmd() |
There was a problem hiding this comment.
| protected function startDeepLinkingCmd() | |
| protected function startDeepLinkingCmd(): never |
| /** | ||
| * Build the "Deep linking" UI (button + modal with iframe). | ||
| * | ||
| * @param string $target_gui_class GUI class that has startDeepLinkingCmd() |
There was a problem hiding this comment.
This is a contradiction to the explicitly used PHP type.
| ilSession::set('lti13_login_data', $data); | ||
|
|
||
| if ($isDlMode) { | ||
| if ($deploymentId === null || $refId <= 0) { |
There was a problem hiding this comment.
$deploymentId is always an int here, so the check is IMO obsolete.
mjansenDatabay
left a comment
There was a problem hiding this comment.
Dear @Saaweel,
Thank you a lot for this important contribution to the LTI component of ILIAS. This is a huge step forward.
Besides my inline comments (above), I mainly (but not exclusively) focussed on the AGS-related changes in my review remarks. Other members of the Technical Board will (most probably) also add some review feedback here.
At first, completing the consumer-side AGS surface is a significant piece of work, and the result is
noticeably more usable than what release_11 ships today. I looked into the AGS specification (https://www.imsglobal.org/spec/lti-ags/v2p0) myself a while back, and I'm glad that you've really improved things here.
Some of my remarks not only address the changes in this PR but point to the current state given after this PR is integrated.
A few coding style/architecture issues I would like to mention:
- You introduced a lot of superglobals. Our guidelines recommend: "The code contains no
$_GET/$_POST/$_REQUEST/$_SESSION" - You introduced a lot of static methods, which do not align with our efforts to reduce the amount of global state in ILIAS in the last years.
- There are some new
$DICusages, even inline in the middle of the method body. - A few PHP types in class methods are missing or not well-documented (with PHPDocs).
docs/development/php-coding-style.mdrequires properties and variables in underscore-case. The new code is consistently camelCase ($contextId,$itemId,$clientId,$scoreMaximum,$objIdOrPseudo,$lineItemUrl, …).
Please answer the following questions:
- Score POST answers 200 instead of the 204 required by AGS 2.0 §3.4.3.1, with a comment pointing at a Moodle bug. Is that bug still present in currently supported Moodle versions? If so, could the deviation become a per-provider setting rather than a global one, so conformant tools get a conformant platform?
-
ilObjLTIConsumer::getDeepLinkingUserIdentifier()/getDeepLinkingUserEmail()return the raw e-mail address whenever it passesFILTER_VALIDATE_EMAIL, which bypasses the provider's configuredprivacy_idententirely. Why is the configured pseudonymisation not honoured in the deep-linking flow? -
ilLTIConsumerGradeSynchronizationGUI.phpextends the multi-actor grade report fromhasOutcomesAccess()tohasOutcomesAccess() || hasWriteAccess(), andilLTIConsumerGradeSynchronizationTableGUI.phpnow resolvesilObjUser::_lookupFullname(). Together this shows real names of all participants to everyone with write permission. What is the data-protection rationale, and how does it interact with a provider configured for pseudonymous identifiers? - From my understanding, ltiauth.php drops
$provider->getContentItemUrl() == $redirectUrifrom the deep-linking condition, so any login initiation carrying adeployment_idin the message hint is routed into the content-selection flow. What prevents a regular resource-link launch from ending up there?
Please consider the following suggestions. You do not need to follow those, but please indicate
shortly why you prefer to do otherwise:
- Model
activityProgressandgradingProgressas PHP enums. They currently exist as string literals duplicated acrossisValidActivityProgress(),isValidGradingProgress(), the LP mapping in Scores and the mapping inilLPStatusLtiOutcome, which is exactly the kind of duplication that lets theFailedmapping drift (see change requests). - Introduce a
LineItemvalue object plus a repository with a constructor-injectedilDBInterface, and let the four resource classes be thin HTTP adapters over it. As it stands, the AGS classes andilLPStatusLtiOutcomecontain 13global $DICstatements and hand-concatenated SQL inside HTTP handlers and inside an abstract base class, so none of the new logic can be unit-tested. Recent components (e.g. MetaData, Certificate, ...) are good references for the repository/value-object split. - Give the access token a type:
checkTool(): ?objectandobject $tokenpush property access on an untyped stdClass into five call sites, andgetClientIdFromToken()is copied verbatim into three classes. A smallAccessTokenvalue object withgetClientId()andgetScopes()would remove all three copies. - Declare the array shapes (
@return array{...}/@param). - Drop the runtime
tableExists('lti_consumer_lineitems')guards. A database update step guarantees the schema. The guards turn a broken installation into a silent 500 or an empty result instead of an error. - Consider not using exception codes as HTTP status codes (
$response->setCode($code >= 400 && $code < 600 ? $code : 500), repeated in four classes). A small set of dedicated exception types, or returning a response object, keeps the mapping explicit and testable.
Please implement the following changes:
- Make the Line Item service conformant to AGS 2.0 §3.2:
- From my understanding, POST must answer 400 when
labelis missing or blank (§3.2.7) and whenscoreMaximumis missing or not strictly greater than 0 (§3.2.8). ilLTIConsumerGradeServiceLineItems.php (in line 152-154) silently substitutes'Line Item <id>'and1, so a malformed tool request creates a line item the tool did not ask for. - From my understanding, POST must answer 404 when
resourceLinkIddoes not denote a resource link that exists in the context and is owned by the same tool (§3.2.9). It is currently stored unchecked. - When
limittruncates a container response,Link: <...>; rel="next"MUST be sent (§3.2.4 for line items, §3.3.6 for results). ilLTIConsumerGradeServiceLineItems.php (line 231) and ilLTIConsumerGradeServiceResults.php (line 194) justarray_slice(). - DELETE on a line item backed by an ILIAS object must either delete the line item or answer 405. ilLTIConsumerGradeServiceLineItem.php resets
scoreMaximumto 1 and answers 200, so the tool is told the deletion succeeded while the line item is still advertised by GET lineitems. GET <lineitem>/resultsmust return the results of that line item (§3.3.3, §3.3.5). ilLTIConsumerGradeServiceResults.php (line 93-95) returns[]unconditionally for tool-created line items, even though the Scores endpoint accepts scores for them, which also makes thescore_maximumit reads on line 91 dead.
- From my understanding, POST must answer 400 when
- Make the Score service conformant to AGS 2.0 §3.4:
- From my understanding, §3.4.9 requires that a score whose
timestampis older than the newest one on record MUST NOT update the result. ilLTIConsumerGradeServiceScores.php inserts every payload andilLPStatus::writeStatus()overwrites the learning progress unconditionally, so a retried or delayed tool request can move a learner backwards. - §3.4.4 requires that an absent or
nullscoreGivenclears the previously stored value score. ilLTIConsumerGradeServiceScores.php only writes when$result !== null, so the oldlti_consumer_resultsrow survives. - §3.4.8 defines
gradingProgress: Failedas "the grading could not complete", i.e. a grading error, not a failed learner. ilLTIConsumerGradeServiceScores.php and ilLPStatusLtiOutcome.php map it toLP_STATUS_FAILED_NUM, which puts a wrong result into the learning progress and, via ilLPStatusLtiOutcome, also into every aggregating container. - §3.4.4 explicitly requires supporting
scoreGivengreater thanscoreMaximum. The derived LP percentage on ilLTIConsumerGradeServiceScores.php (line 181) can therefore exceed 100 and must be IMO clamped.
- From my understanding, §3.4.9 requires that a score whose
- Unify how the line item URL is built.
ilLTIConsumerGradeServiceLineItem::getServiceRootUrl()reconstructs the base URL from$_SERVER['HTTP_HOST']and$_SERVER['SCRIPT_NAME'], whileilObjLTIConsumer::buildLaunchParametersLTI13()usesgetIliasHttpPath(). AGS §3.3.4.3 requires theida tool reads back and thelineitemclaim it received at launch to be identical, and §3.2.3 asks for a stable URL. - If possible, restore validation-before-persistence in ltiauth.php. The PR moves
ilSession::set('lti13_login_data', $data)ahead of the message-hint parsing, so unvalidated request data is written to the session even for requests that are rejected two lines later. - If possible, validate the redirect target in ltiauth.php.
$url = "../../../" . base64_decode($redirect_uri)builds a redirect from tool-supplied data with no validation, which could IMO be an open redirect and allows path traversal out of the intended directory. As far as I understand the code, there is exactly the right check in ilObjLTIConsumerGUI.php.
On how to proceed:
-The spec-conformance remarks on line items and scores and security-related remarks MUST be resolved prior to merging, as they are the ones I consider blocking for a mid-release integration.
- The architecture suggestions SHOULD be addressed soon on the
trunk, probably not until Friday (currently planned release/integration). It would be good to agree on fixing those before you start reworking.
Could you give us feedback on how long you expect the change requests to take?
Best regards,
@mjansenDatabay
on behalf of the Technical Board
Summary
This PR completes the LTI 1.3 consumer-side Assignment and Grade Services (AGS) flow and makes Dynamic Registration / Deep Linking content selection usable from an LTI consumer object.
Validation
Reviewer Focus