feat(backend): MDM-Managed Flag — migration + endpoint + guards

- 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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-26 00:46:44 +02:00
parent d9f5b631b1
commit 8e562c982d
7 changed files with 154 additions and 0 deletions

View File

@ -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;

View File

@ -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).

View File

@ -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) {

View File

@ -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();

View File

@ -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,
},
};
});

View File

@ -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,
},
};
});

View File

@ -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<string, unknown> = { 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,