feat(admin): Phase 3 — requireAdmin middleware + verify-admin endpoint

Backend-side admin-auth. Admin-App (apps/admin/) braucht das damit
useAdminAuth.verifyAdminRole() nach Login server-side prüfen kann ob User
in admin_users-tabelle steht.

New schema:
- model AdminUser → table rebreak.admin_users (user_id UUID PK FK Profile.id,
  created_at, added_by). Migration 20260508_admin_users/migration.sql.
- ⚠️  SCHEMA-MIGRATION — NICHT autopushen. User entscheidet wann pipeline
  triggert.

New backend code:
- backend/server/db/admin.ts: isAdminUser(userId) → boolean
- backend/server/utils/auth.ts: requireAdmin(event) wraps requireUser +
  isAdminUser-check. Throws 403 wenn nicht admin.
- backend/server/api/admin/verify-admin.get.ts: GET endpoint. Returns
  { isAdmin: true, userId, email } bei success, 403 sonst, 401 if not auth'd.

Tests (5 cases in tests/admin/verify-admin.test.ts):
- isAdminUser DB-layer: row exists/null
- requireAdmin: admin → user, non-admin → 403, no token → 401
- Endpoint: admin → success, non-admin → 403

Pending User-Actions nach Push+Deploy:
1. Migration deploy auf staging:
   ssh rebreak-server && cd /srv/rebreak && pnpm exec prisma migrate deploy
2. Seed-Admin eintragen:
   INSERT INTO "rebreak"."admin_users" ("user_id", "created_at")
   VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW())
   ON CONFLICT DO NOTHING;
3. Admin-App composables/useAdminAuth.ts kann dann verifyAdminRole()
   gegen GET /api/admin/verify-admin aufrufen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-08 22:16:47 +02:00
parent 594a43cbf9
commit e12da5385c
6 changed files with 227 additions and 0 deletions

View File

@ -0,0 +1,18 @@
-- Admin-Users Allowlist für Admin-App-Zugang
-- Wird via requireAdmin() middleware geprüft.
--
-- Seed-User (chahine / backoffice):
-- INSERT INTO "rebreak"."admin_users" ("user_id", "created_at")
-- VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW())
-- ON CONFLICT DO NOTHING;
--
-- Deploy: pnpm prisma migrate deploy auf Hetzner-Server
-- NICHT lokal ausführen.
CREATE TABLE IF NOT EXISTS "rebreak"."admin_users" (
"user_id" UUID NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"added_by" TEXT,
CONSTRAINT "admin_users_pkey" PRIMARY KEY ("user_id")
);

View File

@ -616,6 +616,17 @@ model LyraMemory {
@@schema("rebreak")
}
/// Admin-Allowlist — nur Einträge hier erhalten Zugang zur Admin-App.
/// Seed: INSERT INTO "rebreak"."admin_users" ("user_id", "created_at") VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW());
model AdminUser {
userId String @id @map("user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
addedBy String? @map("added_by")
@@map("admin_users")
@@schema("rebreak")
}
// Device-Binding pro User: Free=1, Pro=1, Legend=3 (siehe plan-features.ts maxDevices).
// Frontend liefert via Capacitor Device.getId() eine persistente UUID — diese wird
// bei jedem authentifizierten Request via x-device-id Header geprüft.

View File

@ -0,0 +1,14 @@
import { requireAdmin } from '../../utils/auth';
export default defineEventHandler(async (event) => {
const user = await requireAdmin(event);
return {
success: true,
data: {
isAdmin: true,
userId: user.id,
email: user.email ?? null,
},
};
});

View File

@ -0,0 +1,11 @@
import { usePrisma } from "../utils/prisma";
/**
* Prüft ob eine User-ID in der admin_users Allowlist steht.
* Gibt true zurück wenn admin, false sonst.
*/
export async function isAdminUser(userId: string): Promise<boolean> {
const db = usePrisma();
const row = await db.adminUser.findUnique({ where: { userId } });
return row !== null;
}

View File

@ -1,5 +1,6 @@
import { createClient } from '@supabase/supabase-js';
import type { H3Event } from 'h3';
import { isAdminUser } from '../db/admin';
import { findUserDevice, registerDevice, touchDevice } from '../db/devices';
import { getProfile } from '../db/profile';
import { getPlanLimits } from './plan-features';
@ -95,4 +96,19 @@ export async function requireUser(
}
throw err;
}
}
/**
* requireAdmin wirft 401 wenn nicht eingeloggt, 403 wenn nicht in admin_users.
* Wraps requireUser mit skipDeviceCheck=true (Admin-App hat kein Device-Binding).
*/
export async function requireAdmin(event: H3Event) {
const user = await requireUser(event, { skipDeviceCheck: true });
const admin = await isAdminUser(user.id);
if (!admin) {
throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' });
}
return user;
}

View File

@ -0,0 +1,157 @@
/**
* Tests for GET /api/admin/verify-admin
*
* Covers:
* - happy path: User ist admin isAdmin: true
* - 403: User ist authentifiziert aber NICHT in admin_users
* - 401: User ist nicht eingeloggt
*
* Strategy:
* - isAdminUser DB-layer: tested directly with mocked Prisma
* - requireAdmin util: tested directly with mocked requireUser + isAdminUser
* - endpoint handler: tested with mocked requireAdmin
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
// ─── Prisma mock ─────────────────────────────────────────────────────────────
const prismaMock = vi.hoisted(() => ({
adminUser: {
findUnique: vi.fn(),
},
}));
vi.mock("../../server/utils/prisma", () => ({
usePrisma: () => prismaMock,
}));
// ─── requireUser mock (used by requireAdmin tests) ───────────────────────────
const requireUserMock = vi.hoisted(() => vi.fn());
vi.mock("../../server/utils/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../server/utils/auth")>();
return {
...actual,
requireUser: requireUserMock,
};
});
import { isAdminUser } from "../../server/db/admin";
import { requireAdmin } from "../../server/utils/auth";
beforeEach(() => {
vi.clearAllMocks();
});
// ─── isAdminUser DB-layer ────────────────────────────────────────────────────
describe("isAdminUser — user IS in admin_users", () => {
it("returns true when row exists", async () => {
prismaMock.adminUser.findUnique.mockResolvedValueOnce({
userId: "128df360-2008-4d6f-8aa1-bdb41ec1362f",
createdAt: new Date(),
addedBy: null,
});
const result = await isAdminUser("128df360-2008-4d6f-8aa1-bdb41ec1362f");
expect(result).toBe(true);
expect(prismaMock.adminUser.findUnique).toHaveBeenCalledWith({
where: { userId: "128df360-2008-4d6f-8aa1-bdb41ec1362f" },
});
});
});
describe("isAdminUser — user NOT in admin_users", () => {
it("returns false when row is null", async () => {
prismaMock.adminUser.findUnique.mockResolvedValueOnce(null);
const result = await isAdminUser("some-random-user-id");
expect(result).toBe(false);
});
});
// ─── requireAdmin util ───────────────────────────────────────────────────────
describe("requireAdmin — happy path (admin user)", () => {
it("returns user object when authenticated and in admin_users", async () => {
const fakeUser = {
id: "128df360-2008-4d6f-8aa1-bdb41ec1362f",
email: "chahinebrini@gmail.com",
};
requireUserMock.mockResolvedValueOnce(fakeUser);
prismaMock.adminUser.findUnique.mockResolvedValueOnce({
userId: fakeUser.id,
createdAt: new Date(),
addedBy: null,
});
const result = await requireAdmin({} as Parameters<typeof requireAdmin>[0]);
expect(result).toMatchObject({ id: fakeUser.id, email: fakeUser.email });
});
});
describe("requireAdmin — 403 (non-admin)", () => {
it("throws 403 when user is authenticated but not in admin_users", async () => {
requireUserMock.mockResolvedValueOnce({
id: "regular-user-id",
email: "user@example.com",
});
prismaMock.adminUser.findUnique.mockResolvedValueOnce(null);
await expect(
requireAdmin({} as Parameters<typeof requireAdmin>[0]),
).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("requireAdmin — 401 (not logged in)", () => {
it("propagates 401 from requireUser when token missing", async () => {
const authError = Object.assign(new Error("Nicht eingeloggt"), {
statusCode: 401,
});
requireUserMock.mockRejectedValueOnce(authError);
await expect(
requireAdmin({} as Parameters<typeof requireAdmin>[0]),
).rejects.toMatchObject({ statusCode: 401 });
});
});
// ─── verify-admin endpoint handler ───────────────────────────────────────────
describe("verify-admin endpoint — returns isAdmin: true for admin", () => {
it("returns { success: true, data: { isAdmin: true, userId, email } }", async () => {
const fakeUser = {
id: "128df360-2008-4d6f-8aa1-bdb41ec1362f",
email: "chahinebrini@gmail.com",
};
// requireAdmin is backed by requireUser mock (via spread in vi.mock above)
requireUserMock.mockResolvedValueOnce(fakeUser);
prismaMock.adminUser.findUnique.mockResolvedValueOnce({
userId: fakeUser.id,
createdAt: new Date(),
addedBy: null,
});
const mod = await import("../../server/api/admin/verify-admin.get");
const handler =
typeof mod.default === "function"
? mod.default
: (mod.default as { handler?: unknown }).handler;
const result = await (handler as (e: unknown) => Promise<unknown>)({});
expect(result).toEqual({
success: true,
data: {
isAdmin: true,
userId: fakeUser.id,
email: fakeUser.email,
},
});
});
});