4dc69137dd
H-1: company.settings als typisiertes Sub-Schema - schemas/company.py: CompanySettingsUpdate mit extra=forbid - Nur bekannte Keys (carryover_expires_month/day) erlaubt - Unbekannte Keys → HTTP 422 H-5: SQL-Injection defensiv absichern - dependencies.py: UUID-Round-Trip str(_uuid.UUID(...)) + Sicherheitskommentar H-6: CalDAV DNS-Rebinding-Schutz - caldav_service.py: PinnedIPTransport — IP einmal auflösen, beim Request fixieren - _validate_caldav_url gibt aufgelöste IP zurück - Alle HTTP-Methoden nutzen PinnedIPTransport H-7: Heartbeat-Timestamp nach Route-Logik - kiosk_security.py: last_heartbeat_at-Update aus Dependency entfernt - kiosk_service.py: Update erst in process_heartbeat() nach erfolgreicher Auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
5.2 KiB
Python
144 lines
5.2 KiB
Python
from datetime import datetime, timezone
|
||
from uuid import UUID
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.company import Company
|
||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||
from app.schemas.kiosk import HeartbeatRequest, KioskDeviceCreate, KioskDeviceUpdate
|
||
|
||
|
||
class KioskService:
|
||
|
||
# ── Lesende Operationen ───────────────────────────────────────────────────
|
||
|
||
async def list_devices(
|
||
self,
|
||
company_id: UUID,
|
||
db: AsyncSession,
|
||
status_filter: KioskDeviceStatus | None = None,
|
||
) -> list[KioskDevice]:
|
||
query = (
|
||
select(KioskDevice)
|
||
.where(KioskDevice.company_id == company_id)
|
||
.order_by(KioskDevice.created_at.desc())
|
||
)
|
||
if status_filter is not None:
|
||
query = query.where(KioskDevice.status == status_filter)
|
||
result = await db.scalars(query)
|
||
return list(result.all())
|
||
|
||
async def get_device(
|
||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||
) -> KioskDevice:
|
||
device = await db.scalar(
|
||
select(KioskDevice).where(
|
||
KioskDevice.id == device_id,
|
||
KioskDevice.company_id == company_id,
|
||
)
|
||
)
|
||
if device is None:
|
||
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
|
||
return device
|
||
|
||
# ── Schreibende Operationen ───────────────────────────────────────────────
|
||
|
||
async def create_device(
|
||
self,
|
||
company_id: UUID,
|
||
data: KioskDeviceCreate,
|
||
db: AsyncSession,
|
||
) -> KioskDevice:
|
||
"""
|
||
Gerät anlegen.
|
||
Status = pending wenn kiosk_require_approval, sonst approved.
|
||
"""
|
||
company = await db.get(Company, company_id)
|
||
require_approval = company.kiosk_require_approval if company else True
|
||
|
||
device = KioskDevice(
|
||
company_id=company_id,
|
||
name=data.name,
|
||
location=data.location,
|
||
public_key=data.public_key,
|
||
ip_whitelist=data.ip_whitelist,
|
||
key_algorithm="ed25519",
|
||
status=KioskDeviceStatus.PENDING if require_approval else KioskDeviceStatus.APPROVED,
|
||
)
|
||
db.add(device)
|
||
await db.flush()
|
||
return device
|
||
|
||
async def update_device(
|
||
self,
|
||
device_id: UUID,
|
||
company_id: UUID,
|
||
data: KioskDeviceUpdate,
|
||
db: AsyncSession,
|
||
) -> KioskDevice:
|
||
device = await self.get_device(device_id, company_id, db)
|
||
changes = data.model_dump(exclude_none=True)
|
||
for field, value in changes.items():
|
||
setattr(device, field, value)
|
||
return device
|
||
|
||
async def delete_device(
|
||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||
) -> None:
|
||
device = await self.get_device(device_id, company_id, db)
|
||
await db.delete(device)
|
||
|
||
# ── Status-Verwaltung ─────────────────────────────────────────────────────
|
||
|
||
async def approve_device(
|
||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||
) -> KioskDevice:
|
||
"""Gerät freigeben: Status → approved."""
|
||
device = await self.get_device(device_id, company_id, db)
|
||
device.status = KioskDeviceStatus.APPROVED
|
||
return device
|
||
|
||
async def revoke_device(
|
||
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||
) -> KioskDevice:
|
||
"""Gerät sperren: Status → revoked."""
|
||
device = await self.get_device(device_id, company_id, db)
|
||
device.status = KioskDeviceStatus.REVOKED
|
||
return device
|
||
|
||
# ── Heartbeat ─────────────────────────────────────────────────────────────
|
||
|
||
async def process_heartbeat(
|
||
self,
|
||
device: KioskDevice,
|
||
data: HeartbeatRequest,
|
||
company: Company,
|
||
db: AsyncSession,
|
||
) -> None:
|
||
"""
|
||
Heartbeat-Daten vom Kiosk-Gerät verarbeiten.
|
||
|
||
last_heartbeat_at wird hier gesetzt (H-7 Fix: nicht in verify_kiosk_request,
|
||
da der Timestamp erst nach erfolgreicher Route-Logik committed werden soll).
|
||
"""
|
||
# Heartbeat-Timestamp erst jetzt setzen – nach erfolgreicher Auth + Route-Logik
|
||
device.last_heartbeat_at = datetime.now(timezone.utc)
|
||
|
||
if data.client_version is not None:
|
||
device.client_version = data.client_version
|
||
|
||
device.offline_queue_size = data.queued_offline_entries
|
||
|
||
if data.current_user_id is not None and company.kiosk_track_current_user:
|
||
device.current_user_id = data.current_user_id
|
||
elif not company.kiosk_track_current_user:
|
||
# DSGVO-Opt-Out: aktuellen User nicht speichern
|
||
device.current_user_id = None
|
||
|
||
await db.flush()
|
||
|
||
|
||
kiosk_service = KioskService()
|