Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions kobo/apps/kobo_auth/migrations/0002_collectorgroup_collector.py
Original file line number Diff line number Diff line change
@@ -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',
),
),
],
),
]
38 changes: 37 additions & 1 deletion kobo/apps/kobo_auth/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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 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

Expand Down Expand Up @@ -87,3 +90,36 @@ 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 = []
name = None

def has_perm(self, perm, obj = ...):
if 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)


14 changes: 11 additions & 3 deletions kobo/apps/openrosa/apps/api/viewsets/xform_list_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,7 +26,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
Expand All @@ -47,6 +48,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
Expand Down Expand Up @@ -80,11 +82,17 @@ 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

self.permission_denied(self.request)
else:
# Return all the forms the currently-logged-in user can access,
Expand Down
11 changes: 7 additions & 4 deletions kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions kobo/apps/openrosa/apps/main/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@
XFormListApi.as_view({'get': 'list'}),
name='form-list',
),
re_path(
r'^key/(?P<token>\w+)/formList$',
XFormListApi.as_view({'get': 'list'}),
name='form-list',
),
re_path(
r'^(?P<username>\w+)/xformsManifest/(?P<pk>[\d+^/]+)$',
XFormListApi.as_view({'get': 'manifest'}),
Expand Down Expand Up @@ -161,6 +166,11 @@
XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}),
name='submissions',
),
re_path(
r'^key/(?P<token>\w+)/submission$',
XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}),
name='submissions',
),
re_path(r'^(?P<username>\w+)/bulk-submission$', bulksubmission),
re_path(r'^(?P<username>\w+)/bulk-submission-form$', bulksubmission_form),
re_path(
Expand Down
3 changes: 2 additions & 1 deletion kobo/apps/openrosa/libs/utils/logger_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)

Expand Down
3 changes: 2 additions & 1 deletion kobo/apps/openrosa/libs/utils/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion kobo/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions kpi/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -172,3 +175,26 @@ def authenticate(self, request):
user, creds = result
self.create_access_log(request, user, 'oauth2')
return user, creds


class CollectorTokenAuthentication(BaseAuthentication):
def authenticate(self, request):
context = request.parser_context
kwargs = context.get('kwargs', {})
token = kwargs.get('token', None)
if not token:
return None
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.')
20 changes: 20 additions & 0 deletions kpi/migrations/0067_asset_collector_group.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
3 changes: 3 additions & 0 deletions kpi/models/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions test.py
Original file line number Diff line number Diff line change
@@ -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)
Loading