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:
parent
d9f5b631b1
commit
8e562c982d
@ -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;
|
||||
@ -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).
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
45
backend/server/api/users/me/mdm-status.post.ts
Normal file
45
backend/server/api/users/me/mdm-status.post.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user