Add authenticated device registration for USB intake
Some checks failed
Tests / postgres (push) Has been cancelled

This commit is contained in:
2026-10-11 22:19:57 +02:00
parent 7d1d8f0fde
commit e2870c2fda
7 changed files with 241 additions and 2 deletions

View File

@@ -183,6 +183,23 @@ Passwort-Hashes und Kontoverwaltung sind absichtlich nicht Teil der API. Jede
Feldänderung enthält in der Änderungshistorie sowohl das zugeordnete Konto als Feldänderung enthält in der Änderungshistorie sowohl das zugeordnete Konto als
auch den Namen des verwendeten API-Clients. auch den Namen des verwendeten API-Clients.
## Geräte per Live-USB erfassen
Für den Werkstattbetrieb gibt es einen [eigenständigen USB-Stick](docs/usb-appliance.md)
im separaten Ordner `../USB-Intake`: Nach Eingabe von Server-IP und API-Schlüssel
startet die Prüfung automatisch. Ergebnisse werden direkt hochgeladen oder
dauerhaft auf dem Stick gespeichert. Gespeicherte
Berichte lassen sich später gesammelt senden, auch per WLAN. Der Archiso-Build
enthält alle benötigten Programme und benötigt keine interne Festplatte.
Laptops und Mini-PCs können ohne interne SSD/HDD von einem Ubuntu-Live-USB
gestartet, kurz auf CPU/RAM geprüft und über die API neu erfasst werden.
`../USB-Intake/intake.py` sammelt Hardwaredaten und speichert einen offline
nutzbaren Bericht; der Upload erstellt das Gerät mit automatisch vergebener
Nummer und Prüfbericht im Kommentar. Wiederholte Uploads desselben Berichts
erzeugen keine weiteren Geräte. Einrichtung und Ablauf stehen in der
[Live-USB-Anleitung](docs/usb-intake.md).
## Backup und Wiederherstellung ## Backup und Wiederherstellung
Backup-Verzeichnis anlegen und Skripte ausführbar machen: Backup-Verzeichnis anlegen und Skripte ausführbar machen:

18
docs/usb-appliance.md Normal file
View File

@@ -0,0 +1,18 @@
# Eigenständiger USB-Stick
Das USB-Projekt liegt separat in **`../USB-Intake`** neben diesem Repository.
Die vollständige Anleitung steht in [USB-Intake/README.md](https://git.computerspende-regensburg.de/denis.stoecklmeier/USB-Intake).
Auf dem Arch-Linux-Rechner im USB-Projektordner:
```bash
sudo bash make-usb.sh /dev/sdb --erase
```
Der Befehl baut das Image und überschreibt den geprüften USB-Datenträger.
Beim ersten Boot werden Server-IP/HTTPS-Adresse und API-Schlüssel abgefragt.
Danach laufen Hardwareprüfung und Upload automatisch; ohne Verbindung bleiben
Berichte auf dem Stick für späteres Senden gespeichert.
Dieses Inventory-Repository enthält weiterhin die API-Erweiterung einschließlich
Migration `0013_deviceregistration`, die auf dem Server bereitgestellt werden muss.

13
docs/usb-intake.md Normal file
View File

@@ -0,0 +1,13 @@
# USB-Erfassung
Die eigenständige USB-Anwendung und alle Build-/Installationswerkzeuge wurden
nach [USB-Intake](https://git.computerspende-regensburg.de/denis.stoecklmeier/USB-Intake) neben diesem Repository verschoben.
Sie startet direkt vom Stick, fragt Server und API-Schlüssel ab und führt
Prüfung plus Upload/Offline-Speicherung automatisch aus.
Für manuelle Diagnose enthält das separate Projekt weiterhin `intake.py` mit den
Unterbefehlen `collect`, `choices` und `upload` (`python3 intake.py --help`).
Die zugehörige Server-API bleibt in diesem Repository. Migration `0013` speichert
Upload-Belege, sodass wiederholte Uploads desselben Berichts keine weiteren Geräte
erstellen. Benötigt wird ein aktiver API-Schlüssel mit Lese- und Schreibrecht.

View File

@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
import hashlib
import json
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction from django.db import transaction
@@ -9,9 +11,9 @@ from rest_framework import generics, serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import ChoiceValue, Device, Employee from .models import AuditLog, ChoiceValue, Device, DeviceRegistration, Employee, NumberSequence
from .query import SPECIFICATION_RELATIONS from .query import SPECIFICATION_RELATIONS
from .services import update_device_field from .services import add_audit, update_device_field
class SecureAPIResponseMixin: class SecureAPIResponseMixin:
@@ -121,6 +123,16 @@ class DeviceChangesSerializer(StrictSerializer):
return attrs return attrs
class DeviceCreateSerializer(DeviceChangesSerializer):
request_id = serializers.UUIDField()
status = serializers.PrimaryKeyRelatedField(
queryset=ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True),
)
device_type = serializers.PrimaryKeyRelatedField(
queryset=ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True),
)
class DevicePatchSerializer(DeviceChangesSerializer): class DevicePatchSerializer(DeviceChangesSerializer):
expected_updated_at = serializers.DateTimeField(required=False, write_only=True) expected_updated_at = serializers.DateTimeField(required=False, write_only=True)
expected_revision = serializers.IntegerField(required=False, min_value=1, write_only=True) expected_revision = serializers.IntegerField(required=False, min_value=1, write_only=True)
@@ -169,6 +181,29 @@ def apply_changes(device, changes, *, user, api_client, expected_updated_at=None
class DeviceListView(SecureAPIResponseMixin, generics.ListAPIView): class DeviceListView(SecureAPIResponseMixin, generics.ListAPIView):
serializer_class = DeviceSerializer serializer_class = DeviceSerializer
@transaction.atomic
def post(self, request):
serializer = DeviceCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
values = dict(serializer.validated_data)
request_id = values.pop("request_id")
digest = hashlib.sha256(json.dumps(request.data, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
# Share the existing allocation lock, including across simultaneous retries.
NumberSequence.objects.select_for_update().get_or_create(name="device")
previous = DeviceRegistration.objects.select_related("device").filter(request_id=request_id).first()
if previous:
if previous.payload_hash != digest:
return Response({"detail": "request_id wurde bereits für andere Daten verwendet."}, status=409)
return Response(DeviceSerializer(previous.device).data, status=200)
try:
device = Device.objects.create(**values)
except DjangoValidationError as exc:
raise serializers.ValidationError(exc.messages)
DeviceRegistration.objects.create(request_id=request_id, payload_hash=digest, device=device)
add_audit(request.user, device, AuditLog.Action.CREATE,
new=f"Gerät #{device.number}", api_client=request.auth.name)
return Response(DeviceSerializer(device).data, status=201)
def get_queryset(self): def get_queryset(self):
queryset = Device.objects.select_related( queryset = Device.objects.select_related(
"status", "recipient", "location", "device_type", "employee", "status", "recipient", "location", "device_type", "employee",

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.18 on 2026-10-11 19:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0012_allinonespecification_desktopspecification_and_more'),
]
operations = [
migrations.CreateModel(
name='DeviceRegistration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('request_id', models.UUIDField(unique=True)),
('payload_hash', models.CharField(max_length=64)),
('device', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='registration', to='inventory.device')),
],
options={
'abstract': False,
},
),
]

View File

@@ -102,6 +102,14 @@ class FieldDefinition(TimeStampedModel):
return self.name return self.name
class DeviceRegistration(TimeStampedModel):
"""Durable receipt so retrying a USB report cannot allocate another device."""
request_id = models.UUIDField(unique=True)
payload_hash = models.CharField(max_length=64)
device = models.OneToOneField("Device", on_delete=models.PROTECT, related_name="registration")
class FieldOption(TimeStampedModel): class FieldOption(TimeStampedModel):
field = models.ForeignKey(FieldDefinition, on_delete=models.CASCADE, related_name="options") field = models.ForeignKey(FieldDefinition, on_delete=models.CASCADE, related_name="options")
label = models.CharField(max_length=120) label = models.CharField(max_length=120)

View File

@@ -0,0 +1,120 @@
import json
import uuid
from datetime import timedelta
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
from django.contrib.auth.models import User
from django.db import close_old_connections
from django.test import Client, TestCase, TransactionTestCase, skipUnlessDBFeature
from django.urls import reverse
from django.utils import timezone
from .models import AuditLog, ChoiceValue, Device, DeviceRegistration, InventoryAPIKey, NumberSequence
class RegistrationTests(TestCase):
def setUp(self):
self.user = User.objects.create_user('usb-operator', is_superuser=True)
self.status = ChoiceValue.objects.create(kind='status', label='Neu')
self.kind = ChoiceValue.objects.create(kind='device_type', label='Laptop')
_, self.key = InventoryAPIKey.objects.create_key(
name='usb-intake', service_user=self.user, can_read=True, can_write=True,
expiry_date=timezone.now() + timedelta(days=1))
self.payload = {'request_id': str(uuid.uuid4()), 'status': self.status.pk,
'device_type': self.kind.pk, 'cpu': 'Test CPU', 'ram_gb': 16,
'hwid': str(uuid.uuid4()), 'comment': 'CPU/RAM: PASS'}
def post(self, payload=None, key=None):
return self.client.post(reverse('inventory-api:device-list'),
json.dumps(self.payload if payload is None else payload),
content_type='application/json',
HTTP_AUTHORIZATION=f'Api-Key {key or self.key}')
def test_create_specs_audit_and_retry(self):
NumberSequence.objects.update_or_create(name='device', defaults={'last_value': 42})
response = self.post()
self.assertEqual(response.status_code, 201, response.content)
device = Device.objects.get()
self.assertEqual(device.number, 43)
self.assertEqual(device.ram_gb, 16)
self.assertEqual(device.stage, Device.Stage.INTAKE)
self.assertIsNone(device.ssd_gb)
self.assertEqual(device.wifi, 'UNKNOWN')
audit = AuditLog.objects.get(device=device)
self.assertEqual(audit.api_client, 'usb-intake')
self.assertEqual(audit.user, self.user)
self.assertEqual(audit.action, AuditLog.Action.CREATE)
retry = self.post()
self.assertEqual(retry.status_code, 200)
self.assertEqual(retry.json()['number'], device.number)
self.assertEqual(Device.objects.count(), 1)
self.assertEqual(AuditLog.objects.count(), 1)
def test_changed_retry_conflicts(self):
self.post()
self.assertEqual(self.post({**self.payload, 'ram_gb': 32}).status_code, 409)
self.assertEqual(Device.objects.get().ram_gb, 16)
def test_rejects_missing_auth_readonly_and_viewer(self):
self.assertEqual(self.client.post(reverse('inventory-api:device-list'), self.payload).status_code, 401)
_, key = InventoryAPIKey.objects.create_key(name='read', service_user=self.user, can_read=True,
expiry_date=timezone.now() + timedelta(days=1))
self.assertEqual(self.post(key=key).status_code, 403)
self.user.is_superuser = False
self.user.save()
self.user.profile.role = 'VIEWER'
self.user.profile.save()
self.assertEqual(self.post().status_code, 403)
self.assertFalse(Device.objects.exists())
def test_strict_validation_and_active_choices(self):
for changes in ({'status': self.kind.pk}, {'status': None}, {'device_type': None},
{'ram_gb': -1}, {'number': 99}, {'stage': 'STORAGE'}, {'request_id': 'bad'}):
with self.subTest(changes=changes):
self.assertEqual(self.post({**self.payload, **changes}).status_code, 400)
for missing in ('status', 'device_type', 'request_id'):
self.assertEqual(self.post({k: v for k, v in self.payload.items() if k != missing}).status_code, 400)
self.kind.active = False
self.kind.save()
self.assertEqual(self.post().status_code, 400)
self.assertFalse(Device.objects.exists())
def test_failure_rolls_back_device_receipt_and_number(self):
NumberSequence.objects.update_or_create(name='device', defaults={'last_value': 42})
with patch('inventory.api.add_audit', side_effect=RuntimeError('audit unavailable')):
with self.assertRaises(RuntimeError):
self.post()
self.assertFalse(Device.objects.exists())
self.assertFalse(DeviceRegistration.objects.exists())
self.assertEqual(NumberSequence.objects.get(name='device').last_value, 42)
class ConcurrentRegistrationTests(TransactionTestCase):
@skipUnlessDBFeature('has_select_for_update')
def test_simultaneous_retry_allocates_once(self):
user = User.objects.create_user('concurrent-usb', is_superuser=True)
_, key = InventoryAPIKey.objects.create_key(name='usb', service_user=user, can_read=True, can_write=True,
expiry_date=timezone.now() + timedelta(days=1))
status = ChoiceValue.objects.create(kind='status', label='Neu')
kind = ChoiceValue.objects.create(kind='device_type', label='Desktop')
NumberSequence.objects.update_or_create(name='device', defaults={'last_value': 0})
payload = {'request_id': str(uuid.uuid4()), 'status': status.pk, 'device_type': kind.pk}
barrier = Barrier(2)
def submit(_):
close_old_connections()
try:
barrier.wait(timeout=10)
response = Client().post(reverse('inventory-api:device-list'), json.dumps(payload),
content_type='application/json', HTTP_AUTHORIZATION=f'Api-Key {key}')
return response.status_code, response.json()['number']
finally:
close_old_connections()
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(submit, range(2)))
self.assertEqual(sorted(code for code, _ in results), [200, 201])
self.assertEqual(len({number for _, number in results}), 1)
self.assertEqual(Device.objects.count(), 1)