From d03a66de87a687d9f70381bfedfffa356d2d46dc Mon Sep 17 00:00:00 2001 From: rgraber Date: Mon, 21 Jul 2025 08:56:53 -0400 Subject: [PATCH 1/4] temp: colletor user stuff --- .../0002_collectorgroup_collector.py | 55 +++++++++++++++++++ kobo/apps/kobo_auth/models.py | 41 +++++++++++++- .../apps/api/viewsets/xform_list_api.py | 4 +- kpi/authentication.py | 37 +++++++++++++ kpi/models/asset.py | 3 + 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 kobo/apps/kobo_auth/migrations/0002_collectorgroup_collector.py diff --git a/kobo/apps/kobo_auth/migrations/0002_collectorgroup_collector.py b/kobo/apps/kobo_auth/migrations/0002_collectorgroup_collector.py new file mode 100644 index 0000000000..fe0900da08 --- /dev/null +++ b/kobo/apps/kobo_auth/migrations/0002_collectorgroup_collector.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.15 on 2025-07-18 19:26 + +from django.db import migrations, models +import django.db.models.deletion +import kpi.fields.kpi_uid + + +class Migration(migrations.Migration): + + dependencies = [ + ('kobo_auth', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CollectorGroup', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('uid', kpi.fields.kpi_uid.KpiUidField(_null=False, uid_prefix='cg')), + ], + ), + migrations.CreateModel( + name='Collector', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('uid', kpi.fields.kpi_uid.KpiUidField(_null=False, uid_prefix='c')), + ('name', models.CharField(blank=True, null=True)), + ('token', models.CharField(unique=True)), + ( + 'group', + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name='collectors', + to='kobo_auth.collectorgroup', + ), + ), + ], + ), + ] diff --git a/kobo/apps/kobo_auth/models.py b/kobo/apps/kobo_auth/models.py index 2301c65be6..475fa1b8ab 100644 --- a/kobo/apps/kobo_auth/models.py +++ b/kobo/apps/kobo_auth/models.py @@ -1,12 +1,17 @@ from django.conf import settings -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractUser, AnonymousUser from django_request_cache import cache_for_request +from django.db import models +from rest_framework.authtoken.models import Token +from xlwt.ExcelFormulaLexer import false_pattern +from kobo.apps.openrosa.apps.logger.models import XForm from kobo.apps.openrosa.libs.constants import ( OPENROSA_APP_LABELS, ) from kobo.apps.openrosa.libs.permissions import get_model_permission_codenames from kobo.apps.organizations.models import Organization, create_organization +from kpi.fields import KpiUidField from kpi.utils.database import update_autofield_sequence, use_db from kpi.utils.permissions import is_user_anonymous @@ -87,3 +92,37 @@ def sync_to_openrosa_db(self): unique_fields=['pk'], ) update_autofield_sequence(User) + +class CollectorUser(AnonymousUser): + @property + def is_authenticated(self): + # Always return True. This is a way to tell if + # the user has been authenticated in permissions + return True + + assets = [] + + def has_perm(self, perm, obj = ...): + if not isinstance(obj, XForm) or perm != 'report_xform': + return False + return obj.kpi_asset_uid in self.assets + + + +class CollectorGroup(models.Model): + uid = KpiUidField(uid_prefix='cg') + +class Collector(models.Model): + uid = KpiUidField(uid_prefix='c') + name = models.CharField(blank=True, null=True) + token = models.CharField(unique=True) + group = models.ForeignKey( + CollectorGroup, on_delete=models.PROTECT, related_name='collectors' + ) + + def save(self, *args, **kwargs): + if not self.token: + self.token = Token.generate_key() + return super().save(*args, **kwargs) + + diff --git a/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py b/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py index e612e1ec6f..6315d8c5df 100644 --- a/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py +++ b/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py @@ -25,7 +25,7 @@ XFormListSerializer, XFormManifestSerializer, ) -from kpi.authentication import DigestAuthentication +from kpi.authentication import DigestAuthentication, CollectorTokenAuthentication from kpi.constants import PERM_MANAGE_ASSET from kpi.models.object_permission import ObjectPermission from ..utils.rest_framework.viewsets import OpenRosaReadOnlyModelViewSet @@ -47,6 +47,7 @@ def __init__(self, *args, **kwargs): # previously hard-coded authentication classes are included first authentication_classes = [ DigestAuthentication, + CollectorTokenAuthentication, ] self.authentication_classes = authentication_classes + [ auth_class @@ -85,6 +86,7 @@ def filter_queryset(self, queryset): # If no username is specified, the request must be authenticated if self.request.user.is_anonymous: # raises a permission denied exception, forces authentication + self.permission_denied(self.request) else: # Return all the forms the currently-logged-in user can access, diff --git a/kpi/authentication.py b/kpi/authentication.py index 8f358b0566..0fdbca25f0 100644 --- a/kpi/authentication.py +++ b/kpi/authentication.py @@ -14,7 +14,10 @@ from oauth2_provider.contrib.rest_framework import OAuth2Authentication as OPOAuth2Authentication from kobo.apps.audit_log.mixins import RequiresAccessLogMixin +from kobo.apps.kobo_auth.models import Collector, CollectorUser from kpi.mixins.mfa import MfaBlockerMixin +from rest_framework import exceptions + class BasicAuthentication(MfaBlockerMixin, DRFBasicAuthentication, RequiresAccessLogMixin): @@ -172,3 +175,37 @@ def authenticate(self, request): user, creds = result self.create_access_log(request, user, 'oauth2') return user, creds + + +class CollectorTokenAuthentication(TokenAuthentication): + def authenticate(self, request): + auth = get_authorization_header(request).split() + + if not auth or auth[0].lower() != self.keyword.lower().encode(): + return None + + if len(auth) == 1: + msg = 'Invalid token header. No credentials provided.' + raise exceptions.AuthenticationFailed(msg) + elif len(auth) > 2: + msg = 'Invalid token header. Token string should not contain spaces.' + raise exceptions.AuthenticationFailed(msg) + + try: + token = auth[1].decode() + except UnicodeError: + msg = 'Invalid token header. Token string should not contain invalid characters.' + raise exceptions.AuthenticationFailed(msg) + + return self.authenticate_credentials(token) + + + def authenticate_credentials(self, key): + try: + collector = Collector.objects.get(token=key) + server_user = CollectorUser() + group = collector.group + server_user.assets = group.assets.values_list('uid', flat=True) + return server_user, key + except Collector.DoesNotExist: + raise exceptions.AuthenticationFailed('Invalid token.') diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 2be5b39aea..8daddb359f 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -17,6 +17,8 @@ from formpack.utils.flatten_content import flatten_content from formpack.utils.json_hash import json_hash from formpack.utils.kobo_locking import strip_kobo_locking_profile + +from kobo.apps.kobo_auth.models import CollectorGroup from kobo.apps.reports.constants import DEFAULT_REPORTS_KEY, SPECIFIC_REPORTS_KEY from kobo.apps.subsequences.advanced_features_params_schema import ( ADVANCED_FEATURES_PARAMS_SCHEMA, @@ -272,6 +274,7 @@ class Asset( # visibility in the "My Projects" list. (#5451) is_excluded_from_projects_list = models.BooleanField(null=True) search_field = LazyDefaultJSONBField(default=dict) + collector_group = models.ForeignKey(CollectorGroup, null=True, blank=True, on_delete=models.SET_NULL, related_name='assets') objects = AssetWithoutPendingDeletedManager() all_objects = AssetAllManager() From be512667ad343ab2363fd00adc5a950250137d1a Mon Sep 17 00:00:00 2001 From: rgraber Date: Tue, 22 Jul 2025 10:44:44 -0400 Subject: [PATCH 2/4] fixup!: stuff --- kobo/apps/kobo_auth/models.py | 7 ++---- .../apps/api/viewsets/xform_list_api.py | 12 ++++++--- .../apps/api/viewsets/xform_submission_api.py | 11 +++++--- kobo/apps/openrosa/apps/main/urls.py | 10 ++++++++ kobo/apps/openrosa/libs/utils/logger_tools.py | 3 ++- kobo/apps/openrosa/libs/utils/middleware.py | 3 ++- kobo/settings/base.py | 2 +- kpi/authentication.py | 25 ++++++------------- 8 files changed, 40 insertions(+), 33 deletions(-) diff --git a/kobo/apps/kobo_auth/models.py b/kobo/apps/kobo_auth/models.py index 475fa1b8ab..e60a662a00 100644 --- a/kobo/apps/kobo_auth/models.py +++ b/kobo/apps/kobo_auth/models.py @@ -3,9 +3,7 @@ from django_request_cache import cache_for_request from django.db import models from rest_framework.authtoken.models import Token -from xlwt.ExcelFormulaLexer import false_pattern -from kobo.apps.openrosa.apps.logger.models import XForm from kobo.apps.openrosa.libs.constants import ( OPENROSA_APP_LABELS, ) @@ -101,14 +99,13 @@ def is_authenticated(self): return True assets = [] + name = None def has_perm(self, perm, obj = ...): - if not isinstance(obj, XForm) or perm != 'report_xform': + if perm != 'report_xform': return False return obj.kpi_asset_uid in self.assets - - class CollectorGroup(models.Model): uid = KpiUidField(uid_prefix='cg') diff --git a/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py b/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py index 6315d8c5df..d5517afb0c 100644 --- a/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py +++ b/kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py @@ -11,6 +11,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from kobo.apps.kobo_auth.models import Collector from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.openrosa.apps.api.tools import get_media_file_response from kobo.apps.openrosa.apps.logger.models.xform import XForm @@ -47,7 +48,7 @@ def __init__(self, *args, **kwargs): # previously hard-coded authentication classes are included first authentication_classes = [ DigestAuthentication, - CollectorTokenAuthentication, + CollectorTokenAuthentication ] self.authentication_classes = authentication_classes + [ auth_class @@ -81,8 +82,13 @@ def get_renderers(self): def filter_queryset(self, queryset): username = self.kwargs.get('username') - - if username is None: + token = self.kwargs.get('token') + if token: + collector = Collector.objects.get(token=token) + collector_group = collector.group + assets = list(collector_group.assets.values_list('uid', flat=True)) + queryset = queryset.filter(kpi_asset_uid__in=assets) + elif username is None: # If no username is specified, the request must be authenticated if self.request.user.is_anonymous: # raises a permission denied exception, forces authentication diff --git a/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py b/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py index 9d54372086..1a0ac39222 100644 --- a/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py +++ b/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py @@ -10,6 +10,7 @@ from kobo.apps.audit_log.base_views import AuditLoggedViewSet from kobo.apps.audit_log.models import AuditType +from kobo.apps.kobo_auth.models import CollectorUser from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.openrosa.apps.logger.models import Instance from kobo.apps.openrosa.libs import filters @@ -26,7 +27,7 @@ BasicAuthentication, DigestAuthentication, SessionAuthentication, - TokenAuthentication, + TokenAuthentication, CollectorTokenAuthentication, ) from kpi.utils.object_permission import get_database_user from ..utils.rest_framework.viewsets import OpenRosaGenericViewSet @@ -142,7 +143,8 @@ def __init__(self, *args, **kwargs): authentication_classes = [ DigestAuthentication, BasicAuthentication, - TokenAuthentication + TokenAuthentication, + CollectorTokenAuthentication, ] # Do not use `SessionAuthentication`, which implicitly requires CSRF # prevention (which in turn requires that the CSRF token be submitted @@ -156,13 +158,14 @@ def __init__(self, *args, **kwargs): def create(self, request, *args, **kwargs): username = self.kwargs.get('username') + token = self.kwargs.get('token') if self.request.user.is_anonymous: - if not username: + if not username and not token: # Authentication is mandatory when username is omitted from the # submission URL raise NotAuthenticated - else: + elif username: _ = get_object_or_404(User, username=username.lower()) elif not username: # get the username from the user if not set diff --git a/kobo/apps/openrosa/apps/main/urls.py b/kobo/apps/openrosa/apps/main/urls.py index 4a2a7985ff..2a981ea976 100644 --- a/kobo/apps/openrosa/apps/main/urls.py +++ b/kobo/apps/openrosa/apps/main/urls.py @@ -124,6 +124,11 @@ XFormListApi.as_view({'get': 'list'}), name='form-list', ), + re_path( + r'^key/(?P\w+)/formList$', + XFormListApi.as_view({'get': 'list'}), + name='form-list', + ), re_path( r'^(?P\w+)/xformsManifest/(?P[\d+^/]+)$', XFormListApi.as_view({'get': 'manifest'}), @@ -161,6 +166,11 @@ XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}), name='submissions', ), + re_path( + r'^key/(?P\w+)/submission$', + XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}), + name='submissions', + ), re_path(r'^(?P\w+)/bulk-submission$', bulksubmission), re_path(r'^(?P\w+)/bulk-submission-form$', bulksubmission_form), re_path( diff --git a/kobo/apps/openrosa/libs/utils/logger_tools.py b/kobo/apps/openrosa/libs/utils/logger_tools.py index 8906137256..e50ee2130f 100644 --- a/kobo/apps/openrosa/libs/utils/logger_tools.py +++ b/kobo/apps/openrosa/libs/utils/logger_tools.py @@ -38,6 +38,7 @@ from pyxform.xform2json import create_survey_element_from_xml from rest_framework.exceptions import NotAuthenticated +from kobo.apps.kobo_auth.models import CollectorUser from kobo.apps.openrosa.apps.logger.exceptions import ( AccountInactiveError, ConflictingAttachmentBasenameError, @@ -948,7 +949,7 @@ def _get_instance( else: submitted_by = ( get_database_user(request.user) - if request and request.user.is_authenticated + if request and request.user.is_authenticated and not isinstance(request.user, CollectorUser) else None ) diff --git a/kobo/apps/openrosa/libs/utils/middleware.py b/kobo/apps/openrosa/libs/utils/middleware.py index f4ea4a8817..3e0383931f 100644 --- a/kobo/apps/openrosa/libs/utils/middleware.py +++ b/kobo/apps/openrosa/libs/utils/middleware.py @@ -16,6 +16,7 @@ from django.utils.translation import gettext as t from django.utils.translation.trans_real import parse_accept_lang_header +from kobo.apps.kobo_auth.models import CollectorUser from kobo.apps.openrosa.libs.http import JsonResponseForbidden, XMLResponseForbidden # Define views (and viewsets) below. @@ -80,7 +81,7 @@ def __init__(self, get_response): self._skipped_view = False def process_response(self, request, response): - if not request.user.is_authenticated: + if not request.user.is_authenticated or isinstance(request.user, CollectorUser): return response if self._skipped_view: diff --git a/kobo/settings/base.py b/kobo/settings/base.py index cf6c440ed3..42438c09c1 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -151,7 +151,7 @@ MIDDLEWARE = [ 'kobo.apps.service_health.middleware.HealthCheckMiddleware', 'kobo.apps.openrosa.koboform.redirect_middleware.ConditionalRedirects', - 'kobo.apps.openrosa.apps.main.middleware.RevisionMiddleware', + #'kobo.apps.openrosa.apps.main.middleware.RevisionMiddleware', 'django_dont_vary_on.middleware.RemoveUnneededVaryHeadersMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', diff --git a/kpi/authentication.py b/kpi/authentication.py index 0fdbca25f0..7f24ca515a 100644 --- a/kpi/authentication.py +++ b/kpi/authentication.py @@ -177,35 +177,24 @@ def authenticate(self, request): return user, creds -class CollectorTokenAuthentication(TokenAuthentication): +class CollectorTokenAuthentication(BaseAuthentication): def authenticate(self, request): - auth = get_authorization_header(request).split() - - if not auth or auth[0].lower() != self.keyword.lower().encode(): + context = request.parser_context + kwargs = context.get('kwargs', {}) + token = kwargs.get('token', None) + if not token: return None - - if len(auth) == 1: - msg = 'Invalid token header. No credentials provided.' - raise exceptions.AuthenticationFailed(msg) - elif len(auth) > 2: - msg = 'Invalid token header. Token string should not contain spaces.' - raise exceptions.AuthenticationFailed(msg) - - try: - token = auth[1].decode() - except UnicodeError: - msg = 'Invalid token header. Token string should not contain invalid characters.' - raise exceptions.AuthenticationFailed(msg) - return self.authenticate_credentials(token) + def authenticate_credentials(self, key): try: collector = Collector.objects.get(token=key) server_user = CollectorUser() group = collector.group server_user.assets = group.assets.values_list('uid', flat=True) + server_user.name = collector.name return server_user, key except Collector.DoesNotExist: raise exceptions.AuthenticationFailed('Invalid token.') From 257bec1a9c7d0e193ad645fbf753be47f0d69a2f Mon Sep 17 00:00:00 2001 From: rgraber Date: Tue, 22 Jul 2025 11:07:38 -0400 Subject: [PATCH 3/4] fixup!: migration --- kpi/migrations/0067_asset_collector_group.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 kpi/migrations/0067_asset_collector_group.py diff --git a/kpi/migrations/0067_asset_collector_group.py b/kpi/migrations/0067_asset_collector_group.py new file mode 100644 index 0000000000..8244898711 --- /dev/null +++ b/kpi/migrations/0067_asset_collector_group.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.15 on 2025-07-21 13:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('kobo_auth', '0002_collectorgroup_collector'), + ('kpi', '0066_allow_is_excluded_from_project_list'), + ] + + operations = [ + migrations.AddField( + model_name='asset', + name='collector_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assets', to='kobo_auth.collectorgroup'), + ), + ] From 8e63f2a60f3faea0c8ab597b30f783b8cb2d6f87 Mon Sep 17 00:00:00 2001 From: rgraber Date: Tue, 22 Jul 2025 13:42:28 -0400 Subject: [PATCH 4/4] fixup!: test --- test.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test.py diff --git a/test.py b/test.py new file mode 100644 index 0000000000..0cae85f9c0 --- /dev/null +++ b/test.py @@ -0,0 +1,25 @@ +import uuid +from xml.etree import ElementTree as ET +import kobo.apps.openrosa.libs.utils.logger_tools as lt +from django.test import Client +from django.core.files.uploadedfile import SimpleUploadedFile + + +a = Asset.objects.get(name='eh') +xform = XForm.objects.get(kpi_asset_uid=a.uid) +uuid_ = uuid.uuid4() +submission_data = { + 'a': 'option_1', + 'meta': { + 'instanceID': f'uuid:{uuid_}' + }, + 'formhub': {'uuid': xform.uuid}, + '_uuid': str(uuid_), +} +xml = ET.fromstring(lt.dict2xform(submission_data,xform.id_string)) +xml.tag = a.uid +xml.attrib = {'id': a.uid, 'version': a.latest_version.uid} +kwargs = {'token': '1a369bd19fd696ae889a3aac8f58f22efcf5d5e8'} +data = {'xml_submission_file': SimpleUploadedFile('sub.xml', ET.tostring(xml))} +c = Client() +res = c.post('/key/1a369bd19fd696ae889a3aac8f58f22efcf5d5e8/submission', data)