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:
parent
5264dba257
commit
587b0c273b
18
backend/prisma/migrations/20260508_admin_users/migration.sql
Normal file
18
backend/prisma/migrations/20260508_admin_users/migration.sql
Normal 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")
|
||||
);
|
||||
@ -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.
|
||||
|
||||
14
backend/server/api/admin/verify-admin.get.ts
Normal file
14
backend/server/api/admin/verify-admin.get.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
11
backend/server/db/admin.ts
Normal file
11
backend/server/db/admin.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
157
backend/tests/admin/verify-admin.test.ts
Normal file
157
backend/tests/admin/verify-admin.test.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user