From 981bde3dc12bbc5f71143a9333da6103cbe22871 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 24 May 2026 12:08:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(kiosk):=20Migration=200021=20=E2=80=93=20E?= =?UTF-8?q?d25519-Auth,=20Status-Enum,=20Heartbeat,=20IP-Whitelist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/models/company.py | 5 + backend/app/models/kiosk_device.py | 47 +++++- .../versions/0021_kiosk_security.py | 143 ++++++++++++++++++ .../versions/0022_sick_note_config.py | 2 +- 4 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 backend/migrations/versions/0021_kiosk_security.py diff --git a/backend/app/models/company.py b/backend/app/models/company.py index e878bdc..e51947a 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -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") diff --git a/backend/app/models/kiosk_device.py b/backend/app/models/kiosk_device.py index d573a94..bad2924 100644 --- a/backend/app/models/kiosk_device.py +++ b/backend/app/models/kiosk_device.py @@ -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"" + return f"" diff --git a/backend/migrations/versions/0021_kiosk_security.py b/backend/migrations/versions/0021_kiosk_security.py new file mode 100644 index 0000000..b4af725 --- /dev/null +++ b/backend/migrations/versions/0021_kiosk_security.py @@ -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") diff --git a/backend/migrations/versions/0022_sick_note_config.py b/backend/migrations/versions/0022_sick_note_config.py index d527c46..988e825 100644 --- a/backend/migrations/versions/0022_sick_note_config.py +++ b/backend/migrations/versions/0022_sick_note_config.py @@ -9,7 +9,7 @@ import sqlalchemy as sa revision = "0022" -down_revision = "0020" +down_revision = "0021" branch_labels = None depends_on = None