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})>"
@@ -0,0 +1,143 @@
"""kiosk_security: Ed25519-Public-Key-Auth, Status-Enum, Heartbeat, IP-Whitelist
Revision ID: 0021
Revises: 0020
Create Date: 2026-05-24
Ersetzt token_hash + is_active durch:
- status enum('pending','approved','revoked')
- public_key TEXT (Ed25519 PEM/OpenSSH)
- key_algorithm VARCHAR(20)
- enrollment_token_hash VARCHAR(64) + enrollment_expires_at
- last_heartbeat_at, client_version, offline_queue_size
- current_user_id (DSGVO: pro Firma deaktivierbar)
- ip_whitelist TEXT (CIDR-Liste, optional)
Company-Erweiterungen:
- kiosk_require_approval BOOLEAN DEFAULT TRUE
- kiosk_track_current_user BOOLEAN DEFAULT TRUE
- kiosk_heartbeat_interval_sec INTEGER DEFAULT 30
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "0021"
down_revision = "0020"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── kiosk_devices: neue Spalten ───────────────────────────────────────────
# Status-Enum anlegen
op.execute("""
CREATE TYPE kioskdevicestatus AS ENUM ('pending', 'approved', 'revoked')
""")
# Neue Spalten hinzufügen
op.add_column("kiosk_devices",
sa.Column("status",
sa.Enum("pending", "approved", "revoked", name="kioskdevicestatus"),
nullable=False,
server_default="revoked",
)
)
op.add_column("kiosk_devices",
sa.Column("public_key", sa.Text(), nullable=True)
)
op.add_column("kiosk_devices",
sa.Column("key_algorithm", sa.String(20), nullable=False, server_default="ed25519")
)
op.add_column("kiosk_devices",
sa.Column("enrollment_token_hash", sa.String(64), nullable=True)
)
op.add_column("kiosk_devices",
sa.Column("enrollment_expires_at", sa.DateTime(timezone=True), nullable=True)
)
op.add_column("kiosk_devices",
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True)
)
op.add_column("kiosk_devices",
sa.Column("client_version", sa.String(50), nullable=True)
)
op.add_column("kiosk_devices",
sa.Column("offline_queue_size", sa.Integer(), nullable=False, server_default="0")
)
op.add_column("kiosk_devices",
sa.Column("current_user_id",
UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
)
op.add_column("kiosk_devices",
sa.Column("ip_whitelist", sa.Text(), nullable=True)
)
# Bestehende aktive Geräte → revoked (müssen neu enrolled werden)
# is_active=True Geräte werden ebenfalls revoked sie nutzen token_hash-Auth
# die nach dem Upgrade nicht mehr unterstützt wird
op.execute("UPDATE kiosk_devices SET status = 'revoked'")
# is_active und token_hash entfernen (werden durch status + public_key ersetzt)
op.drop_column("kiosk_devices", "is_active")
op.drop_column("kiosk_devices", "token_hash")
# last_seen_at → last_heartbeat_at (Umbenennung via Daten-Copy, dann drop)
op.execute("""
UPDATE kiosk_devices
SET last_heartbeat_at = last_seen_at
WHERE last_seen_at IS NOT NULL
""")
op.drop_column("kiosk_devices", "last_seen_at")
# ── companies: Kiosk-Konfiguration ────────────────────────────────────────
op.add_column("companies",
sa.Column("kiosk_require_approval", sa.Boolean(), nullable=False, server_default="true")
)
op.add_column("companies",
sa.Column("kiosk_track_current_user", sa.Boolean(), nullable=False, server_default="true")
)
op.add_column("companies",
sa.Column("kiosk_heartbeat_interval_sec", sa.Integer(), nullable=False, server_default="30")
)
def downgrade() -> None:
# companies
op.drop_column("companies", "kiosk_heartbeat_interval_sec")
op.drop_column("companies", "kiosk_track_current_user")
op.drop_column("companies", "kiosk_require_approval")
# kiosk_devices: Altspalten wiederherstellen
op.add_column("kiosk_devices",
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True)
)
op.execute("""
UPDATE kiosk_devices
SET last_seen_at = last_heartbeat_at
WHERE last_heartbeat_at IS NOT NULL
""")
op.add_column("kiosk_devices",
sa.Column("token_hash", sa.String(64), nullable=True)
)
op.add_column("kiosk_devices",
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="false")
)
# Neue Spalten entfernen
op.drop_column("kiosk_devices", "ip_whitelist")
op.drop_column("kiosk_devices", "current_user_id")
op.drop_column("kiosk_devices", "offline_queue_size")
op.drop_column("kiosk_devices", "client_version")
op.drop_column("kiosk_devices", "last_heartbeat_at")
op.drop_column("kiosk_devices", "enrollment_expires_at")
op.drop_column("kiosk_devices", "enrollment_token_hash")
op.drop_column("kiosk_devices", "key_algorithm")
op.drop_column("kiosk_devices", "public_key")
op.drop_column("kiosk_devices", "status")
op.execute("DROP TYPE IF EXISTS kioskdevicestatus")
@@ -9,7 +9,7 @@ import sqlalchemy as sa
revision = "0022"
down_revision = "0020"
down_revision = "0021"
branch_labels = None
depends_on = None