feat(mdm): remove mdm_lock type, derive lockProfileInstalled from nefilter state
This commit is contained in:
parent
5b0a4d03d2
commit
b87ec08431
65
.github/workflows/build-rebreak-magic-win.yml
vendored
Normal file
65
.github/workflows/build-rebreak-magic-win.yml
vendored
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
name: Build ReBreak Magic Windows
|
||||||
|
|
||||||
|
# Baut den NSIS-Installer der neuen unified ReBreak-Magic-App (Tauri) auf einem
|
||||||
|
# echten Windows-Runner — vom Mac aus geht kein Cross-Compile (MSVC + WebView2).
|
||||||
|
# Artefakt: x64-Installer, herunterladbar unter Actions → Run → Artifacts.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "apps/rebreak-magic/**"
|
||||||
|
- ".github/workflows/build-rebreak-magic-win.yml"
|
||||||
|
- "ops/mdm/supervise-magic/**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-rebreak-magic-win
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: NSIS Installer (x64)
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.22"
|
||||||
|
|
||||||
|
- name: Install deps (rebreak-magic)
|
||||||
|
run: pnpm install --filter @rebreak/magic --no-frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build supervise-magic Sidecar (Windows)
|
||||||
|
working-directory: ops/mdm/supervise-magic
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
go build -o bin/rebreak-supervise-magic-x86_64-pc-windows-msvc.exe ./cmd/supervise
|
||||||
|
$triple = (rustc -vV | Select-String 'host: (.*)').Matches.Groups[1].Value
|
||||||
|
New-Item -ItemType Directory -Force -Path "../../apps/rebreak-magic/src-tauri/binaries" | Out-Null
|
||||||
|
Copy-Item "bin/rebreak-supervise-magic-x86_64-pc-windows-msvc.exe" "../../apps/rebreak-magic/src-tauri/binaries/supervise-magic-$triple.exe" -Force
|
||||||
|
|
||||||
|
- name: Build Tauri-App (Frontend + NSIS)
|
||||||
|
working-directory: apps/rebreak-magic
|
||||||
|
run: pnpm tauri build
|
||||||
|
|
||||||
|
- name: Upload Installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ReBreak-Magic-Windows-x64
|
||||||
|
path: apps/rebreak-magic/src-tauri/target/release/bundle/nsis/*.exe
|
||||||
|
if-no-files-found: error
|
||||||
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
scripts/tts-bench-out/
|
||||||
@ -64,6 +64,8 @@ export default defineNitroConfig({
|
|||||||
// ─── Database / Core ─────────────────────────────────────────────────
|
// ─── Database / Core ─────────────────────────────────────────────────
|
||||||
databaseUrl:
|
databaseUrl:
|
||||||
process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "",
|
process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "",
|
||||||
|
// NanoMDM Postgres connection (e.g. postgres://nanomdm:PASS@178.105.101.137:5432/nanomdm).
|
||||||
|
mdmDatabaseUrl: process.env.MDM_DATABASE_URL ?? "",
|
||||||
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
|
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
|
||||||
|
|
||||||
// ─── Admin / Cron ────────────────────────────────────────────────────
|
// ─── Admin / Cron ────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1102,20 +1102,20 @@ model UserDevice {
|
|||||||
/// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE,
|
/// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE,
|
||||||
/// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert.
|
/// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert.
|
||||||
magicRemovalPassword String? @map("magic_removal_password")
|
magicRemovalPassword String? @map("magic_removal_password")
|
||||||
/// Hardware-gebundene ID (z. B. System-UUID, ANDROID_ID, IDFV).
|
|
||||||
/// Wird vom Client geliefert, nicht vom Backend generiert.
|
|
||||||
hardwareId String? @map("hardware_id")
|
|
||||||
/// Wann der User die Entfernung des Magic-Profils beantragt hat.
|
/// Wann der User die Entfernung des Magic-Profils beantragt hat.
|
||||||
/// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar.
|
/// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar.
|
||||||
magicReleaseRequestedAt DateTime? @map("magic_release_requested_at")
|
magicReleaseRequestedAt DateTime? @map("magic_release_requested_at")
|
||||||
/// Temporärer Sleep-Mode für Magic-Desktop-Geräte. NULL = kein Cooldown aktiv.
|
/// Temporärer Sleep-Mode für Magic-Desktop-Geräte. NULL = kein Cooldown aktiv.
|
||||||
magicCooldownUntil DateTime? @map("magic_cooldown_until")
|
magicCooldownUntil DateTime? @map("magic_cooldown_until")
|
||||||
|
|
||||||
|
// ─── 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")
|
||||||
|
|
||||||
@@unique([userId, deviceId])
|
@@unique([userId, deviceId])
|
||||||
@@unique([userId, hardwareId])
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([deviceId])
|
@@index([deviceId])
|
||||||
@@index([hardwareId])
|
|
||||||
@@map("user_devices")
|
@@map("user_devices")
|
||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
@ -1150,6 +1150,40 @@ model ProtectionStateLog {
|
|||||||
@@schema("rebreak")
|
@@schema("rebreak")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DeviceProtectionState {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
deviceId String @map("device_id")
|
||||||
|
platform String // ios | android | mac | windows
|
||||||
|
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])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([deviceId])
|
||||||
|
@@index([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 // app | mdm | system | heartbeat
|
||||||
|
|
||||||
|
@@index([userId, deviceId])
|
||||||
|
@@map("device_protection_state_logs")
|
||||||
|
@@schema("rebreak")
|
||||||
|
}
|
||||||
|
|
||||||
model MagicPairingCode {
|
model MagicPairingCode {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
|
|||||||
92
backend/server/api/devices/protection-state.post.ts
Normal file
92
backend/server/api/devices/protection-state.post.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { requireUser } from "../../utils/auth";
|
||||||
|
import {
|
||||||
|
PROTECTION_TYPES,
|
||||||
|
upsertDeviceProtectionState,
|
||||||
|
type ProtectionType,
|
||||||
|
} from "../../db/device-protection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/devices/protection-state
|
||||||
|
*
|
||||||
|
* Body: {
|
||||||
|
* deviceId: string,
|
||||||
|
* platform: string,
|
||||||
|
* protectionType: 'nefilter' | 'vpn' | 'dns',
|
||||||
|
* active: boolean,
|
||||||
|
* reason?: string,
|
||||||
|
* source?: string
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Reports the per-device protection state from a client.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const {
|
||||||
|
deviceId,
|
||||||
|
platform,
|
||||||
|
protectionType,
|
||||||
|
active,
|
||||||
|
reason,
|
||||||
|
source,
|
||||||
|
} = body as {
|
||||||
|
deviceId?: string;
|
||||||
|
platform?: string;
|
||||||
|
protectionType?: string;
|
||||||
|
active?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
data: { error: "device_id_required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!platform) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
data: { error: "platform_required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!protectionType) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
data: { error: "protection_type_required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PROTECTION_TYPES.includes(protectionType as ProtectionType)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
data: {
|
||||||
|
error: "invalid_protection_type",
|
||||||
|
validTypes: PROTECTION_TYPES,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof active !== "boolean") {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
data: { error: "active_boolean_required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertDeviceProtectionState(
|
||||||
|
user.id,
|
||||||
|
deviceId,
|
||||||
|
platform,
|
||||||
|
protectionType as ProtectionType,
|
||||||
|
active,
|
||||||
|
undefined,
|
||||||
|
reason ?? null,
|
||||||
|
source ?? "app",
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
49
backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts
Normal file
49
backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
getUserDeviceByDeviceId,
|
||||||
|
setUserDeviceMdmId,
|
||||||
|
} from "../../../../db/mdm";
|
||||||
|
import { requireUser } from "../../../../utils/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple UDID: hex/dash, 20–50 chars.
|
||||||
|
*/
|
||||||
|
const UDID_RE = /^[A-Fa-f0-9-]{20,50}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/magic/devices/:deviceId/mdm-link
|
||||||
|
*
|
||||||
|
* Body: { mdmId: string }
|
||||||
|
*
|
||||||
|
* Links a user's iOS device to a NanoMDM UDID.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const deviceId = getRouterParam(event, "deviceId");
|
||||||
|
const body = await readBody(event);
|
||||||
|
const mdmId = (body?.mdmId as string | undefined)?.trim();
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
throw createError({ statusCode: 400, message: "deviceId required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mdmId) {
|
||||||
|
throw createError({ statusCode: 400, message: "mdmId required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UDID_RE.test(mdmId)) {
|
||||||
|
throw createError({ statusCode: 400, message: "invalid_udid_format" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await getUserDeviceByDeviceId(user.id, deviceId, "ios");
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw createError({ statusCode: 404, message: "device_not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setUserDeviceMdmId(user.id, deviceId, mdmId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { mdmId },
|
||||||
|
};
|
||||||
|
});
|
||||||
82
backend/server/api/magic/devices/[deviceId]/mdm.get.ts
Normal file
82
backend/server/api/magic/devices/[deviceId]/mdm.get.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
clearUserDeviceMdmId,
|
||||||
|
getMdmStatusByUdid,
|
||||||
|
getUserDeviceByDeviceId,
|
||||||
|
} from "../../../../db/mdm";
|
||||||
|
import { getDeviceProtectionState } from "../../../../db/device-protection";
|
||||||
|
import { requireUser } from "../../../../utils/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/magic/devices/:deviceId/mdm
|
||||||
|
*
|
||||||
|
* Returns the NanoMDM enrollment status for the user's iOS device and the
|
||||||
|
* locally tracked nefilter (lock profile) protection state.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const deviceId = getRouterParam(event, "deviceId");
|
||||||
|
|
||||||
|
if (!deviceId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
data: { error: "device_id_required" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await getUserDeviceByDeviceId(user.id, deviceId, "ios");
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
data: { error: "device_not_found" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not linked to a NanoMDM UDID → enrolled false.
|
||||||
|
if (!device.mdmId) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { enrolled: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: Awaited<ReturnType<typeof getMdmStatusByUdid>>;
|
||||||
|
try {
|
||||||
|
status = await getMdmStatusByUdid(device.mdmId);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[MDM] NanoMDM DB query failed:", err);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
message: "mdm_db_unreachable",
|
||||||
|
data: { code: "mdm_db_unreachable" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDID stored but no longer present in NanoMDM → clear stale link.
|
||||||
|
if (!status.enrolled) {
|
||||||
|
await clearUserDeviceMdmId(user.id, deviceId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { enrolled: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock-profile state is derived from the locally tracked nefilter state,
|
||||||
|
// not from MDM enrollment alone.
|
||||||
|
const lockState = await getDeviceProtectionState(
|
||||||
|
user.id,
|
||||||
|
deviceId,
|
||||||
|
"nefilter",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
enrolled: true,
|
||||||
|
company: "ReBreak",
|
||||||
|
supervised: true,
|
||||||
|
lockProfileInstalled: lockState?.active ?? false,
|
||||||
|
lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
123
backend/server/db/device-protection.ts
Normal file
123
backend/server/db/device-protection.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { usePrisma } from "../utils/prisma";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ProtectionType = "nefilter" | "vpn" | "dns";
|
||||||
|
|
||||||
|
export const PROTECTION_TYPES: ProtectionType[] = [
|
||||||
|
"nefilter",
|
||||||
|
"vpn",
|
||||||
|
"dns",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface DeviceProtectionStateRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
platform: string;
|
||||||
|
protectionType: string;
|
||||||
|
active: boolean;
|
||||||
|
lastSeenAt: Date | null;
|
||||||
|
changedAt: Date;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Write ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upserts the per-device/per-protection-type state.
|
||||||
|
*
|
||||||
|
* If `active` changed compared to the existing row (or there was no row), an
|
||||||
|
* entry is appended to `DeviceProtectionStateLog` with `occurredAt = now()`.
|
||||||
|
*/
|
||||||
|
export async function upsertDeviceProtectionState(
|
||||||
|
userId: string,
|
||||||
|
deviceId: string,
|
||||||
|
platform: string,
|
||||||
|
protectionType: ProtectionType,
|
||||||
|
active: boolean,
|
||||||
|
lastSeenAt?: Date | null,
|
||||||
|
reason?: string | null,
|
||||||
|
source?: string | null,
|
||||||
|
): Promise<DeviceProtectionStateRecord> {
|
||||||
|
const db = usePrisma();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const existing = await db.deviceProtectionState.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_deviceId_protectionType: { userId, deviceId, protectionType },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const changed = !existing || existing.active !== active;
|
||||||
|
|
||||||
|
const row = await db.deviceProtectionState.upsert({
|
||||||
|
where: {
|
||||||
|
userId_deviceId_protectionType: { userId, deviceId, protectionType },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
platform,
|
||||||
|
protectionType,
|
||||||
|
active,
|
||||||
|
lastSeenAt: lastSeenAt ?? null,
|
||||||
|
changedAt: now,
|
||||||
|
reason: reason ?? null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
platform,
|
||||||
|
active,
|
||||||
|
lastSeenAt: lastSeenAt === undefined ? undefined : lastSeenAt,
|
||||||
|
changedAt: now,
|
||||||
|
reason: reason === undefined ? undefined : reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await db.deviceProtectionStateLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
protectionType,
|
||||||
|
active,
|
||||||
|
occurredAt: now,
|
||||||
|
reason: reason ?? null,
|
||||||
|
source: source ?? "app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Read ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the current state for one protection type on a device. */
|
||||||
|
export async function getDeviceProtectionState(
|
||||||
|
userId: string,
|
||||||
|
deviceId: string,
|
||||||
|
protectionType: ProtectionType,
|
||||||
|
): Promise<DeviceProtectionStateRecord | null> {
|
||||||
|
const db = usePrisma();
|
||||||
|
return db.deviceProtectionState.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_deviceId_protectionType: { userId, deviceId, protectionType },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists all protection states for a user, optionally filtered to one device. */
|
||||||
|
export async function listDeviceProtectionStates(
|
||||||
|
userId: string,
|
||||||
|
deviceId?: string,
|
||||||
|
): Promise<DeviceProtectionStateRecord[]> {
|
||||||
|
const db = usePrisma();
|
||||||
|
return db.deviceProtectionState.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
...(deviceId ? { deviceId } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ deviceId: "asc" }, { protectionType: "asc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
142
backend/server/db/mdm.ts
Normal file
142
backend/server/db/mdm.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { usePrisma } from "../utils/prisma";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
let _mdmPool: pg.Pool | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily initialised pg.Pool against the NanoMDM Postgres.
|
||||||
|
* Connection string comes from runtimeConfig.mdmDatabaseUrl.
|
||||||
|
*/
|
||||||
|
function useMdmPool(): pg.Pool {
|
||||||
|
if (_mdmPool) return _mdmPool;
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const connectionString = (config as any).mdmDatabaseUrl;
|
||||||
|
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("MDM_DATABASE_URL not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
_mdmPool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
// NanoMDM queries are point lookups — keep pool small.
|
||||||
|
max: 5,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
queryTimeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return _mdmPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDeviceMdmRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
platform: string;
|
||||||
|
mdmId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const USER_DEVICE_MDM_SELECT = {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
deviceId: true,
|
||||||
|
platform: true,
|
||||||
|
mdmId: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a user's iOS device by Capacitor deviceId.
|
||||||
|
*/
|
||||||
|
export async function getUserDeviceByDeviceId(
|
||||||
|
userId: string,
|
||||||
|
deviceId: string,
|
||||||
|
platform: string = "ios",
|
||||||
|
): Promise<UserDeviceMdmRecord | null> {
|
||||||
|
const db = usePrisma();
|
||||||
|
return db.userDevice.findFirst({
|
||||||
|
where: { userId, deviceId, platform },
|
||||||
|
select: USER_DEVICE_MDM_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the NanoMDM UDID for a user's device.
|
||||||
|
*/
|
||||||
|
export async function setUserDeviceMdmId(
|
||||||
|
userId: string,
|
||||||
|
deviceId: string,
|
||||||
|
mdmId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = usePrisma();
|
||||||
|
await db.userDevice.updateMany({
|
||||||
|
where: { userId, deviceId, platform: "ios" },
|
||||||
|
data: { mdmId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the stored NanoMDM UDID (e.g. device no longer enrolled).
|
||||||
|
*/
|
||||||
|
export async function clearUserDeviceMdmId(
|
||||||
|
userId: string,
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = usePrisma();
|
||||||
|
await db.userDevice.updateMany({
|
||||||
|
where: { userId, deviceId, platform: "ios" },
|
||||||
|
data: { mdmId: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MdmDeviceStatus {
|
||||||
|
enrolled: boolean;
|
||||||
|
company: string | null;
|
||||||
|
tokenUpdateAt: Date | null;
|
||||||
|
lastAckAt: Date | null;
|
||||||
|
lastAppPushAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query NanoMDM Postgres for a device by UDID.
|
||||||
|
*
|
||||||
|
* Throws if the MDM DB is unreachable — callers should treat this as an
|
||||||
|
* infra/runtime error and not cache a negative result.
|
||||||
|
*/
|
||||||
|
export async function getMdmStatusByUdid(
|
||||||
|
udid: string,
|
||||||
|
): Promise<MdmDeviceStatus> {
|
||||||
|
const pool = useMdmPool();
|
||||||
|
|
||||||
|
// Defensive: only raw parameters reach the query layer below.
|
||||||
|
const result = await pool.query<{
|
||||||
|
enrolled: string;
|
||||||
|
token_update_at: Date | null;
|
||||||
|
last_ack: Date | null;
|
||||||
|
last_app_push_at: Date | null;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
(SELECT count(*)::text FROM devices WHERE id = $1) AS enrolled,
|
||||||
|
(SELECT token_update_at FROM devices WHERE id = $1) AS token_update_at,
|
||||||
|
(SELECT max(updated_at) FROM command_results WHERE id = $1) AS last_ack,
|
||||||
|
(SELECT max(r.updated_at)
|
||||||
|
FROM command_results r
|
||||||
|
JOIN commands c ON c.command_uuid = r.command_uuid
|
||||||
|
WHERE r.id = $1
|
||||||
|
AND c.request_type = 'InstallApplication'
|
||||||
|
AND r.status = 'Acknowledged') AS last_app_push_at`,
|
||||||
|
[udid],
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
const enrolled = row ? row.enrolled !== "0" : false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enrolled,
|
||||||
|
company: enrolled ? "ReBreak" : null,
|
||||||
|
tokenUpdateAt: row?.token_update_at ?? null,
|
||||||
|
lastAckAt: row?.last_ack ?? null,
|
||||||
|
lastAppPushAt: row?.last_app_push_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -55,6 +55,7 @@ exec infisical run \
|
|||||||
[[ -n "${SUPABASE_ANON_KEY:-}" ]] && export NITRO_SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" && export NITRO_PUBLIC_SUPABASE_KEY="$SUPABASE_ANON_KEY"
|
[[ -n "${SUPABASE_ANON_KEY:-}" ]] && export NITRO_SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" && export NITRO_PUBLIC_SUPABASE_KEY="$SUPABASE_ANON_KEY"
|
||||||
[[ -n "${SUPABASE_SERVICE_ROLE_KEY:-}" ]] && export NITRO_SUPABASE_SERVICE_KEY="$SUPABASE_SERVICE_ROLE_KEY"
|
[[ -n "${SUPABASE_SERVICE_ROLE_KEY:-}" ]] && export NITRO_SUPABASE_SERVICE_KEY="$SUPABASE_SERVICE_ROLE_KEY"
|
||||||
[[ -n "${DATABASE_URL:-}" ]] && export NITRO_DATABASE_URL="$DATABASE_URL"
|
[[ -n "${DATABASE_URL:-}" ]] && export NITRO_DATABASE_URL="$DATABASE_URL"
|
||||||
|
[[ -n "${MDM_DATABASE_URL:-}" ]] && export NITRO_MDM_DATABASE_URL="$MDM_DATABASE_URL"
|
||||||
[[ -n "${OPENROUTER_API_KEY:-}" ]] && export NITRO_OPENROUTER_API_KEY="$OPENROUTER_API_KEY"
|
[[ -n "${OPENROUTER_API_KEY:-}" ]] && export NITRO_OPENROUTER_API_KEY="$OPENROUTER_API_KEY"
|
||||||
[[ -n "${OPENAI_API_KEY:-}" ]] && export NITRO_OPENAI_API_KEY="$OPENAI_API_KEY"
|
[[ -n "${OPENAI_API_KEY:-}" ]] && export NITRO_OPENAI_API_KEY="$OPENAI_API_KEY"
|
||||||
[[ -n "${GROQ_API_KEY:-}" ]] && export NITRO_GROQ_API_KEY="$GROQ_API_KEY"
|
[[ -n "${GROQ_API_KEY:-}" ]] && export NITRO_GROQ_API_KEY="$GROQ_API_KEY"
|
||||||
|
|||||||
162
docs/superpowers/specs/2026-06-17-mdm-device-link-design.md
Normal file
162
docs/superpowers/specs/2026-06-17-mdm-device-link-design.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
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:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"enrolled": true,
|
||||||
|
"company": "ReBreak",
|
||||||
|
"supervised": true,
|
||||||
|
"lockProfileInstalled": true,
|
||||||
|
"lastAppPushAt": "2026-06-11T19:09:04.363Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response not enrolled:
|
||||||
|
```json
|
||||||
|
{ "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:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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.
|
||||||
Loading…
x
Reference in New Issue
Block a user