feat(kiosk): Migration 0021 – Ed25519-Auth, Status-Enum, Heartbeat, IP-Whitelist

Migration 0021_kiosk_security (eingeklinkt zwischen 0020 und 0022):
- kiosk_devices: token_hash + is_active → status enum(pending/approved/revoked)
- kiosk_devices: public_key, key_algorithm, enrollment_token_hash/expires_at
- kiosk_devices: last_heartbeat_at, client_version, offline_queue_size
- kiosk_devices: current_user_id (DSGVO), ip_whitelist (CIDR)
- companies: kiosk_require_approval, kiosk_track_current_user, kiosk_heartbeat_interval_sec

Model KioskDevice: komplett überarbeitet (KioskDeviceStatus Enum)
Model Company: 3 neue Kiosk-Felder

Bestehende Geräte: status=revoked (müssen neu enrolled werden)
Existing servers: SQL manuell angewendet (Alembic skip bei inserted migrations)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:08:33 +02:00
parent 62ef6c2a11
commit 981bde3dc1
4 changed files with 190 additions and 7 deletions
+5
View File
@@ -44,6 +44,11 @@ class Company(Base):
busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Kiosk-Konfiguration
kiosk_require_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
kiosk_track_current_user: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
kiosk_heartbeat_interval_sec: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
# Relationships
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
+41 -6
View File
@@ -3,14 +3,15 @@ import enum
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import ARRAY, UUID
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.user import User
class KioskAuthMethod(str, enum.Enum):
@@ -20,6 +21,12 @@ class KioskAuthMethod(str, enum.Enum):
LIST = "list" # Mitarbeiter-Liste
class KioskDeviceStatus(str, enum.Enum):
PENDING = "pending" # Wartet auf Admin-Freigabe
APPROVED = "approved" # Aktiv, darf stempeln
REVOKED = "revoked" # Gesperrt / neu enrollen erforderlich
class KioskDevice(Base):
__tablename__ = "kiosk_devices"
@@ -30,12 +37,40 @@ class KioskDevice(Base):
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
location: Mapped[str | None] = mapped_column(String(255))
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# ── Ed25519-Auth (löst token_hash + is_active ab) ─────────────────────────
status: Mapped[KioskDeviceStatus] = mapped_column(
Enum(KioskDeviceStatus, name="kioskdevicestatus"),
nullable=False,
default=KioskDeviceStatus.REVOKED,
)
public_key: Mapped[str | None] = mapped_column(Text) # Ed25519 PEM/OpenSSH
key_algorithm: Mapped[str] = mapped_column(String(20), nullable=False, default="ed25519")
# ── Enrollment (einmaliger Setup-Flow) ────────────────────────────────────
enrollment_token_hash: Mapped[str | None] = mapped_column(String(64))
enrollment_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# ── Heartbeat & Liveness ──────────────────────────────────────────────────
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
client_version: Mapped[str | None] = mapped_column(String(50))
offline_queue_size: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# ── DSGVO: aktuell eingestempelter User (per Firma deaktivierbar) ─────────
current_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# ── Sicherheit ────────────────────────────────────────────────────────────
ip_whitelist: Mapped[str | None] = mapped_column(Text) # CIDR-Liste, z.B. "10.0.0.0/24,192.168.1.0/24"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# ── Relationships ─────────────────────────────────────────────────────────
company: Mapped["Company"] = relationship("Company", lazy="noload")
current_user: Mapped["User | None"] = relationship(
"User", foreign_keys=[current_user_id], lazy="noload"
)
def __repr__(self) -> str:
return f"<KioskDevice {self.name} ({self.company_id})>"
return f"<KioskDevice {self.name} status={self.status.value} ({self.company_id})>"