task(H3): library admin role guard - requireLibraryAdmin on all 8 global library write routes, read/export stays open for authenticated users

This commit is contained in:
Agent Zero
2026-08-29 03:01:55 +02:00
parent e3fde5012e
commit d4030400b4
3 changed files with 186 additions and 9 deletions
+17
View File
@@ -44,3 +44,20 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply, authS
} }
return user; return user;
} }
/**
* Require library-admin role (Task H3: global library write guard).
* Library-admin is bound to the existing admin role: only admins may
* write (create/update/delete/import) the GLOBAL block library, while
* every authenticated user may read/export it. Own drawing-scoped
* blocks are unaffected (per-user ownership there).
*/
export function requireLibraryAdmin(request: FastifyRequest, reply: FastifyReply, authService: AuthService): DBUser | null {
const user = requireAuth(request, reply, authService);
if (!user) return null;
if (user.role !== 'admin') {
reply.code(403).send({ error: 'Library admin access required' });
return null;
}
return user;
}
+9 -9
View File
@@ -3,7 +3,7 @@
*/ */
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { DatabaseInterface } from '../database/DatabaseInterface.js'; import type { DatabaseInterface } from '../database/DatabaseInterface.js';
import { requireAuth } from '../auth/authMiddleware.js'; import { requireAuth, requireLibraryAdmin } from '../auth/authMiddleware.js';
import type { AuthService } from '../auth/AuthService.js'; import type { AuthService } from '../auth/AuthService.js';
import { validateName, validateIdParam } from '../utils/validation.js'; import { validateName, validateIdParam } from '../utils/validation.js';
import JSZip from 'jszip'; import JSZip from 'jszip';
@@ -36,7 +36,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Create a new folder // Create a new folder
fastify.post('/api/global-folders', async (request, reply) => { fastify.post('/api/global-folders', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const body = request.body as { name?: string; parent_id?: string | null }; const body = request.body as { name?: string; parent_id?: string | null };
const nameErr = validateName(body.name, 'name'); const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr }); if (nameErr) return reply.code(400).send({ error: nameErr });
@@ -49,7 +49,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Rename / move a folder // Rename / move a folder
fastify.patch('/api/global-folders/:id', async (request, reply) => { fastify.patch('/api/global-folders/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id'); const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr }); if (idErr) return reply.code(400).send({ error: idErr });
@@ -71,7 +71,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Delete a folder (cascades to children, blocks get folder_id = NULL) // Delete a folder (cascades to children, blocks get folder_id = NULL)
fastify.delete('/api/global-folders/:id', async (request, reply) => { fastify.delete('/api/global-folders/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id'); const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr }); if (idErr) return reply.code(400).send({ error: idErr });
@@ -107,7 +107,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Create a new global block // Create a new global block
fastify.post('/api/global-blocks', async (request, reply) => { fastify.post('/api/global-blocks', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string }; const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string };
const nameErr = validateName(body.name, 'name'); const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr }); if (nameErr) return reply.code(400).send({ error: nameErr });
@@ -131,7 +131,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Update a global block (rename, move, update data) // Update a global block (rename, move, update data)
fastify.patch('/api/global-blocks/:id', async (request, reply) => { fastify.patch('/api/global-blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id'); const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr }); if (idErr) return reply.code(400).send({ error: idErr });
@@ -160,7 +160,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Delete a global block // Delete a global block
fastify.delete('/api/global-blocks/:id', async (request, reply) => { fastify.delete('/api/global-blocks/:id', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const idErr = validateIdParam(id, 'id'); const idErr = validateIdParam(id, 'id');
if (idErr) return reply.code(400).send({ error: idErr }); if (idErr) return reply.code(400).send({ error: idErr });
@@ -192,7 +192,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten) // Import: .wcadlib-Paket einspielen (IDs neu, Ordner-Hierarchie über Mapping erhalten)
fastify.post('/api/global-blocks/import', async (request, reply) => { fastify.post('/api/global-blocks/import', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const pkg = request.body as { const pkg = request.body as {
format?: string; format?: string;
folders?: Array<{ id: string; name: string; parent_id: string | null }>; folders?: Array<{ id: string; name: string; parent_id: string | null }>;
@@ -280,7 +280,7 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database
// ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails) // ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails)
fastify.post('/api/global-blocks/import-zip', async (request, reply) => { fastify.post('/api/global-blocks/import-zip', async (request, reply) => {
if (!requireAuth(request, reply, authService)) return; if (!requireLibraryAdmin(request, reply, authService)) return;
const body = request.body; const body = request.body;
if (!Buffer.isBuffer(body) || body.length === 0) { if (!Buffer.isBuffer(body) || body.length === 0) {
return reply.code(400).send({ error: 'ZIP body fehlt oder leer' }); return reply.code(400).send({ error: 'ZIP body fehlt oder leer' });
+160
View File
@@ -0,0 +1,160 @@
/**
* Task H3 Rollen: library-admin (global write) vs. user (read-only).
*
* requireLibraryAdmin schützt alle schreibenden Global-Library-Routen:
* POST/PATCH/DELETE auf folders+blocks, import, import-zip. Lesen
* (GET, export) bleibt für alle authentifizierten Nutzer offen.
* library-admin ist an die bestehende admin-Rolle angebunden.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
import { createServer } from '../src/server.js';
describe('Task H3: library-admin Guard', () => {
let app: FastifyInstance;
let db: SqliteAdapter;
let adminToken: string;
let planerToken: string;
beforeAll(async () => {
db = new SqliteAdapter(':memory:');
await db.init();
app = await createServer({ db, port: 0 });
await app.ready();
const adminReg = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'h3-admin@example.com', password: 'Password123!', name: 'H3 Admin', role: 'admin' },
});
if (adminReg.statusCode !== 201) console.error('ADMIN REG FAILED:', adminReg.statusCode, adminReg.body.slice(0, 200));
adminToken = JSON.parse(adminReg.body).session?.token ?? '';
const planerReg = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { email: 'h3-planer@example.com', password: 'Password123!', name: 'H3 Planer', role: 'planer' },
});
if (planerReg.statusCode !== 201) console.error('PLANER REG FAILED:', planerReg.statusCode, planerReg.body.slice(0, 200));
planerToken = JSON.parse(planerReg.body).session?.token ?? '';
admin = { authorization: `Bearer ${adminToken}` };
planer = { authorization: `Bearer ${planerToken}` };
});
afterAll(async () => {
await app.close();
db.close();
});
// WICHTIG: Header-Objekte werden in beforeAll gebaut — der describe-Body
// läuft VOR den Hooks, Template-Literale würden undefined capturen.
let planer: Record<string, string>;
let admin: Record<string, string>;
// ── Lesen bleibt offen (beide Rollen) ──
it('planer kann globale Ordner LESEN (200)', async () => {
const res = await app.inject({ method: 'GET', url: '/api/global-folders', headers: planer });
expect(res.statusCode).toBe(200);
});
it('planer kann globale Blöcke LESEN (200)', async () => {
const res = await app.inject({ method: 'GET', url: '/api/global-blocks', headers: planer });
expect(res.statusCode).toBe(200);
});
it('planer kann Library EXPORTIEREN (GET export, 200)', async () => {
const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export', headers: planer });
expect(res.statusCode).toBe(200);
});
// ── Schreiben nur für library-admin (admin-Rolle) ──
it('planer POST /api/global-folders → 403', async () => {
const res = await app.inject({
method: 'POST', url: '/api/global-folders', headers: planer,
payload: { name: 'Verboten', parent_id: null },
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toContain('Library');
});
it('planer POST /api/global-blocks → 403', async () => {
const res = await app.inject({
method: 'POST', url: '/api/global-blocks', headers: planer,
payload: { name: 'Verboten', block_data: '[]' },
});
expect(res.statusCode).toBe(403);
});
it('planer PATCH /api/global-folders/:id → 403', async () => {
const res = await app.inject({
method: 'PATCH', url: '/api/global-folders/irgendeine-id', headers: planer,
payload: { name: 'Hack' },
});
expect(res.statusCode).toBe(403);
});
it('planer PATCH /api/global-blocks/:id → 403', async () => {
const res = await app.inject({
method: 'PATCH', url: '/api/global-blocks/irgendeine-id', headers: planer,
payload: { name: 'Hack' },
});
expect(res.statusCode).toBe(403);
});
it('planer DELETE /api/global-folders/:id → 403', async () => {
const res = await app.inject({ method: 'DELETE', url: '/api/global-folders/irgendeine-id', headers: planer });
expect(res.statusCode).toBe(403);
});
it('planer DELETE /api/global-blocks/:id → 403', async () => {
const res = await app.inject({ method: 'DELETE', url: '/api/global-blocks/irgendeine-id', headers: planer });
expect(res.statusCode).toBe(403);
});
it('planer POST /api/global-blocks/import → 403', async () => {
const res = await app.inject({
method: 'POST', url: '/api/global-blocks/import', headers: planer,
payload: { format: 'wcadlib', blocks: [] },
});
expect(res.statusCode).toBe(403);
});
it('planer POST /api/global-blocks/import-zip → 403', async () => {
const res = await app.inject({
method: 'POST', url: '/api/global-blocks/import-zip', headers: { ...planer, 'content-type': 'application/zip' },
payload: Buffer.from('PK'),
});
expect(res.statusCode).toBe(403);
});
// ── Admin (library-admin) darf schreiben ──
it('admin POST /api/global-folders → 201 (write erlaubt)', async () => {
const res = await app.inject({
method: 'POST', url: '/api/global-folders', headers: admin,
payload: { name: 'Admin Set', parent_id: null },
});
expect(res.statusCode).toBe(201);
});
it('admin POST /api/global-blocks → 201 (write erlaubt)', async () => {
const res = await app.inject({
method: 'POST', url: '/api/global-blocks', headers: admin,
payload: { name: 'Admin Block', block_data: '[]', folder_id: null },
});
expect(res.statusCode).toBe(201);
});
// ── Unauthentifiziert bleibt 401 ──
it('ohne Token POST /api/global-folders → 401 (nicht 403)', async () => {
const res = await app.inject({
method: 'POST', url: '/api/global-folders',
payload: { name: 'Anon', parent_id: null },
});
expect(res.statusCode).toBe(401);
});
});