Include recent Magic app work: Tauri native shell, iOS device detection via supervise-magic sidecar, MDM client, local HTTP server, new pages (detect, enroll, supervise, sideload, pair, preflight, configure, done), and updated device section/status UI.
511 lines
14 KiB
Markdown
511 lines
14 KiB
Markdown
# MDM Healthcheck Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Ein Nitro-Cron-Healthcheck prüft alle 5 Minuten alle über `UserDevice.mdmId` mit NanoMDM verknüpften iOS-Geräte und persistiert Enrollment-, Supervision-Status sowie letzten Check-In auf `UserDevice`.
|
|
|
|
**Architecture:** Ein neues Nitro-Plugin orchestriert den Lauf. `backend/server/db/mdm.ts` bekommt Bulk-Lesefunktionen für NanoMDM und ein Update für `UserDevice`. Die benötigten Spalten werden per Prisma-Migration auf `UserDevice` ergänzt. Keine neue Tabelle.
|
|
|
|
**Tech Stack:** Nuxt/Nitro, Prisma, PostgreSQL (NanoMDM), TypeScript, Vitest.
|
|
|
|
---
|
|
|
|
## Files
|
|
|
|
- `backend/prisma/schema.prisma` — `UserDevice` um `mdmEnrolled`, `mdmSupervised`, `mdmLastSeenAt` erweitern
|
|
- `backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql` — idempotente Migration
|
|
- `backend/server/db/mdm.ts` — Bulk-Abfrage von NanoMDM, Update der UserDevice-Health-Spalten
|
|
- `backend/server/plugins/mdm-health-cron.ts` — 5-Minuten-Cron
|
|
- `backend/tests/devices/mdm-health.test.ts` — Unit-Tests für DB-Layer und Cron-Logik
|
|
|
|
---
|
|
|
|
### Task 1: Extend UserDevice Prisma schema
|
|
|
|
**Files:**
|
|
- Modify: `backend/prisma/schema.prisma:1111-1114`
|
|
|
|
- [ ] **Step 1: Add three new fields after `mdmId`**
|
|
|
|
```prisma
|
|
// ─── NanoMDM iOS Enrollment ─────────────────────────────────────────────
|
|
/// Apple-Geräte-UDID wie von NanoMDM verwendet (z.B. 00008101-000544261E87001E).
|
|
/// NULL → Gerät ist nicht mit einem MDM-UDID verknüpft.
|
|
mdmId String? @map("mdm_id")
|
|
|
|
/// Gespiegelter Enrollment-Status aus NanoMDM enrollments.enabled.
|
|
mdmEnrolled Boolean? @map("mdm_enrolled")
|
|
|
|
/// Gespiegelter Supervision-Status aus NanoMDM devices.unlock_token IS NOT NULL.
|
|
mdmSupervised Boolean? @map("mdm_supervised")
|
|
|
|
/// Letzter NanoMDM Check-In (enrollments.last_seen_at).
|
|
mdmLastSeenAt DateTime? @map("mdm_last_seen_at") @db.Timestamptz(6)
|
|
```
|
|
|
|
- [ ] **Step 2: Validate schema**
|
|
|
|
Run: `cd backend && pnpm prisma validate`
|
|
Expected: `Prisma schema validation - (get config )`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add backend/prisma/schema.prisma
|
|
git commit -m "feat(mdm): add mdmEnrolled, mdmSupervised, mdmLastSeenAt to UserDevice schema"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create migration for MDM health columns
|
|
|
|
**Files:**
|
|
- Create: `backend/prisma/migrations/20250618_add_mdm_health_columns/migration.sql`
|
|
|
|
- [ ] **Step 1: Write idempotent migration**
|
|
|
|
```sql
|
|
-- Spiegel-Spalten für NanoMDM Enrollment/Supervision-Status auf UserDevice.
|
|
-- mdm_id wurde manuell hinzugefügt; IF NOT EXISTS macht die Migration idempotent.
|
|
|
|
ALTER TABLE "rebreak"."user_devices"
|
|
ADD COLUMN IF NOT EXISTS "mdm_id" TEXT,
|
|
ADD COLUMN IF NOT EXISTS "mdm_enrolled" BOOLEAN,
|
|
ADD COLUMN IF NOT EXISTS "mdm_supervised" BOOLEAN,
|
|
ADD COLUMN IF NOT EXISTS "mdm_last_seen_at" TIMESTAMPTZ(6);
|
|
|
|
CREATE INDEX IF NOT EXISTS "user_devices_mdm_id_idx"
|
|
ON "rebreak"."user_devices"("mdm_id");
|
|
```
|
|
|
|
- [ ] **Step 2: Apply migration locally**
|
|
|
|
Run: `cd backend && pnpm prisma migrate dev --name add_mdm_health_columns`
|
|
Expected: Migration applies successfully; Prisma Client is regenerated.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add backend/prisma/migrations/20250618_add_mdm_health_columns
|
|
git commit -m "feat(mdm): add migration for UserDevice MDM health columns"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Extend mdm.ts DB layer
|
|
|
|
**Files:**
|
|
- Modify: `backend/server/db/mdm.ts`
|
|
|
|
- [ ] **Step 1: Add types and select constant after `MdmDeviceStatus`**
|
|
|
|
```typescript
|
|
export interface MdmEnrollmentStatus {
|
|
enrolled: boolean;
|
|
supervised: boolean;
|
|
lastSeenAt: Date | null;
|
|
}
|
|
|
|
export interface UserDeviceMdmHealthRecord {
|
|
id: string;
|
|
userId: string;
|
|
deviceId: string;
|
|
platform: string;
|
|
mdmId: string;
|
|
mdmEnrolled: boolean | null;
|
|
mdmSupervised: boolean | null;
|
|
mdmLastSeenAt: Date | null;
|
|
}
|
|
|
|
const USER_DEVICE_MDM_HEALTH_SELECT = {
|
|
id: true,
|
|
userId: true,
|
|
deviceId: true,
|
|
platform: true,
|
|
mdmId: true,
|
|
mdmEnrolled: true,
|
|
mdmSupervised: true,
|
|
mdmLastSeenAt: true,
|
|
} as const;
|
|
```
|
|
|
|
- [ ] **Step 2: Add `getLinkedUserDevices`**
|
|
|
|
```typescript
|
|
/**
|
|
* Load all iOS devices that have a NanoMDM UDID link.
|
|
*/
|
|
export async function getLinkedUserDevices(): Promise<UserDeviceMdmHealthRecord[]> {
|
|
const db = usePrisma();
|
|
return db.userDevice.findMany({
|
|
where: { platform: "ios", mdmId: { not: null } },
|
|
select: USER_DEVICE_MDM_HEALTH_SELECT,
|
|
}) as Promise<UserDeviceMdmHealthRecord[]>;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add `getMdmEnrollmentStatusesByUdids`**
|
|
|
|
```typescript
|
|
/**
|
|
* Bulk-query NanoMDM for enrollment/supervision/last-seen status.
|
|
* Returns a map keyed by UDID. Missing devices are omitted.
|
|
*/
|
|
export async function getMdmEnrollmentStatusesByUdids(
|
|
udids: string[],
|
|
): Promise<Map<string, MdmEnrollmentStatus>> {
|
|
if (udids.length === 0) {
|
|
return new Map();
|
|
}
|
|
|
|
const pool = useMdmPool();
|
|
const result = await pool.query<{
|
|
udid: string;
|
|
enrolled: boolean;
|
|
supervised: boolean;
|
|
last_seen_at: Date | null;
|
|
}>(
|
|
`SELECT
|
|
d.id AS udid,
|
|
(e.enabled = TRUE) AS enrolled,
|
|
(d.unlock_token IS NOT NULL) AS supervised,
|
|
e.last_seen_at
|
|
FROM devices d
|
|
LEFT JOIN enrollments e ON e.device_id = d.id
|
|
WHERE d.id = ANY($1::text[])`,
|
|
[udids],
|
|
);
|
|
|
|
const map = new Map<string, MdmEnrollmentStatus>();
|
|
for (const row of result.rows) {
|
|
map.set(row.udid, {
|
|
enrolled: row.enrolled,
|
|
supervised: row.supervised,
|
|
lastSeenAt: row.last_seen_at,
|
|
});
|
|
}
|
|
return map;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add `updateUserDeviceMdmHealth`**
|
|
|
|
```typescript
|
|
/**
|
|
* Persist mirrored MDM health status on a UserDevice row.
|
|
*/
|
|
export async function updateUserDeviceMdmHealth(
|
|
id: string,
|
|
status: MdmEnrollmentStatus,
|
|
): Promise<void> {
|
|
const db = usePrisma();
|
|
await db.userDevice.update({
|
|
where: { id },
|
|
data: {
|
|
mdmEnrolled: status.enrolled,
|
|
mdmSupervised: status.supervised,
|
|
mdmLastSeenAt: status.lastSeenAt,
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run typecheck**
|
|
|
|
Run: `cd backend && pnpm typecheck`
|
|
Expected: No type errors.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/server/db/mdm.ts
|
|
git commit -m "feat(mdm): add bulk MDM health status read/write helpers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Create mdm-health-cron.ts plugin
|
|
|
|
**Files:**
|
|
- Create: `backend/server/plugins/mdm-health-cron.ts`
|
|
|
|
- [ ] **Step 1: Write the cron plugin**
|
|
|
|
```typescript
|
|
/**
|
|
* MDM Healthcheck Cron
|
|
*
|
|
* Läuft alle 5 Minuten. Prüft für alle mit NanoMDM verknüpften iOS-Geräte
|
|
* den aktuellen Enrollment-/Supervision-Status und spiegelt ihn auf UserDevice.
|
|
*/
|
|
import { consola } from "consola";
|
|
import {
|
|
getLinkedUserDevices,
|
|
getMdmEnrollmentStatusesByUdids,
|
|
updateUserDeviceMdmHealth,
|
|
type MdmEnrollmentStatus,
|
|
} from "../db/mdm";
|
|
|
|
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
const INITIAL_DELAY_MS = 30 * 1000;
|
|
|
|
export default defineNitroPlugin((nitro) => {
|
|
if (import.meta.dev) {
|
|
consola.info("[mdm-health-cron] Skipping cron in dev mode");
|
|
return;
|
|
}
|
|
|
|
consola.info("[mdm-health-cron] Starting (5min interval)");
|
|
|
|
const initialTimer = setTimeout(() => {
|
|
runMdmHealthCheck().catch(() => {});
|
|
}, INITIAL_DELAY_MS);
|
|
|
|
const interval = setInterval(() => {
|
|
runMdmHealthCheck().catch(() => {});
|
|
}, FIVE_MINUTES);
|
|
|
|
nitro.hooks.hook("close", () => {
|
|
clearTimeout(initialTimer);
|
|
clearInterval(interval);
|
|
});
|
|
});
|
|
|
|
async function runMdmHealthCheck() {
|
|
const start = Date.now();
|
|
try {
|
|
const devices = await getLinkedUserDevices();
|
|
if (devices.length === 0) {
|
|
consola.info("[mdm-health-cron] No linked iOS devices");
|
|
return;
|
|
}
|
|
|
|
const statuses = await getMdmEnrollmentStatusesByUdids(
|
|
devices.map((d) => d.mdmId),
|
|
);
|
|
|
|
let updated = 0;
|
|
let unchanged = 0;
|
|
|
|
for (const device of devices) {
|
|
const status = statuses.get(device.mdmId) ?? {
|
|
enrolled: false,
|
|
supervised: false,
|
|
lastSeenAt: null,
|
|
};
|
|
|
|
const changed =
|
|
device.mdmEnrolled !== status.enrolled ||
|
|
device.mdmSupervised !== status.supervised ||
|
|
!sameNullableDate(device.mdmLastSeenAt, status.lastSeenAt);
|
|
|
|
if (changed) {
|
|
await updateUserDeviceMdmHealth(device.id, status);
|
|
updated++;
|
|
} else {
|
|
unchanged++;
|
|
}
|
|
}
|
|
|
|
consola.success(
|
|
`[mdm-health-cron] Checked ${devices.length} devices in ${Date.now() - start}ms (${updated} updated, ${unchanged} unchanged)`,
|
|
);
|
|
} catch (err: any) {
|
|
consola.error("[mdm-health-cron] run failed:", err?.message ?? err);
|
|
}
|
|
}
|
|
|
|
function sameNullableDate(a: Date | null, b: Date | null): boolean {
|
|
if (a === null && b === null) return true;
|
|
if (a === null || b === null) return false;
|
|
return a.getTime() === b.getTime();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
Run: `cd backend && pnpm typecheck`
|
|
Expected: No errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add backend/server/plugins/mdm-health-cron.ts
|
|
git commit -m "feat(mdm): add 5-minute MDM healthcheck cron"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Write tests for MDM healthcheck
|
|
|
|
**Files:**
|
|
- Create: `backend/tests/devices/mdm-health.test.ts`
|
|
|
|
- [ ] **Step 1: Write tests for DB helpers**
|
|
|
|
```typescript
|
|
/**
|
|
* Tests für MDM-Healthcheck DB-Layer.
|
|
*/
|
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import {
|
|
getLinkedUserDevices,
|
|
getMdmEnrollmentStatusesByUdids,
|
|
updateUserDeviceMdmHealth,
|
|
} from "../../server/db/mdm";
|
|
|
|
const mockPrisma = {
|
|
userDevice: {
|
|
findMany: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
};
|
|
|
|
vi.mock("../../server/utils/prisma", () => ({
|
|
usePrisma: () => mockPrisma,
|
|
}));
|
|
|
|
const mockPool = {
|
|
query: vi.fn(),
|
|
};
|
|
|
|
vi.mock("pg", () => ({
|
|
default: { Pool: vi.fn(() => mockPool) },
|
|
Pool: vi.fn(() => mockPool),
|
|
}));
|
|
|
|
vi.mock("../../server/utils/runtime-config", () => ({
|
|
useRuntimeConfig: () => ({ mdmDatabaseUrl: "postgres://fake" }),
|
|
}));
|
|
|
|
describe("getLinkedUserDevices", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns only iOS devices with a non-null mdmId", async () => {
|
|
mockPrisma.userDevice.findMany.mockResolvedValue([
|
|
{ id: "d1", userId: "u1", deviceId: "cap1", platform: "ios", mdmId: "udid-1", mdmEnrolled: true, mdmSupervised: true, mdmLastSeenAt: null },
|
|
]);
|
|
|
|
const result = await getLinkedUserDevices();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].mdmId).toBe("udid-1");
|
|
expect(mockPrisma.userDevice.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { platform: "ios", mdmId: { not: null } },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getMdmEnrollmentStatusesByUdids", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns an empty map for empty input", async () => {
|
|
const result = await getMdmEnrollmentStatusesByUdids([]);
|
|
expect(result.size).toBe(0);
|
|
expect(mockPool.query).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps NanoMDM rows to status objects", async () => {
|
|
const lastSeen = new Date("2026-06-15T11:25:00Z");
|
|
mockPool.query.mockResolvedValue({
|
|
rows: [
|
|
{ udid: "udid-1", enrolled: true, supervised: true, last_seen_at: lastSeen },
|
|
{ udid: "udid-2", enrolled: false, supervised: true, last_seen_at: null },
|
|
],
|
|
});
|
|
|
|
const result = await getMdmEnrollmentStatusesByUdids(["udid-1", "udid-2"]);
|
|
|
|
expect(result.get("udid-1")).toEqual({ enrolled: true, supervised: true, lastSeenAt: lastSeen });
|
|
expect(result.get("udid-2")).toEqual({ enrolled: false, supervised: true, lastSeenAt: null });
|
|
});
|
|
});
|
|
|
|
describe("updateUserDeviceMdmHealth", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("updates the mirrored status columns", async () => {
|
|
mockPrisma.userDevice.update.mockResolvedValue({ id: "d1" });
|
|
|
|
await updateUserDeviceMdmHealth("d1", {
|
|
enrolled: true,
|
|
supervised: false,
|
|
lastSeenAt: new Date("2026-06-15T11:25:00Z"),
|
|
});
|
|
|
|
expect(mockPrisma.userDevice.update).toHaveBeenCalledWith({
|
|
where: { id: "d1" },
|
|
data: {
|
|
mdmEnrolled: true,
|
|
mdmSupervised: false,
|
|
mdmLastSeenAt: new Date("2026-06-15T11:25:00Z"),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
Run: `cd backend && pnpm test backend/tests/devices/mdm-health.test.ts`
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add backend/tests/devices/mdm-health.test.ts
|
|
git commit -m "test(mdm): add MDM healthcheck DB-layer tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Apply migration and verify
|
|
|
|
- [ ] **Step 1: Apply migration to the target database**
|
|
|
|
Run: `cd backend && pnpm prisma migrate deploy`
|
|
Expected: Migration `20250618_add_mdm_health_columns` applies successfully.
|
|
|
|
- [ ] **Step 2: Regenerate Prisma Client**
|
|
|
|
Run: `cd backend && pnpm prisma generate`
|
|
Expected: Client generated.
|
|
|
|
- [ ] **Step 3: Run full test suite (at least device tests)**
|
|
|
|
Run: `cd backend && pnpm test backend/tests/devices/`
|
|
Expected: All tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add backend/prisma/schema.prisma backend/prisma/migrations backend/server/db/mdm.ts backend/server/plugins/mdm-health-cron.ts backend/tests/devices/mdm-health.test.ts
|
|
git commit -m "feat(mdm): implement MDM healthcheck cron with mirrored status columns"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage:**
|
|
- Healthcheck runs every 5 minutes → Task 4.
|
|
- Supervised devices checked → Task 3 query filters via `unlock_token IS NOT NULL`.
|
|
- Enrollment status changes persisted → Task 3 `updateUserDeviceMdmHealth`.
|
|
- No new table → only `UserDevice` columns added → Tasks 1-2.
|
|
|
|
**Placeholder scan:**
|
|
- No TBD/TODO/fill-in-details.
|
|
- All code blocks contain concrete implementation.
|
|
|
|
**Type consistency:**
|
|
- `MdmEnrollmentStatus` used consistently across Tasks 3, 4, 5.
|
|
- Column names `mdmEnrolled`, `mdmSupervised`, `mdmLastSeenAt` match in schema, migration, DB layer, tests.
|