Zum Inhalt

Evaluierung: Offenes Portal & QR-Code-Links für calServer V2

Kontext

calServer V1 bietet zwei verwandte, aber getrennt schaltbare Funktionen für den Zugriff ohne Login:

  • das offene Portal (Einstieg /public, konfigurierter Anzeigepfad z. B. /portal): eine read-only Inventarliste mit Detailansichten für ausgewählte Kunden, und
  • die QR-Code-Links (/inventory/qrcode/<MTAG>): gedruckte Etiketten auf Geräten, deren Scan direkt die Geräte-Detailansicht öffnet — bewusst unabhängig vom Portal-Schalter, damit Etiketten im Feld immer funktionieren.

Beide Funktionen fehlen in V2 vollständig; die V2-Roadmap führte sie bislang nicht einmal als Lücke. Diese Evaluierung erarbeitet die Strategie für die V2-Umsetzung.

Anforderung (gesetzt): Die Sichtbarkeit wird wie in V1 über einen Public User konfiguriert — ein spezieller Benutzer, dessen Kundenzuordnungen bestimmen, welche Kunden (und damit Geräte) öffentlich sichtbar sind. Zusätzlich steuert das Feldmanagement (small_view_visible), welche Felder die Portal-/QR-Ansichten zeigen.

Randbedingungen, die jede Lösung erfüllen muss:

  1. Gedruckte Etiketten sind unumkehrbar: Auf Kundengeräten kleben QR-Codes mit V1-URLs (/inventory/qrcode/<MTAG> bzw. /frontend/frontendInventory/smallView?id=<MTAG>). Diese URLs müssen nach einem Cutover auf V2 weiter auflösen — ein Token-/Signatur-Zwang in der URL scheidet damit als Fundament aus.
  2. Konfigurationsparität: Public User (user.is_public), Kundenzuordnung (map_user) und Rollen-Flags (authitem.portal_enable/qrlink_enable) sind das bestehende, dokumentierte Bedienmodell (Admin-Handbuch: QR-Code-Rolle). V2 soll dasselbe Modell nutzen, damit die V1→V2-Migration konfigurationsfrei bleibt.
  3. Multi-DB-Gebot: MySQL, PostgreSQL, MSSQL gleichberechtigt (siehe CLAUDE.md, DATABASE_ABSTRACTION_PLAN.md).
  4. Read-only by construction: Portalbesucher dürfen unter keinen Umständen schreiben. V1 erzwingt das über Rollen-Denies; V2 kann es strukturell besser lösen.
  5. Keine V1-Schemaänderungen an geteilten Tabellen; nötige Spalten existieren glücklicherweise bereits in beiden Welten (siehe Ist-Analyse V2).

Ist-Analyse

V1: Funktionsweise (Yii, httpdocs/)

Drei Bausteine tragen beide Features:

  1. Public User — genau ein Benutzer mit user.is_public = 1 (Validator erzwingt Einzigkeit, BaseAdminUser::checkPublicUser()). Er wird in den öffentlichen Actions serverseitig eingeloggt (passwortlos, 24-h-Session), z. B. FrontendInventoryController::actionSmallView() (Z. 939 ff.). WebUser::checkAccessForUser() verweigert für is_public-User hart alle edit/delete-Operationen.
  2. QR-Code-Rolle (authitem.name = 'QR_Code_Role') — trägt vier Konfigurationsspalten: portal_enable + portal_start_address (Portal) und qrlink_enable + qrlink_address (QR-Links), gepflegt im Adminpanel (AdminUserRolesController::actionQrCodeRole()). Die der Rolle zugewiesenen *_view-Operationen steuern, welche Reiter (Kalibrierungen, Reparaturen, DMS, Tickets, …) die Detailansicht zeigt (user()->checkAccessQRCode(...) in small_view.php).
  3. Kundenzuordnung map_user (user_uIDcustomer_KTAG) — die eigentliche „Wer sieht was“-Logik: Die Portal-Liste filtert per FrontendInventory::ofCustomers() auf die dem Public User zugeordneten Kunden; jede Detailansicht prüft per canView(), ob der Kunde des Geräts dem Public User zugeordnet ist (sonst 404).

Feldsteuerung über das Feldmanagement: Die Detailansicht zeigt nur Felder mit field_configuration.small_view_visible = 1. Sobald die QR-Code-Rolle aktiv ist, kollabieren alle Sichtbarkeitsmodi auf dieses Flag (InventoryHelper::getConditionByMode(), httpdocs/protected/modules/frontend/components/InventoryHelper.php Z. 929 ff.) — auch die Spaltenauswahl des Portal-Grids.

QR-Etikett: Jasper-Report QR_Code_View.jrxml kodiert die absolute URL createAbsoluteUrl('/frontend/frontendInventory/smallView', ['id' => MTAG]); die hübsche Route ist /inventory/qrcode/<MTAG> (config/components.php Z. 50–52). Die MTAG ist eine UUID — sie ist das einzige „Geheimnis“ der URL.

Sicherheitsmodell V1 (Schwächen): kein Rate-Limit, kein Token/Ablauf, 24-h-Gastsession mit echtem Login-Zustand, Feld-/Reiter-Filterung serverseitig beim Rendern. Schutz = Existenz des Public Users + aktivierte Rolle + map_user-Freigabe + UUID-Unratbarkeit.

V2: Was bereits existiert

Die Bestandsaufnahme fällt überraschend günstig aus — das gesamte Konfigurationsfundament ist schon da, es fehlen „nur“ die öffentlichen Endpunkte und Ansichten:

Baustein Stand V2 Beleg
user.is_public, authitem.portal_enable, portal_start_address, qrlink_enable, qrlink_address ✅ in den Laravel-Migrations für frische V2-Installationen enthalten laravel/database/migrations/0001_01_01_000000_create_users_table.php (Z. 51, 73–76)
Public User pflegbar ✅ User-CRUD kennt is_public (Request-Validierung, Resource) StoreUserRequest/UpdateUserRequest/UserResource
Kundenzuordnung MapUser-Model + Users-API GET/POST /users/{id}/customers laravel/app/Models/MapUser.php, UserController::customers()
Sichtbarkeits-Scoping ✅ wiederverwendbare Helfer über map_user app/Support/MapUserScope.php, CustomerVisibility.php, InventoryVisibility.php
Feldmanagement small_view_visible ✅ Spalte in field_definitions und field_configuration, API-Resources/Requests, Spiegelung nach V1 (FieldConfigurationMirror), Admin-UI (Checkbox + „Kompakt“-Tag) laravel/app/Models/FieldDefinition.php:35, pages/admin/field-definitions/index.vue
… dessen Konsum useFieldConfig() bietet smallViewFields fertig an, kein Consumer nutzt es frontend-v2/composables/useFieldConfig.ts:113
Public-API-Konvention ✅ Sanctum-freier /public/*-Block mit throttle:30,1 laravel/routes/api.php:150 (Languages/Translations)
QR-Erzeugung ⚠️ vorhanden, aber auth-pflichtig und Ziel ist die interne Detailseite InventoryQrController (GET /v2/inventories/{mtag}/qrcode, api.php:317)
Datei-Streaming ✅ wiederverwendbar für Zertifikat-Downloads DocumentStorageService::streamDownload()
Frontend-Gerüst ⚠️ globale Auth-Middleware mit hartkodierter Whitelist (/login, /setup); minimalistisches layouts/print.vue als Vorlage; öffentlicher i18n-Endpoint frontend-v2/middleware/auth.global.ts

Es fehlt: jegliche öffentliche Portal-/QR-Route (Backend wie Frontend), eine Public-Resource-Schicht mit Feld-Whitelist, die Admin-UI für die Rollen-Flags sowie die URL-Weiterleitungen für den Cutover. Signed-URL-/Share-Link-Infrastruktur existiert nicht (signedRoute/temporarySignedRoute: 0 Treffer) — wird nach der Empfehlung unten aber auch nicht als Fundament benötigt.


Optionen

Option A: 1:1-Portierung des V1-Musters (Gast-Login als Public User)

Der Besucher erhält beim Aufruf öffentlicher Seiten serverseitig eine echte Session/ein Sanctum-Token des Public Users; alle bestehenden authentifizierten Endpunkte funktionieren dann unverändert.

Pro Contra
Maximale Verhaltensparität zu V1 Anonyme Besucher erhalten ein echtes Sanctum-Token — und Sanctum ist ohne Ablauf konfiguriert (expiration => null); jedes geleakte Token ist ein dauerhafter API-Zugang
Wiederverwendung des kompletten api.auth-Stacks und aller Controller Der gesamte authentifizierte API-Umfang (inkl. Schreibendpunkte) hängt dann nur noch an Rollen-Denies — eine Fehlkonfiguration genügt
Kein neuer Public-Endpoint-Layer Session-/Token-Erzeugung pro Crawler-Request skaliert schlecht und füllt personal_access_tokens
Vermischt „Konfigurationsanker“ (wessen Kunden sind sichtbar) mit „Identität“ (wer ruft ab) — genau die Stelle, an der V1 konzeptionell schwach ist

Option B: Public-Kontext ohne Gast-Login (Empfehlung)

Dedizierte, unauthentifizierte Read-only-Endpunkte unter /api/v2/public/portal/* und /api/v2/public/qr/* (Vorbild: bestehender /public/*-Block). Ein PortalAccessService löst den Public User rein serverseitig als Konfigurationsanker auf: Feature-Flags von der QR-Code-Rolle, sichtbare Kunden aus map_user, sichtbare Reiter aus den Rollen-Operationen, sichtbare Felder aus small_view_visible. Der Besucher bekommt niemals ein Token oder eine Session.

Pro Contra
Read-only by construction: Es existieren nur GET-Routen; Schreibzugriff ist strukturell unmöglich, nicht nur verboten Portal-Endpunkte müssen neu geschrieben werden (kein 1:1-Reuse der authentifizierten Controller)
Kein Token-/Session-Leak möglich, stateless, cache- und throttlebar Sichtbarkeitslogik existiert zweimal (authentifiziert vs. öffentlich) — mitigiert durch Wiederverwendung von MapUserScope/InventoryVisibility und FieldDefinitionService
Volle Konfigurationsparität: dieselben Tabellen/Spalten wie V1, Migration konfigurationsfrei
Explizite Feld-Whitelist in eigenen Portal*Resources — unauthentifizierte Antworten enthalten nur small_view_visible-Felder, statt wie V1 die volle Detail-View zu filtern
Passt zur bestehenden /public/*-Konvention und zur JSON:API-Struktur

Jedes Gerät erhält einen zufälligen Zugriffstoken bzw. eine signierte URL (URL::temporarySignedRoute); nur wer den Link kennt, sieht das Gerät — Widerruf und Ablauf pro Gerät möglich.

Pro Contra
Feingranularer Widerruf/Ablauf pro Gerät Bricht alle gedruckten Etiketten: Bestehende QR-Codes enthalten nur die MTAG, keine Signatur/keinen Token — Randbedingung 1 verletzt
Stand der Technik für Share-Links Kein Äquivalent zum Portal (Liste über alle freigegebenen Geräte)
Zusätzlicher Zustand (Token-Tabelle) + Verwaltungs-UI, ohne das Public-User-Modell zu ersetzen

Option C scheidet als Fundament aus, bleibt aber als optionale Härtungsschicht (Phase 4) sinnvoll: zusätzliche widerrufbare Tokens für sensible Einzelfreigaben, neben dem MTAG-Zugang.


Empfehlung

Option B — Public-Kontext ohne Gast-Login, mit dem Public User als reinem Konfigurationsanker.

Begründung in einem Satz: Sie liefert exakt das geforderte Bedienmodell (Public User + Kundenzuordnung + Feldmanagement) auf den bereits vorhandenen Tabellen und Admin-Oberflächen, beseitigt zugleich die konzeptionellen Schwächen des V1-Ansatzes (Gast-Session, Vollzugriff-minus-Denies, ungefilterte Feld-Exposition) und hält die gedruckten QR-Etiketten am Leben — bei null Migrationsaufwand für die Konfiguration.


Zielarchitektur

Backend (Laravel)

Neuer Routen-Block im bestehenden Public-Bereich von routes/api.php (ohne api.auth, mit throttle), alle Endpunkte ausschließlich GET:

Endpoint Zweck
GET /api/v2/public/portal/config Portal aktiv? Branding, Startkonfiguration, sichtbare Reiter (aus den Operationen der QR-Code-Rolle)
GET /api/v2/public/portal/inventories Geräteliste, gefiltert auf map_user-Kunden des Public Users; Paging/Filter/Sortierung als abstrakte Descriptoren wie bei useDataTable
GET /api/v2/public/portal/inventories/{id} Detailansicht
GET /api/v2/public/portal/inventories/{id}/calibrations (analog repairs, documents, …) Reiter-Daten, jeweils durch Rollen-Operation gegated
GET /api/v2/public/portal/documents/{id}/download Nur freigegebene, einem sichtbaren Gerät zugeordnete Dokumente; via DocumentStorageService::streamDownload()
GET /api/v2/public/qr/{mtag} QR-Resolver — funktioniert wie in V1 unabhängig vom Portal-Schalter (nur qrlink_enable)

Tragende Bausteine:

  • app/Services/Portal/PortalAccessService — der einzige Ort, der den Public User auflöst: lädt user.is_public = 1 (404, wenn keiner existiert — V1-Verhalten), liest die Flags der QR-Code-Rolle, berechnet die sichtbaren customer_ids über MapUserScope und die sichtbaren Reiter über die Rollen-Operationen (authitemchild, Auflösung analog User::hasPermission()). Ergebnis wird pro Request gecacht.
  • PortalGuard-Middleware — bricht mit 404 ab, wenn Feature deaktiviert oder kein Public User existiert (Gleichbehandlung mit „Gerät nicht sichtbar“ verhindert Enumeration von Konfigurationszuständen).
  • Portal*Resources mit serverseitiger Feld-Whitelist — geben ausschließlich Felder mit field_definitions.small_view_visible = true aus (via FieldDefinitionService); das ist die V2-Entsprechung der V1-small_view-Filterung, aber an der API-Grenze statt im View-Rendering. Unauthentifizierte Antworten enthalten nicht freigegebene Felder damit gar nicht erst.
  • Kein neues Schema erforderlich für den MVP. Optionale spätere Tabellen (Phase 4): portal_access_tokens (Widerruf), portal_access_log (Audit) — als V2-Tabellen nach V2-Konventionen (uuid-PK, DB-agnostisch).
  • InventoryQrController umstellen: Das erzeugte QR-SVG muss auf die öffentliche Route zeigen (/inventory/qrcode/{mtag}), nicht auf die interne Detailseite — sonst produziert V2 Etiketten, die Gäste nicht öffnen können. Eingeloggte Nutzer werden von der öffentlichen Seite aus zur Vollansicht weitergeleitet (V1-Semantik: eine URL für beide Welten).
  • Admin-API: GET/PUT /api/v2/admin/portal-settings liest/schreibt die vier Flag-Spalten der QR-Code-Rolle (Werte-Schreiben in geteilte V1-Tabellen ist erlaubt; das Schema bleibt unangetastet). Validierung wie V1: Flag nur mit gesetztem Pfad.

Frontend (Nuxt)

  • Seiten: pages/portal/index.vue (Liste), pages/portal/[id].vue und pages/qr/[id].vue (Detail; QR-Variante ohne Portal-Navigation, analog V1 smallViewQRLink). Mobile-first — QR-Scans kommen praktisch immer vom Smartphone.
  • Layout: neues layouts/portal.vue nach Vorbild von layouts/print.vue (kein Sidebar-Chrome, Branding + „Zum Login“-Link wie V1 main_portal).
  • Auth-Middleware: middleware/auth.global.ts von der hartkodierten Pfadliste auf to.meta.public === true umstellen; die Portal-Seiten deklarieren definePageMeta({ public: true, layout: 'portal' }). (Die bestehenden Seiten register, forgot-password etc. profitieren von derselben Aufräumarbeit.)
  • API-Zugriff: useApi() um einen Public-Modus erweitern (kein Token-Header, kein 401-Redirect) oder schlankes usePortalApi()-Composable.
  • Felder: Die Detail-/Listen-Darstellung nutzt das bereits vorhandene smallViewFields aus useFieldConfig() — als Darstellungs-Reihenfolge; die Sicherheits-Filterung geschieht serverseitig (s. o.).
  • i18n: über den bestehenden öffentlichen Endpoint /public/translations/bundle/{lang}.

Konfigurationsparität und Migration

Konfigurationselement V1 V2 Migrationsaufwand
Public User user.is_public (genau einer) identische Spalte; Einzigkeit-Validierung in Store/Update-Request nachziehen keiner
Sichtbare Kunden map_user identische Tabelle, UI vorhanden (/users/{id}/customers) keiner
Portal an/aus + Pfad authitem.portal_enable/portal_start_address identische Spalten, neue Admin-Settings-UI keiner
QR-Links an/aus + Pfad authitem.qrlink_enable/qrlink_address identische Spalten, dito keiner
Sichtbare Reiter Operationen der QR_Code_Role identische authitem*-Tabellen keiner
Sichtbare Felder field_configuration.small_view_visible field_definitions.small_view_visible (+ Spiegelung nach field_configuration via FieldConfigurationMirror) keiner

Die V1→V2-Datenmigration nimmt die Konfiguration automatisch mit; es gibt keine neuen Pflicht-Tabellen. Yii-Migrationen sind nicht erforderlich, da alle Spalten in V1 bereits existieren (m240320_082359, m250606_023205).

URL-Kompatibilität und Cutover

  1. Koexistenzphase: V1 beantwortet /inventory/qrcode/<id> weiterhin selbst; V2-Portalrouten liegen parallel unter /v2/portal bzw. /v2/qr/<id>.
  2. Cutover: nginx-Regeln mappen die Legacy-URLs auf V2: /inventory/qrcode/<id>/v2/qr/<id> sowie /frontend/frontendInventory/smallView?id=<id>/v2/qr/<id> (301). Dieser Schritt gehört in die Cutover-Checkliste — gedruckte Etiketten sind der einzige calServer-Artefakttyp, der beim Kunden physisch im Feld liegt.
  3. QR-Etikettendruck: Der V1-Jasper-Report QR_Code_View.jrxml läuft über die Report-Strategie (ADR-009) weiter; ein V2-natives Sticker-Bundle kodiert dieselbe URL-Form.

Sicherheitsvergleich

Aspekt V1 V2-Zielzustand
Rate-Limiting keins throttle auf allen Public-Routen (z. B. 60/min)
Session/Token für Gäste 24-h-Auto-Login als Public User keine — stateless Public-Kontext
Schreibzugriff Rollen-Denies (checkAccessForUser) strukturell unmöglich (nur GET-Routen, kein auth-Kontext)
Feld-Exposition Detail-View filtert beim Rendern serverseitige Whitelist in Portal*Resources (small_view_visible)
Reiter-/Modulzugriff Rollen-Operationen identisch (aus denselben Tabellen), aber pro Endpoint erzwungen
URL-Geheimnis UUID-MTAG UUID bleibt (Etiketten-Kompatibilität); Enumeration durch Throttle + einheitliche 404 erschwert
Widerruf Kundenzuordnung entfernen identisch; zusätzlich optional Phase 4 (Token pro Gerät)
Audit keins optional portal_access_log (Phase 4)

Umsetzungsphasen

GET /public/qr/{mtag} + PortalAccessService + PortalGuard, pages/qr/[id].vue mit Portal-Layout, Middleware-Umbau auf meta.public, InventoryQrController auf die öffentliche URL umstellen, Feld-Whitelist-Resource. Akzeptanzkriterium: Ein mit V1 gedrucktes Etikett löst (mit nginx-Mapping) auf einer V2-Installation korrekt auf und zeigt nur small_view_visible-Felder sichtbarer Geräte.

Phase 2 — Portal

/public/portal/config + Geräteliste + Reiter-Endpunkte + Dokument-Download (nur freigegebene Dokumente), pages/portal/index.vue + [id].vue, Reiter-Gating aus den Rollen-Operationen.

Phase 3 — Admin & Doku

Admin-Settings-UI für die vier Rollen-Flags (/api/v2/admin/portal-settings), Einzigkeit-Validierung für is_public im User-CRUD, Admin-Handbuch in docs-v2/ (DE+EN), Roadmap-Eintrag.

Phase 4 — Härtung (optional, nach Bedarf)

Widerrufbare Zugriffstokens pro Gerät (zusätzlich zur MTAG-URL), Ablaufdaten für sensible Dokumente, portal_access_log mit Statistik-Widget.

Jede Phase liefert Feature- und Unit-Tests (Backend) bzw. Vitest-Tests (Frontend) gemäß den CLAUDE.md-Pflichten; Phase 1 und 2 sind unabhängig deploybar, da QR-Links in V1 bewusst portal-unabhängig sind.


Risiken und Gegenmaßnahmen

Risiko Gegenmaßnahme
Datenleck durch zu breite Public-Resources Serverseitige Feld-Whitelist (small_view_visible), Feature-Tests, die die Antwortform explizit einfrieren (Snapshot der erlaubten Keys)
UUID-Enumeration Throttle, einheitliche 404 für „existiert nicht“ / „nicht sichtbar“ / „Feature aus“
Fehlkonfiguration: Public User mit weitreichenden Kundenzuordnungen Bewusste Zuordnung bleibt Betreiber-Entscheidung (wie V1); Admin-UI zeigt Warnhinweis mit Anzahl öffentlich sichtbarer Geräte
Doppelte Sichtbarkeitslogik driftet MapUserScope/InventoryVisibility und FieldDefinitionService als gemeinsame Bausteine; keine Duplikation von Query-Logik in Controllern
DSGVO: personenbezogene Daten öffentlich Feld-Whitelist per Feldmanagement steuerbar; Doku-Hinweis, Kontakt-/Personenfelder standardmäßig nicht small_view_visible
V1/V2-Koexistenz: zwei Portale gleichzeitig aktiv Betriebsempfehlung dokumentieren: pro Installation nur ein Portal aktiv schalten; Flags sind geteilt, daher konsistent

Offene Punkte

  1. Custom Fields im Portal: custom_fields-JSON-Inhalte nur anzeigen, wenn die zugehörige field_definition small_view_visible ist — Klärung, ob das dem V1-Verhalten entspricht.
  2. Branding: Soll das Portal das Instanz-Branding (/auth/branding) übernehmen oder eigene Einstellungen (Logo, Begrüßungstext) erhalten?
  3. Portal-Suche/-Filter-Umfang: V1 bietet das volle Grid-Filterset; für V2 zunächst einfache Suche + Statusfilter, Ausbau nach Bedarf?
  4. Mehrere Public User (Multi-Portal, z. B. je Endkunde ein Portal): V1 erlaubt genau einen — beibehalten oder als Phase-4-Erweiterung denken (hätte Schema-Folgen: Portal-Slug pro Public User)?
  5. portal_start_address/qrlink_address-Semantik in V2: In V1 frei konfigurierbare Pfade; in V2 sprechen feste Routen (/portal, /qr/<id>) für Einfachheit — die konfigurierten Pfade dann nur noch als nginx-Redirect-Quellen respektieren?