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:
- 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. - 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. - Multi-DB-Gebot: MySQL, PostgreSQL, MSSQL gleichberechtigt (siehe
CLAUDE.md,DATABASE_ABSTRACTION_PLAN.md). - 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.
- 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:
- 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üris_public-User hart alleedit/delete-Operationen. - QR-Code-Rolle (
authitem.name = 'QR_Code_Role') — trägt vier Konfigurationsspalten:portal_enable+portal_start_address(Portal) undqrlink_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(...)insmall_view.php). - Kundenzuordnung
map_user(user_uID↔customer_KTAG) — die eigentliche „Wer sieht was“-Logik: Die Portal-Liste filtert perFrontendInventory::ofCustomers()auf die dem Public User zugeordneten Kunden; jede Detailansicht prüft percanView(), 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 |
Option C: Token-basierte Share-Links / Signed URLs pro Gerät¶
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ädtuser.is_public = 1(404, wenn keiner existiert — V1-Verhalten), liest die Flags der QR-Code-Rolle, berechnet die sichtbarencustomer_ids überMapUserScopeund die sichtbaren Reiter über die Rollen-Operationen (authitemchild, Auflösung analogUser::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*Resourcesmit serverseitiger Feld-Whitelist — geben ausschließlich Felder mitfield_definitions.small_view_visible = trueaus (viaFieldDefinitionService); 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). InventoryQrControllerumstellen: 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-settingsliest/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].vueundpages/qr/[id].vue(Detail; QR-Variante ohne Portal-Navigation, analog V1smallViewQRLink). Mobile-first — QR-Scans kommen praktisch immer vom Smartphone. - Layout: neues
layouts/portal.vuenach Vorbild vonlayouts/print.vue(kein Sidebar-Chrome, Branding + „Zum Login“-Link wie V1main_portal). - Auth-Middleware:
middleware/auth.global.tsvon der hartkodierten Pfadliste aufto.meta.public === trueumstellen; die Portal-Seiten deklarierendefinePageMeta({ public: true, layout: 'portal' }). (Die bestehenden Seitenregister,forgot-passwordetc. profitieren von derselben Aufräumarbeit.) - API-Zugriff:
useApi()um einen Public-Modus erweitern (kein Token-Header, kein 401-Redirect) oder schlankesusePortalApi()-Composable. - Felder: Die Detail-/Listen-Darstellung nutzt das bereits vorhandene
smallViewFieldsaususeFieldConfig()— 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¶
- Koexistenzphase: V1 beantwortet
/inventory/qrcode/<id>weiterhin selbst; V2-Portalrouten liegen parallel unter/v2/portalbzw./v2/qr/<id>. - 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. - QR-Etikettendruck: Der V1-Jasper-Report
QR_Code_View.jrxmllä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¶
Phase 1 — QR-Link (MVP)¶
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¶
- Custom Fields im Portal:
custom_fields-JSON-Inhalte nur anzeigen, wenn die zugehörigefield_definitionsmall_view_visibleist — Klärung, ob das dem V1-Verhalten entspricht. - Branding: Soll das Portal das Instanz-Branding (
/auth/branding) übernehmen oder eigene Einstellungen (Logo, Begrüßungstext) erhalten? - Portal-Suche/-Filter-Umfang: V1 bietet das volle Grid-Filterset; für V2 zunächst einfache Suche + Statusfilter, Ausbau nach Bedarf?
- 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)?
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?