From 8e562c982da860db054bf8bdfc855ac0bb43be8c Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Tue, 26 May 2026 00:46:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20MDM-Managed=20Flag=20=E2=80=94?= =?UTF-8?q?=20migration=20+=20endpoint=20+=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prisma migration: users.mdm_managed (Boolean DEFAULT false) + users.mdm_detected_at (DateTime?) - setMdmManaged() helper in server/db/profile.ts - POST /api/users/me/mdm-status — App reports MDM status to backend - cooldown/status + cooldown/request — early-return 400 when mdm_managed - protection/state — response extended with mdmManaged: boolean Co-Authored-By: Claude Sonnet 4.6 --- .../20260526_add_mdm_managed/migration.sql | 20 +++++++++ backend/prisma/schema.prisma | 12 +++++ backend/server/api/cooldown/request.post.ts | 14 ++++++ backend/server/api/cooldown/status.get.ts | 23 ++++++++++ backend/server/api/protection/state.get.ts | 2 + .../server/api/users/me/mdm-status.post.ts | 45 +++++++++++++++++++ backend/server/db/profile.ts | 38 ++++++++++++++++ 7 files changed, 154 insertions(+) create mode 100644 backend/prisma/migrations/20260526_add_mdm_managed/migration.sql create mode 100644 backend/server/api/users/me/mdm-status.post.ts diff --git a/backend/prisma/migrations/20260526_add_mdm_managed/migration.sql b/backend/prisma/migrations/20260526_add_mdm_managed/migration.sql new file mode 100644 index 0000000..090b2a6 --- /dev/null +++ b/backend/prisma/migrations/20260526_add_mdm_managed/migration.sql @@ -0,0 +1,20 @@ +-- Profile.mdm_managed + Profile.mdm_detected_at +-- +-- mdm_managed: true wenn das Device des Users via MDM (NEFilter-Profil sideloaded +-- + non-removable) verwaltet wird. Wird vom App-Code nach nativem NEFilterManager- +-- Check gesetzt (POST /api/users/me/mdm-status). +-- +-- mdm_detected_at: Zeitstempel des ersten mdm_managed=true-Writes. Wird nur beim +-- Übergang false→true gesetzt und danach nie überschrieben — Audit-Trail. +-- +-- Effekte: +-- - /api/cooldown/status: canDisableProtection=false, reason="mdm_managed" +-- - /api/cooldown/request: HTTP 400 error="mdm_managed_cannot_self_deactivate" +-- - /api/protection/state: mdmManaged=true +-- +-- Backfill: alle existierenden Profile bekommen mdm_managed=false (DEFAULT) und +-- mdm_detected_at=NULL — keine Verhaltensänderung für laufende Sessions. + +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "mdm_managed" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "mdm_detected_at" TIMESTAMPTZ NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e6cdfc0..1bb3904 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -102,6 +102,18 @@ model Profile { // Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace. globalBlocklistGraceUntil DateTime? @map("global_blocklist_grace_until") + // ─── MDM-Managed Flag (Build 19, Migration 20260526) ──────────────────── + // mdmManaged: true wenn User's Device via MDM verwaltet wird (NEFilter- + // Profil sideloaded + non-removable). Wird vom App-Code nach nativem + // NEFilterManager.isEnabled-Check via POST /api/users/me/mdm-status gesetzt. + // Effekt: Cooldown-Selfdeactivation blockiert, Schutz nur via Trustee/ + // Apple Configurator deaktivierbar. + // + // mdmDetectedAt: Zeitstempel des ersten mdmManaged=true-Writes (Audit-Trail). + // Wird nur beim Übergang false→true gesetzt, nie überschrieben. + mdmManaged Boolean @default(false) @map("mdm_managed") + mdmDetectedAt DateTime? @map("mdm_detected_at") + // ─── Admin-Management (Phase E, Migration 20260509) ───────────────────── // banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase // bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO). diff --git a/backend/server/api/cooldown/request.post.ts b/backend/server/api/cooldown/request.post.ts index c66335a..f898cf5 100644 --- a/backend/server/api/cooldown/request.post.ts +++ b/backend/server/api/cooldown/request.post.ts @@ -1,12 +1,26 @@ import { requireUser } from "../../utils/auth"; import { getActiveCooldown, createCooldown } from "../../db/cooldown"; import { signCooldownToken, generateJti } from "../../utils/cooldownToken"; +import { usePrisma } from "../../utils/prisma"; /** POST /api/cooldown/request — Start a 24h cooldown before protection can be disabled. */ export default defineEventHandler(async (event) => { const user = await requireUser(event); const body = await readBody(event).catch(() => ({})); + // ─── MDM-Guard: MDM-User können sich nie selbst deaktivieren. + const db = usePrisma(); + const profile = await db.profile.findUnique({ + where: { id: user.id }, + select: { mdmManaged: true }, + }); + if (profile?.mdmManaged) { + throw createError({ + statusCode: 400, + data: { error: "mdm_managed_cannot_self_deactivate" }, + }); + } + // Reject if a cooldown is already running (not resolved, not cancelled). const existing = await getActiveCooldown(user.id); if (existing) { diff --git a/backend/server/api/cooldown/status.get.ts b/backend/server/api/cooldown/status.get.ts index eedbe06..8c9b026 100644 --- a/backend/server/api/cooldown/status.get.ts +++ b/backend/server/api/cooldown/status.get.ts @@ -7,6 +7,29 @@ import { usePrisma } from "../../utils/prisma"; export default defineEventHandler(async (event) => { const user = await requireUser(event); + // ─── MDM-Guard: wenn User via MDM verwaltet wird, kann er Schutz nie selbst + // deaktivieren — Cooldown-Flow ist komplett irrelevant. Früh zurückkehren. + const db = usePrisma(); + const profile = await db.profile.findUnique({ + where: { id: user.id }, + select: { mdmManaged: true }, + }); + + if (profile?.mdmManaged) { + return { + success: true, + data: { + active: false, + remainingSeconds: 0, + cooldownEndsAt: null, + canDisableProtection: false, + reason: "mdm_managed", + message: "MDM-Mode: Schutz nur via Apple Configurator oder Trustee deaktivierbar", + token: null, + }, + }; + } + const cooldown = await getActiveCooldown(user.id); const now = new Date(); diff --git a/backend/server/api/protection/state.get.ts b/backend/server/api/protection/state.get.ts index 2d413f8..bedee1f 100644 --- a/backend/server/api/protection/state.get.ts +++ b/backend/server/api/protection/state.get.ts @@ -51,6 +51,7 @@ export default defineEventHandler(async (event) => { } const plan = (profile?.plan ?? "free") as "free" | "pro" | "legend"; + const mdmManaged = profile?.mdmManaged ?? false; // protectionShouldBeActive = "der Schutz sollte gerade auf dem Device laufen" // - false wenn Cooldown aktiv ist (User darf grad nicht zurück-reaktivieren) @@ -71,6 +72,7 @@ export default defineEventHandler(async (event) => { cooldownEndsAt, }, plan, + mdmManaged, }, }; }); diff --git a/backend/server/api/users/me/mdm-status.post.ts b/backend/server/api/users/me/mdm-status.post.ts new file mode 100644 index 0000000..e5456b0 --- /dev/null +++ b/backend/server/api/users/me/mdm-status.post.ts @@ -0,0 +1,45 @@ +import { requireUser } from "../../../utils/auth"; +import { setMdmManaged } from "../../../db/profile"; +import { z } from "zod"; + +const Body = z.object({ + mdmManaged: z.boolean(), +}); + +/** + * POST /api/users/me/mdm-status + * + * App ruft diesen Endpoint auf nachdem nativ via NEFilterManager.shared() + * .loadFromPreferences() der MDM-NEFilter-Active-State detektiert wurde. + * + * Body: { mdmManaged: boolean } + * true → MDM-Profil aktiv auf Device — setzt mdm_managed=true in DB + * false → Profil nicht aktiv (z.B. nach Trustee-Deactivation, Reset) → + * setzt mdm_managed=false (mdm_detected_at bleibt als Audit-Trail) + * + * Response: { ok: true, mdmManaged: boolean, mdmDetectedAt: string | null } + */ +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const raw = await readBody(event).catch(() => ({})); + const parsed = Body.safeParse(raw); + if (!parsed.success) { + throw createError({ + statusCode: 400, + data: { error: "INVALID_BODY", detail: parsed.error.flatten() }, + }); + } + + const { mdmManaged } = parsed.data; + const result = await setMdmManaged(user.id, mdmManaged); + + return { + success: true, + data: { + ok: true, + mdmManaged: result.mdmManaged, + mdmDetectedAt: result.mdmDetectedAt?.toISOString() ?? null, + }, + }; +}); diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts index 82bedcf..a981fb7 100644 --- a/backend/server/db/profile.ts +++ b/backend/server/db/profile.ts @@ -352,6 +352,44 @@ export async function getLastSeenBatch( return rows.map((r) => ({ id: r.id, lastSeenAt: r.last_seen_at })); } +// ─── MDM-Managed Flag ──────────────────────────────────────────────────────── + +/** + * Set or clear the mdmManaged flag on a profile. + * On the first true-write, mdmDetectedAt is stamped (never overwritten). + * On false-write, mdmDetectedAt is left intact as audit trail. + */ +export async function setMdmManaged( + userId: string, + mdmManaged: boolean, +): Promise<{ mdmManaged: boolean; mdmDetectedAt: Date | null }> { + const db = usePrisma(); + return db.$transaction(async (tx) => { + const current = await tx.profile.findUnique({ + where: { id: userId }, + select: { mdmManaged: true, mdmDetectedAt: true }, + }); + if (!current) { + throw createError({ statusCode: 404, data: { error: "PROFILE_NOT_FOUND" } }); + } + + const data: Record = { mdmManaged }; + + // Stamp mdmDetectedAt only on first true-write, never overwrite. + if (mdmManaged && !current.mdmDetectedAt) { + data.mdmDetectedAt = new Date(); + } + + const updated = await tx.profile.update({ + where: { id: userId }, + data, + select: { mdmManaged: true, mdmDetectedAt: true }, + }); + + return { mdmManaged: updated.mdmManaged, mdmDetectedAt: updated.mdmDetectedAt }; + }); +} + /** Update presence_visible opt-out toggle for a user. */ export async function setPresenceVisible( userId: string,