diff --git a/docs/docs/rights_engine.md b/docs/docs/rights_engine.md index 23b1807005f529205a8671147f17ad0ecc32a19a..4952e5b62f3cf4f0069df25551f76b87c5442435 100644 --- a/docs/docs/rights_engine.md +++ b/docs/docs/rights_engine.md @@ -86,3 +86,29 @@ Response (HTTP 200) ```js {"message": "xAPI statements successfully stored in LRS", "data": ["6411d628e31a30251d6391f0"]} ``` + +### Anonymized xAPI proxy endpoint + +In addition to the aforementioned endpoint for regular xAPI statements, the rights engine offers an endpoint to submit anonymized statements which are not directly associated with a user. +This works by supplying a "TAN", an arbitrary string sequence, instead of the usual user account information. +Providers have to keep a list / matching of TANs and users, since the rights engine has no way of knowing which user a given TAN belongs to. + +The endpoint is available at `[BASE_URL]/xapi/statements` and accepts valid xAPI statements that contain a TAN in the user object. + +#### Example + +```console +$ curl -i -X POST http://localhost:9001/xapi/tanStatements --data '{ "actor": {"objectType": "Agent", "tan": "u4gueb983fnklerg"}, "timestamp": "2022-03-18T18:07:32+01:00", "context": { "platform": "Moodle", "contextActivities": { "category": { "objectType": "Activity", "definition": { "type": "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/lms", "name": { "de": "moosltest" } }, "id": "https://moosltest.it-services.ruhr-uni-bochum.de" }, "grouping": [ { "objectType": "Activity", "definition": { "type": "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/course", "name": { "de": "Structured Query Language" } }, "id": "https://moosltest.it-services.ruhr-uni-bochum.de/course/view.php?id=2" }, { "objectType": "Activity", "id": "https://moosltest.it-services.ruhr-uni-bochum.de/mod/quiz/view.php?id=2", "definition": { "type": "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/quiz", "name": { "de": "SQL-Test" } } } ] }, "extensions": { "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/course/extensions/roles": "student", "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/quiz_attempt/extensions/timestart": 1647610537, "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/quiz_attempt/extensions/timefinish": 1647623252 } }, "verb": { "id": "https://xapi.elearn.rwth-aachen.de/definitions/lms/verbs/accessed" }, "object": { "objectType": "Activity", "id": "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/course", "definition": { "type": "https://xapi.elearn.rwth-aachen.de/definitions/lms/activities/quiz_attempt", "name": { "de": "SQL-Test" } } }, "result": { "score": { "raw": 0 } } }' -H "Content-Type: application/json" -H "Authorization: Basic ff9b69722ad9fcf3c77cf7a54b31da6c651f546101ded46f03e53a8f008a66de" +``` + +Error response when the endpoint is used without a TAN (HTTP 400) + +```js +{"message": "xAPI statements couldn't be stored in LRS", "data": [{"valid": false, "reason": "TAN missing in statement"}] +``` + +The other responses are analogous to the non-anonymized endpoint. + +Users can associate anonymized data to their account by using the merge tool included in the frontend. +For data to be associated, a user has to give a correct TAN and provider. +In order to prevent abuse by brute-forcing TANs, this tool is rate-limited per user. \ No newline at end of file diff --git a/src/.gitignore b/src/.gitignore index a3c3f3e3fa6a8a24017c2fccd3a4738149a3ee09..d5bdae71688381def56f44406c6e46acbb985444 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -12,4 +12,5 @@ dump.rdb backend/jwtRS256.key backend/jwtRS256.key.pub -static/upload/analytic-tokens-images/*.* \ No newline at end of file +static/upload/analytic-tokens-images/*.* +!static/upload/analytic-tokens-images/.gitkeep \ No newline at end of file diff --git a/src/backend/settings.py b/src/backend/settings.py index ba9191342bdcb3db2371a49c76d6777802b78c5d..edb77243fab6c56265d2d0a14744fea9dd38ac3a 100644 --- a/src/backend/settings.py +++ b/src/backend/settings.py @@ -34,10 +34,13 @@ mimetypes.add_type("text/javascript", ".js", True) SECRET_KEY = "django-insecure-4%wn*pt3vpdgk#==(qbtocdh#-2wr58py(ri=(0a^15&&n*gl9" # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env("DEBUG") +DEBUG = env("DEBUG", default=False) == "True" +DEBUG_PROPAGATE_EXCEPTIONS = env("DEBUG_PROPAGATE_EXCEPTIONS", default=False) == "True" TESTING = env("TESTING", default="False") +ANALYTICS_RESULTS_RETENTION = env("ANALYTICS_RESULTS_RETENTION", default=31) + ALLOWED_HOSTS = ["*"] IDP_ENABLED = env("IDP_ENABLED") diff --git a/src/backend/urls.py b/src/backend/urls.py index 056685de1d97c317ce142bb649ed997769ec9b36..996168754d3719686880c38f7abf91b8f42941c8 100644 --- a/src/backend/urls.py +++ b/src/backend/urls.py @@ -2,10 +2,13 @@ from django.conf import settings from django.contrib import admin from django.shortcuts import redirect, render from django.urls import include, path, re_path +from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view from rest_framework import permissions +import os + from ssoauth import views schema_view = get_schema_view( @@ -31,6 +34,7 @@ def render_angular_de(request): def render_angular_en(request): return render(request, "en/index.html") + urlpatterns = [ path("admin/", admin.site.urls), path("api/v1/consents/", include("consents.urls")), @@ -49,8 +53,9 @@ urlpatterns = [ re_path(r"^saml2/meta(?:data)?/?$", views.MetadataView.as_view(), name="sso-saml2-meta"), re_path(r"^sso-dev/?$", views.DevView.as_view(), name="sso-dev"), - re_path("app/de/", render_angular_de), - re_path("app/en/", render_angular_en), + path("app/de/", render_angular_de), + path("app/en/", render_angular_en), + re_path(r'^app/de/(?P<path>.*)$', serve,{'document_root': os.path.join(settings.BASE_DIR, "frontend/dist/frontend/de")}), re_path(r"^$", detect_lang_redirect), re_path(r"^(?:.*)/?$", detect_lang_redirect), ] diff --git a/src/data_disclosure/tasks.py b/src/data_disclosure/tasks.py index cbedfe2263c0e50ce16b449398e497cad70f96ae..558d48a4844ed7b6e6b5795f82edd480b419b36c 100644 --- a/src/data_disclosure/tasks.py +++ b/src/data_disclosure/tasks.py @@ -42,7 +42,8 @@ class DataDisclosureProcessor: Get all XAPI statements matching a user email address from database. """ collection = lrs_db["statements"] - query = {"actor.mbox": f"mailto:{user_email}"} + query = { "$or" : [ {"actor.account.name": f"{user_email}"}, { "actor.mbox": f"mailto:{user_email}" } ] } + """ query = {"actor.account.name": f"{user_email}"} """ cursor = collection.find(query) return [ ( diff --git a/src/frontend/src/app/consent-management/consentDeclaration.ts b/src/frontend/src/app/consent-management/consentDeclaration.ts index c7672416fb9f0f0647ec03bfa46e479654e6751d..369df5c1da8128a0eb1d3184c8a491bdad991367 100644 --- a/src/frontend/src/app/consent-management/consentDeclaration.ts +++ b/src/frontend/src/app/consent-management/consentDeclaration.ts @@ -111,10 +111,10 @@ export const providerSchemaToUserConsent = (providerSchema: ProviderSchema): Use verbs: group.verbs.map((verb) => ({ ...verb, consented: verb.defaultConsent, - objects: verb.objects.map((object) => ({ + objects: verb.objects ? verb.objects.map((object) => ({ ...object, consented: object.defaultConsent - })) + })) : [] })) })), essential_verbs: providerSchema.essential_verbs diff --git a/src/frontend/src/app/consent-management/provider-settings/provider-setting.component.ts b/src/frontend/src/app/consent-management/provider-settings/provider-setting.component.ts index 35f2f6471d9a1b135d44fff2a49e124b5b652542..850222f2635a3a9e32c1d97482690fffa85bd121 100644 --- a/src/frontend/src/app/consent-management/provider-settings/provider-setting.component.ts +++ b/src/frontend/src/app/consent-management/provider-settings/provider-setting.component.ts @@ -107,7 +107,7 @@ export class PrivacySettingComponent implements OnInit { } }) }) - if (isConfirmationRequired) { + if (isConfirmationRequired && this.previousUserConsent != null) { const onConfirm = () => this.change.emit(this.consentDeclaration) const onCancel = () => (event.source.checked = false) this.openVerbWarningDialog(onConfirm, onCancel, verbId) @@ -161,7 +161,7 @@ export class PrivacySettingComponent implements OnInit { } }) }) - if (isConfirmationRequired) { + if (isConfirmationRequired && this.previousUserConsent != null) { const onConfirm = () => this.change.emit(this.consentDeclaration) const onCancel = () => (event.source.checked = false) this.openObjectWarningDialog(onConfirm, onCancel,verbId) diff --git a/src/frontend/src/app/consent-management/wizard/wizard.component.ts b/src/frontend/src/app/consent-management/wizard/wizard.component.ts index 28767a28f9f9dd781297ab84c502d43ec6da1713..4a434e7b02264d050f72a9ec8ea33d33e6dfec95 100644 --- a/src/frontend/src/app/consent-management/wizard/wizard.component.ts +++ b/src/frontend/src/app/consent-management/wizard/wizard.component.ts @@ -18,12 +18,12 @@ export const extractVerbs = (providerSchema: UserConsent, providerId: ProviderId provider: providerId, id, consented: consented, - objects: JSON.stringify( + objects: objects ? JSON.stringify( objects.map((object) => ({ ...object, consented: object.consented })) - ) + ) : [] })) }) } diff --git a/src/frontend/src/app/merge-data/merge-data.component.html b/src/frontend/src/app/merge-data/merge-data.component.html index 9c7fa9ae56b839e8045fca15c1af09054e25f553..c1ed11ae748d8f2bacb96fce242265719fa39e7d 100644 --- a/src/frontend/src/app/merge-data/merge-data.component.html +++ b/src/frontend/src/app/merge-data/merge-data.component.html @@ -2,25 +2,27 @@ <mat-card style='margin: 8px;'> <mat-card-title i18n="Merge Data | Header entry @@mergeDataHeader"> Merge Data - </mat-card-title> - <mat-card-content> - - <form [formGroup]="mergeForm" (ngSubmit)="submit()"> - <mat-form-field> - <mat-label>Provider</mat-label> - <mat-select formControlName="provider"> - <mat-option *ngFor="let provider of providers" [value]="provider.id">{{ provider.name }}</mat-option> - </mat-select> - </mat-form-field> - <br> - <mat-form-field> - <mat-label>TAN</mat-label> - <input matInput formControlName="tan"> - </mat-form-field> - <br> - <button type="submit" mat-raised-button i18n="Merge Data | Header entry @@mergeDataHeader">Merge Data</button> - </form> - </mat-card-content> + </mat-card-title> + <mat-card-subtitle i18n="@@mergeDataDescription"> + This tool enables you to associate data which has been anonymized by use of a TAN to your account. All entries which match the given TAN and provider will be assigned. + </mat-card-subtitle> + <mat-card-content> + <form [formGroup]="mergeForm" (ngSubmit)="submit()"> + <mat-form-field> + <mat-label>Provider</mat-label> + <mat-select formControlName="provider"> + <mat-option *ngFor="let provider of providers" [value]="provider.id">{{ provider.name }}</mat-option> + </mat-select> + </mat-form-field> + <br> + <mat-form-field> + <mat-label>TAN</mat-label> + <input matInput formControlName="tan"> + </mat-form-field> + <br> + <button type="submit" mat-raised-button i18n="Merge Data | Header entry @@mergeDataHeader">Merge Data</button> + </form> + </mat-card-content> </mat-card> </div> diff --git a/src/frontend/src/locale/messages.de.xlf b/src/frontend/src/locale/messages.de.xlf index e2d8dc375a4febaef3b47139c27b80ea66677ac6..0f8c52018b81756db7b52a8e650774927a9a4777 100644 --- a/src/frontend/src/locale/messages.de.xlf +++ b/src/frontend/src/locale/messages.de.xlf @@ -274,6 +274,15 @@ <note priority="1" from="description"> Header entry </note> <note priority="1" from="meaning">Merge Data </note> </trans-unit> + <trans-unit id="mergeDataDescription" datatype="html"> + <source>This tool enables you to associate data which has been anonymized by use of a TAN to your account. All entries which match the given TAN and provider will be assigned.</source> + <target>Dieses Werkzeug ermöglicht es, Daten, welche mittels einer TAN anonymisiert sind, zum aktuellen Nutzer zuzuordnen. Alle Einträge, welche auf die angegebene TAN und den angegebenen Provider zutreffen, werden zugeordnet.</target> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/merge-data/merge-data.component.html</context> + <context context-type="linenumber">7</context> + </context-group> + </trans-unit> + <trans-unit id="profileProfileDropDown" datatype="html"> <source>Profile</source> <target>Profil</target> diff --git a/src/frontend/src/locale/messages.xlf b/src/frontend/src/locale/messages.xlf index 197fba2c6314644d2cd41f3282ad13ad1a7e4c23..f70d4948c3e0b8c1805050c7b506fd8fd58b7d64 100644 --- a/src/frontend/src/locale/messages.xlf +++ b/src/frontend/src/locale/messages.xlf @@ -1124,6 +1124,13 @@ <note priority="1" from="description"> Header entry </note> <note priority="1" from="meaning">Merge Data </note> </trans-unit> + <trans-unit id="mergeDataDescription" datatype="html"> + <source>This tool enables you to associate data which has been anonymized by use of a TAN to your account. All entries which match the given TAN and provider will be assigned.</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/merge-data/merge-data.component.html</context> + <context context-type="linenumber">7</context> + </context-group> + </trans-unit> <trans-unit id="applicationTokens" datatype="html"> <source>Application Tokens</source> <context-group purpose="location"> diff --git a/src/providers/views.py b/src/providers/views.py index b4f6948d15f032c3924514831a95a56d98a570f0..3c2cee522b5a5e1d86a0bacdaa7d7c60340e2e29 100644 --- a/src/providers/views.py +++ b/src/providers/views.py @@ -464,7 +464,7 @@ class GetProviderData(APIView): {"$or": [ {"actor.mbox": {"$exists": False}}, {"$and": [ - {"actor.mbox": {"$exists": False}}, + {"actor.mbox": {"$exists": True}}, {"actor.mbox": {"$regex": "^mailto"}}, ]}, # also query for system statements added by relevant providers @@ -490,6 +490,7 @@ class GetProviderData(APIView): ] } ) + print(query) cursor = collection.find(query).limit(page_size) data = { "verbs": list(set(active_verbs)), @@ -794,11 +795,12 @@ class RunResultsRetention(APIView): AnalyticsToken.objects.get(key=token) collection = lrs_db["results"] query = {"analytics_token": token} - cursor = collection.find(query, sort=[("created_at", -1)]).skip(31) - document_ids = [document["_id"] for document in cursor] - - query = {"_id": {"$in": document_ids}} - cursor = collection.delete_many(query) + for context_id_distinct in collection.distinct("context_id",query): + cursor = collection.find({"analytics_token": token, context_id: context_id_distinct}, sort=[("created_at", -1)]).skip(settings.ANALYTICS_RESULTS_RETENTION) + document_ids = [document["_id"] for document in cursor] + + query = {"_id": {"$in": document_ids}} + cursor = collection.delete_many(query) return Response( {"deleted_documents": cursor.deleted_count}, status=status.HTTP_200_OK diff --git a/src/requirements.txt b/src/requirements.txt index 8d3937d60d99cbbf943582a65c606add4975b435..cd6f13607c3a6b9d92c2fdf137a0bdc63ac60849 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -79,7 +79,7 @@ python-crontab==2.6.0 python-dateutil==2.8.2 python3-saml==1.15.0 pytz==2022.1 -PyYAML==5.4.1 +PyYAML==6.0.1 pyyaml_env_tag==0.1 ranger-fm==1.9.3 redis==4.3.4 diff --git a/src/static/upload/analytic-tokens-images/.gitkeep b/src/static/upload/analytic-tokens-images/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/static/xapi_statement.schema.json b/src/static/xapi_statement.schema.json index e2a369185b5bb6b568faf3dee1d530effdf5dfcc..72577fd22d2ea8f16a00719665556d252c2357b5 100644 --- a/src/static/xapi_statement.schema.json +++ b/src/static/xapi_statement.schema.json @@ -36,7 +36,7 @@ }, "required": ["actor", "verb", "object"], "$defs": { - "iri": { "type": "string", "pattern": "^[a-zA-Z0-9_]+:" }, + "iri": { "type": "string", "pattern": "^[a-zA-Z0-9_]+" }, "languageMap": { "type": "object" }, "agent": { "type": "object", @@ -111,7 +111,7 @@ }, "required": ["id"] }, - "mbox": { "type": "string", "pattern": "mailto:[a-z0-9._%+!$&*=^|~#%{}/-]+@([a-z0-9-]+.){1,}([a-z]{2,22})" }, + "mbox": { "type": "string", "pattern": "(mailto:[a-z0-9._%+!$&*=^|~#%{}/-]+@([a-z0-9-]+.){1,}([a-z]{2,22}))|(system:[0-9]+)" }, "mbox_sha1sum": { "type": "string", "pattern": "^\b[0-9a-f]{5,40}$" }, "account": { "type": "object", diff --git a/src/xapi/tests/test_xapi_statement_validation.py b/src/xapi/tests/test_xapi_statement_validation.py index ad8c8f265f615eb947e6b857d558d01e75073a0e..a1079271ac153d999eb8e6e9b434c4dbf53d9f8f 100644 --- a/src/xapi/tests/test_xapi_statement_validation.py +++ b/src/xapi/tests/test_xapi_statement_validation.py @@ -7,7 +7,7 @@ from jsonschema import ValidationError, validate PROJECT_PATH = os.path.abspath(os.path.dirname(__name__)) -class TestxAPIStatementeValidation(TestCase): +class TestxAPIStatementValidation(TestCase): def setUp(self): with open(os.path.join(PROJECT_PATH, "static/xapi_statement.schema.json")) as f: self.schema = json.load(f) @@ -167,3 +167,24 @@ class TestxAPIStatementeValidation(TestCase): validate(statement, self.schema) except ValueError: assert False + + def test_validate_statement_with_tan(self): + statement = { + "actor": { + "objectType": "Agent", + "tan": "u4gueb983fnklerg", + }, + "verb": { + "id": "http://adlnet.gov/expapi/verbs/failed", + "display": {"en-US": "failed"}, + }, + "object": { + "id": "https://example.adlnet.gov/AUidentifier", + "objectType": "Activity", + }, + } + + try: + validate(statement, self.schema) + except ValueError: + assert False \ No newline at end of file diff --git a/src/xapi/tests/tests.py b/src/xapi/tests/tests.py index 4467c61f5627b96c6fad2bd447ec25ee79594841..5e764908923f95d9cd69d0993fa4a5c1325ccc93 100644 --- a/src/xapi/tests/tests.py +++ b/src/xapi/tests/tests.py @@ -197,9 +197,13 @@ class XAPITestCase(TestCase): "actor": {"mbox": f"system:{provider.id}"}, "verb": {"id": "some_id"}, "object": { - "id": "some_other_id", + "id": "someOtherId", "objectType": "Activity", - "definition": "object_definition", + "definition": { + "name": { + "de-DE": "Testobjekt" + } + }, }, "timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"), }, @@ -506,7 +510,7 @@ class TestxAPIStatementActorAccount(BaseTestCase): { "valid": False, "accepted": False, - "reason": "User missing in statement", + "reason": "'name' is a required property", } ], }, diff --git a/src/xapi/views.py b/src/xapi/views.py index 4af3b782062a9b77b3e71a87599925342ff3a361..31a7f138295c3d236e5f7bf67e485678102994d2 100644 --- a/src/xapi/views.py +++ b/src/xapi/views.py @@ -52,7 +52,7 @@ def process_statement(x_api_statement, provider, latest_schema): Process xAPI statement by checking for validation errors and user consent settings. """ try: - validate(x_api_statement, x_api_statement) + validate(x_api_statement, schema) except ValidationError as e: return {"valid": False, "accepted": False, "reason": e.message} @@ -164,7 +164,7 @@ def process_tan_statement(x_api_statement): Process xAPI statement by checking for validation errors. """ try: - validate(x_api_statement, x_api_statement) + validate(x_api_statement, schema) except ValidationError as e: return {"valid": False, "reason": e.message}