Add authenticated device registration for USB intake
Some checks failed
Tests / postgres (push) Has been cancelled
Some checks failed
Tests / postgres (push) Has been cancelled
This commit is contained in:
17
README.md
17
README.md
@@ -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
18
docs/usb-appliance.md
Normal 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
13
docs/usb-intake.md
Normal 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.
|
||||||
@@ -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",
|
||||||
|
|||||||
28
inventory/migrations/0013_deviceregistration.py
Normal file
28
inventory/migrations/0013_deviceregistration.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
120
inventory/test_usb_intake.py
Normal file
120
inventory/test_usb_intake.py
Normal 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)
|
||||||
Reference in New Issue
Block a user