rebreak-monorepo/docs/superpowers/specs/2026-06-17-mdm-device-link-design.md

5.8 KiB

MDM Device Link für Magic App — Design

Ziel

In der ReBreak Magic App unter „Meine iOS-Geräte“ automatisch prüfen, ob ein iOS-Gerät im NanoMDM enrolled ist, und den passenden Status in der Device-Card anzeigen.

Annahmen / Einschränkungen

  • Native-App-DB (rebreak auf rebreak-server) und NanoMDM-DB (nanomdm auf rebreak-mdm) liegen auf unterschiedlichen Hetzner-VPS.
  • Apple gibt die Hardware-UDID nicht an React Native / Expo Apps weiter. Die Native-App-deviceId ist identifierForVendor, nicht die NanoMDM-UDID.
  • Daher braucht UserDevice eine zusätzliche Spalte mdmId, die die NanoMDM-UDID speichert.

Architektur

Magic App (Tauri/Nuxt)
  │
  ├─ GET /api/magic/devices/:deviceId/mdm  →  Backend
  │                                          │
  │                                          ├─ Prisma: UserDevice.mdmId lesen
  │                                          │
  │                                          └─ pg.Pool → NanoMDM DB auf rebreak-mdm
  │                                              SELECT devices / command_results
  │
  └─ POST /api/magic/devices/:deviceId/mdm-link
       (setzt UserDevice.mdmId, z.B. nach USB-Enrollment)

Datenmodell

model UserDevice {
  // ... bestehende Felder ...
  mdmId String? @map("mdm_id")  // NanoMDM-UDID, nullable
}

model DeviceProtectionState {
    id             String    @id @default(uuid()) @db.Uuid
    userId         String    @map("user_id") @db.Uuid
    deviceId       String    @map("device_id")
    platform       String
    protectionType String    @map("protection_type")
    active         Boolean
    lastSeenAt     DateTime? @map("last_seen_at")
    changedAt      DateTime  @default(now()) @map("changed_at")
    reason         String?

    @@unique([userId, deviceId, protectionType])
    @@map("device_protection_states")
    @@schema("rebreak")
}

model DeviceProtectionStateLog {
    id             String   @id @default(uuid()) @db.Uuid
    userId         String   @map("user_id") @db.Uuid
    deviceId       String   @map("device_id")
    protectionType String   @map("protection_type")
    active         Boolean
    occurredAt     DateTime @map("occurred_at")
    reason         String?
    source         String

    @@index([userId, deviceId])
    @@map("device_protection_state_logs")
    @@schema("rebreak")
}

Endpunkte

GET /api/magic/devices/:deviceId/mdm

Auth: Magic-Session (requireUser)

Response enrolled:

{
  "success": true,
  "data": {
    "enrolled": true,
    "company": "ReBreak",
    "supervised": true,
    "lockProfileInstalled": true,
    "lastAppPushAt": "2026-06-11T19:09:04.363Z"
  }
}

Response not enrolled:

{ "success": true, "data": { "enrolled": false } }

Wenn mdmId gesetzt ist, aber NanoMDM das Gerät nicht mehr kennt, wird mdmId automatisch auf null gesetzt.

lockProfileInstalled wird nicht aus der bloßen MDM-Enrollment abgeleitet, sondern aus dem lokal gespeicherten DeviceProtectionState mit protectionType = "nefilter". Solange die Native-App diesen Zustand noch nicht meldet, ist der Wert false.

POST /api/magic/devices/:deviceId/mdm-link

Body: { "mdmId": "00008150-001C686601F0401C" }

Setzt UserDevice.mdmId für das iOS-Gerät des aktuellen Users.

POST /api/devices/protection-state

Body:

{
  "deviceId": "string",
  "platform": "ios",
  "protectionType": "nefilter | vpn | dns",
  "active": true,
  "reason": "optional",
  "source": "optional"
}

Upsertet den per-Gerät-Schutz-Status und schreibt bei Änderung einen Log-Eintrag.

Magic-App UI

IosDeviceCard.vue ruft über useMdmStatus(deviceId) den Backend-Status ab:

  • Enrolled: Grüne Box mit Company / Supervised / Lock-Profil / Letzter App-Push.
  • Not enrolled: Gelbe Box mit Hinweis: „Nicht MDM-enrolled. Verbinde das iPhone per USB. Das Enrollment dauert ca. 2 Minuten und geht ohne Datenverlust.“
  • Wenn ein iPhone per USB verbunden ist, erscheint ein Button „Mit MDM verknüpfen“, der linkMdmDevice(deviceId, udid) aufruft.

Infra / Env

  • MDM_DATABASE_URL muss im Backend-Env gesetzt sein.
  • rebreak-mdm PostgreSQL muss auf der öffentlichen IP lauschen und den Backend-Server in pg_hba.conf + UFW erlauben.

Dateien

Backend:

  • backend/prisma/schema.prisma
  • backend/nitro.config.ts
  • backend/server/db/mdm.ts
  • backend/server/db/device-protection.ts
  • backend/server/api/magic/devices/[deviceId]/mdm.get.ts
  • backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts
  • backend/server/api/devices/protection-state.post.ts
  • backend/start-staging.sh

Magic App:

  • apps/rebreak-magic/src-tauri/src/backend/api.rs
  • apps/rebreak-magic/src-tauri/src/lib.rs
  • apps/rebreak-magic/app/composables/useTauri.ts
  • apps/rebreak-magic/app/composables/useMdmStatus.ts
  • apps/rebreak-magic/app/components/IosDeviceCard.vue
  • apps/rebreak-magic/app/components/DevLogDrawer.vue

Test-Ergebnis (Staging)

  • Backend-Build:
  • Magic Rust: cargo check
  • Magic Nuxt-Build:
  • API-Test für User charioanouar@gmail.com, iPhone MHFLX23QM0:
    • GET .../mdm{ enrolled: true, company: "ReBreak", supervised: true, lockProfileInstalled: false, lastAppPushAt: "2026-06-11T19:09:04.363Z" }
    • mdmId gelöscht → { enrolled: false }
    • POST .../mdm-link{ mdmId: "00008150-001C686601F0401C" }

Offene TODOs

  • nefilter, vpn, dns werden noch nicht von den Clients gemeldet. Dafür ist POST /api/devices/protection-state vorbereitet.
  • Die Tabellen device_protection_states / device_protection_state_logs wurden für den schnellen Test manuell angelegt. Für Produktion muss eine Prisma-Migration erstellt und deployed werden.
  • Die Spalte mdm_id wurde für den schnellen Test manuell mit ALTER TABLE angelegt.