feat(260929-dzu): persoenliche eigene Module je Benutzer (API, Migration, Zeilenschutz)

- ownerUserId (NULL = gemeinsam, sonst persoenlich) mit Zeilenschutz nach Muster SearchProvider
- GET nur gemeinsame + eigene, fremde persoenliche Eintraege 404
- POST fuer jeden Benutzer, shared nur fuer Administratoren (403)
- PATCH/DELETE: persoenlich nur Besitzer, gemeinsam nur Administrator
- Zugriffsklassifikation nachgemessen: 61/223/6

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 10:09:08 +02:00
parent 76a923450f
commit c703d87a1c
8 changed files with 533 additions and 150 deletions
@@ -1,9 +1,10 @@
import { NotFoundException } from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId)
// uebergeben wird.
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode
// gebunden wird (mit oder ohne Benutzer).
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
@@ -24,6 +25,11 @@ function makeFakePrisma() {
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
let list = [...rows.values()];
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
if (where?.OR) {
list = list.filter((r) =>
where.OR.some((c: { ownerUserId: string | null }) => r.ownerUserId === c.ownerUserId),
);
}
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
return list;
}),
@@ -41,86 +47,233 @@ function makeFakePrisma() {
}
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
const admin = { id: 'admin1', role: Role.ADMIN };
const userA = { id: 'ua', role: Role.USER };
const userB = { id: 'ub', role: Role.USER };
describe('CustomModulesService', () => {
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
await service.create('t1', { ...dto, tenantId: 'evil' } as any);
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
function setup() {
const prisma = makeFakePrisma();
return { prisma, service: new CustomModulesService(prisma as any) };
}
describe('CustomModulesService — anlegen', () => {
it('speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
const { prisma, service } = setup();
await service.create('t1', userA, { ...dto, tenantId: 'evil' } as any);
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
});
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
await service.create('t1', { ...dto, name: 'Zebra' });
await service.create('t1', { ...dto, name: 'Anker' });
await service.create('t2', { ...dto, name: 'Fremd' });
const result = await service.list('t1');
expect(result.map((r: any) => r.name)).toEqual(['Anker', 'Zebra']);
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1' });
it('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => {
const { prisma, service } = setup();
const res: any = await service.create('t1', userA, dto);
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
expect(res.personal).toBe(true);
expect(res).not.toHaveProperty('ownerUserId');
});
it('getOne liefert die Zeile ohne tenantId', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
const row: any = await service.getOne('t1', created.id);
it('auch ein Administrator legt ohne shared persoenlich an', async () => {
const { prisma, service } = setup();
const res: any = await service.create('t1', admin, dto);
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1');
expect(res.personal).toBe(true);
});
it('shared: true durch einen Administrator legt einen gemeinsamen Eintrag an', async () => {
const { prisma, service } = setup();
const res: any = await service.create('t1', admin, { ...dto, shared: true });
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBeNull();
expect(res.personal).toBe(false);
});
it('shared: true durch einen normalen Benutzer -> ForbiddenException, nichts gespeichert', async () => {
const { prisma, service } = setup();
await expect(service.create('t1', userA, { ...dto, shared: true })).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(prisma.customModule.create).not.toHaveBeenCalled();
});
it('shared: false durch einen normalen Benutzer bleibt persoenlich', async () => {
const { prisma, service } = setup();
await service.create('t1', userA, { ...dto, shared: false });
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
});
it('das Feld shared landet nie in den gespeicherten Daten', async () => {
const { prisma, service } = setup();
await service.create('t1', admin, { ...dto, shared: true });
expect(prisma.customModule.create.mock.calls[0][0].data).not.toHaveProperty('shared');
});
});
describe('CustomModulesService — lesen', () => {
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
const { service } = setup();
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
await service.create('t1', userA, { ...dto, name: 'A-privat' });
await service.create('t1', userB, { ...dto, name: 'B-privat' });
const resA: any[] = await service.list('t1', userA);
expect(resA.map((r) => [r.name, r.personal])).toEqual([
['A-privat', true],
['Gemeinsam', false],
]);
const resB: any[] = await service.list('t1', userB);
expect(resB.map((r) => r.name)).toEqual(['B-privat', 'Gemeinsam']);
});
it('list sieht auch als Administrator keine persoenlichen Eintraege anderer', async () => {
const { service } = setup();
await service.create('t1', userA, { ...dto, name: 'A-privat' });
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
const res: any[] = await service.list('t1', admin);
expect(res.map((r) => r.name)).toEqual(['Gemeinsam']);
});
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
const { prisma, service } = setup();
await service.create('t1', admin, { ...dto, name: 'Zebra', shared: true });
await service.create('t1', admin, { ...dto, name: 'Anker', shared: true });
await service.create('t2', admin, { ...dto, name: 'Fremd', shared: true });
const result: any[] = await service.list('t1', userA);
expect(result.map((r) => r.name)).toEqual(['Anker', 'Zebra']);
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({
tenantId: 't1',
OR: [{ ownerUserId: null }, { ownerUserId: 'ua' }],
});
});
it('getOne liefert einen gemeinsamen Eintrag jedem, ohne tenantId und ownerUserId', async () => {
const { service } = setup();
const created: any = await service.create('t1', admin, { ...dto, shared: true });
const row: any = await service.getOne('t1', userA, created.id);
expect(row.name).toBe('Wiki');
expect(row.personal).toBe(false);
expect(row).not.toHaveProperty('tenantId');
expect(row).not.toHaveProperty('ownerUserId');
});
it('getOne liefert einen eigenen persoenlichen Eintrag', async () => {
const { service } = setup();
const created: any = await service.create('t1', userA, dto);
const row: any = await service.getOne('t1', userA, created.id);
expect(row.personal).toBe(true);
});
it('getOne auf den persoenlichen Eintrag eines anderen -> NotFoundException (auch fuer Administratoren)', async () => {
const { service } = setup();
const created: any = await service.create('t1', userA, dto);
await expect(service.getOne('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
});
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
const service = new CustomModulesService(makeFakePrisma() as any);
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf(
const { service } = setup();
await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.remove('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
});
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t2', dto);
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
const { prisma, service } = setup();
const created: any = await service.create('t2', admin, { ...dto, shared: true });
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled();
});
});
describe('CustomModulesService — aendern und loeschen', () => {
it('der Besitzer aendert und loescht seinen persoenlichen Eintrag', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
const updated: any = await service.update('t1', userA, created.id, { name: 'Neu' });
expect(updated.name).toBe('Neu');
expect(updated.personal).toBe(true);
await expect(service.remove('t1', userA, created.id)).resolves.toEqual({ deleted: true });
expect(prisma.rows.size).toBe(0);
});
it('ein anderer Benutzer kann den persoenlichen Eintrag weder aendern noch loeschen (404)', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
await expect(service.update('t1', userB, created.id, { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled();
});
it('update aendert nur gesetzte Felder', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
await service.update('t1', created.id, { name: 'Neu' });
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.size).toBe(1);
});
it('remove loescht und liefert { deleted: true }', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
const created: any = await service.create('t1', dto);
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true });
it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', admin, { ...dto, shared: true });
await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect(service.remove('t1', userA, created.id)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(prisma.customModule.update).not.toHaveBeenCalled();
expect(prisma.customModule.delete).not.toHaveBeenCalled();
});
it('ein Administrator aendert und loescht einen gemeinsamen Eintrag', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', admin, { ...dto, shared: true });
const updated: any = await service.update('t1', admin, created.id, { name: 'Neu' });
expect(updated.name).toBe('Neu');
expect(updated.personal).toBe(false);
await expect(service.remove('t1', admin, created.id)).resolves.toEqual({ deleted: true });
expect(prisma.rows.size).toBe(0);
});
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => {
const prisma = makeFakePrisma();
const service = new CustomModulesService(prisma as any);
vi.mocked(forTenant).mockClear();
const created: any = await service.create('t1', dto);
await service.list('t1');
await service.getOne('t1', created.id);
await service.update('t1', created.id, { name: 'a' });
await service.remove('t1', created.id);
expect(forTenant).toHaveBeenCalledTimes(5);
for (const call of vi.mocked(forTenant).mock.calls) {
expect(call).toEqual([prisma, 't1']);
}
it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
await service.update('t1', userA, created.id, {
name: 'Neu',
shared: true,
ownerUserId: 'ub',
} as any);
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
});
});
describe('CustomModulesService — RLS-Bindung', () => {
it('bindet persoenliche Zugriffe mit Benutzer, gemeinsame Schreibzugriffe ohne', async () => {
const { prisma, service } = setup();
vi.mocked(forTenant).mockClear();
const shared: any = await service.create('t1', admin, { ...dto, shared: true });
const mine: any = await service.create('t1', userA, dto);
await service.list('t1', userA);
await service.getOne('t1', userA, mine.id);
await service.update('t1', userA, mine.id, { name: 'a' });
await service.update('t1', admin, shared.id, { name: 'b' });
const calls = vi.mocked(forTenant).mock.calls;
// create shared: ohne Benutzer
expect(calls[0]).toEqual([prisma, 't1']);
// create personal + list + getOne + (update personal: Laden + Schreiben)
expect(calls[1]).toEqual([prisma, 't1', 'ua']);
expect(calls[2]).toEqual([prisma, 't1', 'ua']);
expect(calls[3]).toEqual([prisma, 't1', 'ua']);
// update shared als Admin: Laden mit Benutzer, Schreiben ohne
expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']);
expect(calls[calls.length - 1]).toEqual([prisma, 't1']);
});
});