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.
|
// Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace.
|
||||||
globalBlocklistGraceUntil DateTime? @map("global_blocklist_grace_until")
|
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) ─────────────────────
|
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
|
||||||
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
||||||
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
||||||
|
|||||||
@ -1,12 +1,26 @@
|
|||||||
import { requireUser } from "../../utils/auth";
|
import { requireUser } from "../../utils/auth";
|
||||||
import { getActiveCooldown, createCooldown } from "../../db/cooldown";
|
import { getActiveCooldown, createCooldown } from "../../db/cooldown";
|
||||||
import { signCooldownToken, generateJti } from "../../utils/cooldownToken";
|
import { signCooldownToken, generateJti } from "../../utils/cooldownToken";
|
||||||
|
import { usePrisma } from "../../utils/prisma";
|
||||||
|
|
||||||
/** POST /api/cooldown/request — Start a 24h cooldown before protection can be disabled. */
|
/** POST /api/cooldown/request — Start a 24h cooldown before protection can be disabled. */
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(event);
|
const user = await requireUser(event);
|
||||||
const body = await readBody(event).catch(() => ({}));
|
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).
|
// Reject if a cooldown is already running (not resolved, not cancelled).
|
||||||
const existing = await getActiveCooldown(user.id);
|
const existing = await getActiveCooldown(user.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@ -7,6 +7,29 @@ import { usePrisma } from "../../utils/prisma";
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const user = await requireUser(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 cooldown = await getActiveCooldown(user.id);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const plan = (profile?.plan ?? "free") as "free" | "pro" | "legend";
|
const plan = (profile?.plan ?? "free") as "free" | "pro" | "legend";
|
||||||
|
const mdmManaged = profile?.mdmManaged ?? false;
|
||||||
|
|
||||||
// protectionShouldBeActive = "der Schutz sollte gerade auf dem Device laufen"
|
// protectionShouldBeActive = "der Schutz sollte gerade auf dem Device laufen"
|
||||||
// - false wenn Cooldown aktiv ist (User darf grad nicht zurück-reaktivieren)
|
// - false wenn Cooldown aktiv ist (User darf grad nicht zurück-reaktivieren)
|
||||||
@ -71,6 +72,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
cooldownEndsAt,
|
cooldownEndsAt,
|
||||||
},
|
},
|
||||||
plan,
|
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 }));
|
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. */
|
/** Update presence_visible opt-out toggle for a user. */
|
||||||
export async function setPresenceVisible(
|
export async function setPresenceVisible(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user