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
@@ -0,0 +1,77 @@
-- 260929-dzu — Eigene Module fuer jeden Benutzer: persoenliche Eintraege.
--
-- Zweck: jeder Benutzer darf eigene Seitenleisten-Eintraege anlegen, die nur
-- er selbst sieht. Die Spalte "ownerUserId" unterscheidet: NULL = gemeinsamer
-- Eintrag (vom Administrator, fuer alle sichtbar, bisheriges Verhalten),
-- gesetzt = persoenlicher Eintrag dieses Benutzers. Faellt der Benutzer weg,
-- fallen seine Eintraege mit (ON DELETE CASCADE). Bestehende Zeilen bleiben
-- gemeinsam (NULL).
--
-- Zeilenschutz: Muster "SearchProvider" (20260911120000_rls_user_dimension_
-- personal_tables) — Spalte mit NULL = gemeinsame Zeile. Die eine Regel
-- "tenant_isolation_policy" (aus 20260929120000, ohne Benutzerdimension) wird
-- durch vier nach Befehl getrennte Regeln ersetzt (Praezedenz 260910-jab (3)):
-- ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst,
-- wuerde sie sonst auch zum Aendern/Entfernen freigeben.
-- SELECT: Mandant UND (kein Benutzer gesetzt ODER gemeinsame Zeile ODER
-- eigene Zeile).
-- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER eigene
-- Zeile). Ein Benutzerkontext kann gemeinsame Zeilen also NICHT
-- schreiben; der Administrator-Weg fuer gemeinsame Eintraege bindet
-- deshalb ohne Benutzer (`forTenant(prisma, tenantId)`), die
-- Rollenpruefung liegt im Controller/Dienst.
-- Die Regelnamen sind neu (vier statt eine), rls-coverage.spec.ts fordert nur
-- mindestens eine Regel je Tabelle mit eingeschaltetem RLS.
--
-- Rechte fuer tessera_app kommen ueber ALTER DEFAULT PRIVILEGES aus
-- 20260909130000_rls_app_role — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
-- Anwendungspruefungen im Dienst den Schutz allein.
ALTER TABLE "CustomModule" ADD COLUMN "ownerUserId" TEXT;
CREATE INDEX "CustomModule_tenantId_ownerUserId_idx" ON "CustomModule"("tenantId", "ownerUserId");
ALTER TABLE "CustomModule" ADD CONSTRAINT "CustomModule_ownerUserId_fkey"
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DROP POLICY tenant_isolation_policy ON "CustomModule";
CREATE POLICY tenant_user_read_policy ON "CustomModule"
FOR SELECT
USING (
"tenantId" = current_tenant_id()
AND (
current_user_id() IS NULL
OR "ownerUserId" IS NULL
OR "ownerUserId" = current_user_id()
)
);
CREATE POLICY tenant_user_insert_policy ON "CustomModule"
FOR INSERT
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
CREATE POLICY tenant_user_update_policy ON "CustomModule"
FOR UPDATE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
)
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
CREATE POLICY tenant_user_delete_policy ON "CustomModule"
FOR DELETE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
+7
View File
@@ -53,6 +53,7 @@ model User {
passwordResetTokens PasswordResetToken[]
groupMemberships GroupMembership[]
moduleGrants ModuleGrant[]
customModules CustomModule[]
@@index([tenantId])
@@index([username])
@@ -727,8 +728,14 @@ model CustomModule {
name String
url String
category String // eine der MODULE_CATEGORIES aus @tessera/shared
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
ownerUserId String?
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
@@index([tenantId, ownerUserId])
}
@@ -1,10 +1,9 @@
import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CustomModulesController } from './custom-modules.controller';
import { CreateCustomModuleDto } from './dto/custom-module.dto';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
function makeService() {
return {
@@ -17,14 +16,20 @@ function makeService() {
}
const req = (tenantId?: string) => ({ tenantId }) as any;
const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = CustomModulesController.prototype as any;
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
});
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => {
describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
// Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
// Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
// vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
it.each([
'list',
'getOne',
'create',
'update',
'remove',
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
@@ -37,26 +42,26 @@ describe('CustomModulesController — Mandant', () => {
it('reicht req.tenantId an den Dienst weiter', async () => {
const service = makeService();
const controller = new CustomModulesController(service as any);
await controller.list(req('t1'));
await controller.getOne(req('t1'), 'x');
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
await controller.update(req('t1'), 'x', { name: 'b' });
await controller.remove(req('t1'), 'x');
expect(service.list).toHaveBeenCalledWith('t1');
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
expect(service.create.mock.calls[0][0]).toBe('t1');
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
await controller.list(req('t1'), user);
await controller.getOne(req('t1'), user, 'x');
await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
await controller.update(req('t1'), user, 'x', { name: 'b' });
await controller.remove(req('t1'), user, 'x');
expect(service.list).toHaveBeenCalledWith('t1', user);
expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
});
it('wirft ForbiddenException ohne req.tenantId', async () => {
const controller = new CustomModulesController(makeService() as any);
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.create(req(), { name: 'a', url: 'https://a.de', category: 'fleet' }),
controller.create(req(), user, { name: 'a', url: 'https://a.de', category: 'fleet' }),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.remove(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
});
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
@@ -67,6 +72,27 @@ describe('CustomModulesController — Mandant', () => {
);
expect(out).not.toHaveProperty('tenantId');
});
it('die globale Pipe verwirft ownerUserId, laesst shared beim Anlegen durch', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'a', url: 'https://a.de', category: 'fleet', ownerUserId: 'evil', shared: true },
{ type: 'body', metatype: CreateCustomModuleDto },
);
expect(out).not.toHaveProperty('ownerUserId');
expect(out.shared).toBe(true);
});
it('die globale Pipe verwirft shared und ownerUserId beim Aendern', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
{ name: 'b', shared: true, ownerUserId: 'evil' },
{ type: 'body', metatype: UpdateCustomModuleDto },
);
expect(out).not.toHaveProperty('shared');
expect(out).not.toHaveProperty('ownerUserId');
expect(out.name).toBe('b');
});
});
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
@@ -9,18 +9,21 @@ import {
Post,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
import { CustomModulesService } from './custom-modules.service';
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
/**
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01);
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`:
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
* angemeldete Benutzer darf lesen und eigene (persoenliche) Eintraege anlegen,
* aendern und loeschen; gemeinsame Eintraege (`shared: true`) darf nur ein
* Administrator anlegen, aendern und loeschen — diese Rollenentscheidung trifft
* der Dienst, weil sie vom Eintrag abhaengt (gemeinsam oder persoenlich), nicht
* von der Route. Deshalb tragen die Routen kein `@Roles`. Kein `@UseModule`:
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`).
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), der Benutzer
* aus dem Token.
*
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
@@ -40,34 +43,44 @@ export class CustomModulesController {
}
@Get()
async list(@Req() req: AuthenticatedRequest) {
return this.service.list(this.requireTenantId(req));
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.service.list(this.requireTenantId(req), user);
}
@Get(':id')
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.getOne(this.requireTenantId(req), id);
async getOne(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
return this.service.getOne(this.requireTenantId(req), user, id);
}
@Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) {
return this.service.create(this.requireTenantId(req), dto);
async create(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Body() dto: CreateCustomModuleDto,
) {
return this.service.create(this.requireTenantId(req), user, dto);
}
@Patch(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateCustomModuleDto,
) {
return this.service.update(this.requireTenantId(req), id, dto);
return this.service.update(this.requireTenantId(req), user, id, dto);
}
@Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.remove(this.requireTenantId(req), id);
async remove(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
return this.service.remove(this.requireTenantId(req), user, id);
}
}
@@ -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']);
});
});
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
@@ -9,34 +10,132 @@ const CUSTOM_MODULE_SELECT = {
name: true,
url: true,
category: true,
ownerUserId: true,
createdAt: true,
updatedAt: true,
};
/** Der Aufrufer, wie ihn der Controller aus dem Anmelde-Token liest. */
export interface CustomModuleCaller {
id: string;
role: Role;
}
function isAdmin(caller: CustomModuleCaller): boolean {
return caller.role === Role.ADMIN || caller.role === Role.SUPER_ADMIN;
}
/** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */
function toResponse<T extends { ownerUserId: string | null }>(row: T) {
const { ownerUserId, ...rest } = row;
return { ...rest, personal: ownerUserId !== null };
}
/**
* Eigene Module (quick-260929-9wc): vom Administrator angelegte
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten-
* Eintraege mit externer https-Adresse. Zwei Arten:
*
* - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle
* Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren.
* - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und
* loescht ihn. Ein anderer Benutzer bekommt fuer die id immer 404 — nie einen
* Hinweis, dass es sie gibt.
*
* `tenantId` kommt ausschliesslich als Argument (aus `req.tenantId`), nie aus
* dem DTO. Je Methode ein eigener `forTenant`-Klient.
*
* RLS-BINDUNG (Muster SearchProvider, siehe Migration 20260929130000): Lesen
* und Schreiben PERSOENLICHER Eintraege laeuft mit dem Benutzer als drittem
* Argument (`forTenant(prisma, tenantId, user.id)`); die Regel laesst dann nur
* gemeinsame und eigene Zeilen zu. Schreiben GEMEINSAMER Eintraege laeuft
* bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), weil die Regel einem
* Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — die
* Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen
* alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange
* der RLS-Schalter aus ist.
*/
@Injectable()
export class CustomModulesService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.findMany({
where: { tenantId },
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
async list(tenantId: string, caller: CustomModuleCaller) {
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
const rows = await tenantPrisma.customModule.findMany({
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
orderBy: { name: 'asc' },
select: CUSTOM_MODULE_SELECT,
});
return rows.map(toResponse);
}
async getOne(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
async getOne(tenantId: string, caller: CustomModuleCaller, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
const row = await this.loadVisible(tenantPrisma, tenantId, caller, id);
const { tenantId: _omit, ...result } = row;
return toResponse(result);
}
async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) {
const shared = dto.shared === true;
if (shared && !isAdmin(caller)) {
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
}
const data = {
tenantId,
name: dto.name,
url: dto.url,
category: dto.category,
ownerUserId: shared ? null : caller.id,
};
if (shared) {
// Gemeinsam: ohne Benutzerkontext (die Regel verwehrt ihn dort).
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
return toResponse(row);
}
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
return toResponse(row);
}
async update(
tenantId: string,
caller: CustomModuleCaller,
id: string,
dto: UpdateCustomModuleDto,
) {
const tenantPrisma = await this.writableClient(tenantId, caller, id);
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
if (dto.category !== undefined) data.category = dto.category;
// Besitz und Gemeinsamkeit stehen nie in `data` — sie aendern sich nicht.
const row = await tenantPrisma.customModule.update({
where: { id },
data,
select: CUSTOM_MODULE_SELECT,
});
return toResponse(row);
}
async remove(tenantId: string, caller: CustomModuleCaller, id: string) {
const tenantPrisma = await this.writableClient(tenantId, caller, id);
await tenantPrisma.customModule.delete({ where: { id } });
return { deleted: true };
}
/**
* Zeile laden, die der Aufrufer sehen darf: gleicher Mandant UND (gemeinsam
* ODER eigene). Alles andere — unbekannt, fremder Mandant, fremder
* persoenlicher Eintrag — ist ununterscheidbar 404.
*/
private async loadVisible(
tenantPrisma: ReturnType<typeof forTenant>,
tenantId: string,
caller: CustomModuleCaller,
id: string,
) {
const row = await tenantPrisma.customModule.findUnique({
where: { id },
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
@@ -44,42 +143,28 @@ export class CustomModulesService {
if (!row || row.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const { tenantId: _omit, ...result } = row;
return result;
}
async create(tenantId: string, dto: CreateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.create({
data: { tenantId, name: dto.name, url: dto.url, category: dto.category },
select: CUSTOM_MODULE_SELECT,
});
}
async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
if (dto.category !== undefined) data.category = dto.category;
return tenantPrisma.customModule.update({
where: { id },
data,
select: CUSTOM_MODULE_SELECT,
});
return row;
}
async remove(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
/**
* Klient fuer Aendern/Loeschen: persoenlicher Eintrag -> mit Benutzer (nur der
* Besitzer kommt bis hierher); gemeinsamer Eintrag -> nur Administrator (403
* sonst, der Eintrag ist fuer alle sichtbar, sein Bestehen ist kein
* Geheimnis), dann ohne Benutzerkontext.
*/
private async writableClient(tenantId: string, caller: CustomModuleCaller, id: string) {
const userClient = forTenant(this.prisma, tenantId, caller.id);
const row = await this.loadVisible(userClient, tenantId, caller, id);
if (row.ownerUserId === caller.id) {
return userClient;
}
await tenantPrisma.customModule.delete({ where: { id } });
return { deleted: true };
if (!isAdmin(caller)) {
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern');
}
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma;
}
}
@@ -1,9 +1,11 @@
import { PartialType } from '@nestjs/mapped-types';
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Validate,
@@ -60,7 +62,23 @@ export class CreateCustomModuleDto {
@IsIn([...MODULE_CATEGORIES])
category!: (typeof MODULE_CATEGORIES)[number];
/**
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
* an (nur Administratoren, sonst 403 im Dienst). Fehlt das Feld oder ist es
* `false`, ist der Eintrag persoenlich und gehoert dem Aufrufer.
*/
@IsOptional()
@IsBoolean()
shared?: boolean;
}
/** Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. */
export class UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) {}
/**
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
* dank `whitelist: true`).
*/
export class UpdateCustomModuleDto extends PartialType(
OmitType(CreateCustomModuleDto, ['shared'] as const),
) {}