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:
@@ -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())
|
||||||
|
);
|
||||||
@@ -53,6 +53,7 @@ model User {
|
|||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
groupMemberships GroupMembership[]
|
groupMemberships GroupMembership[]
|
||||||
moduleGrants ModuleGrant[]
|
moduleGrants ModuleGrant[]
|
||||||
|
customModules CustomModule[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@ -727,8 +728,14 @@ model CustomModule {
|
|||||||
name String
|
name String
|
||||||
url String
|
url String
|
||||||
category String // eine der MODULE_CATEGORIES aus @tessera/shared
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, ownerUserId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
import { CustomModulesController } from './custom-modules.controller';
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
import { CreateCustomModuleDto } from './dto/custom-module.dto';
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
function makeService() {
|
function makeService() {
|
||||||
return {
|
return {
|
||||||
@@ -17,14 +16,20 @@ function makeService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
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;
|
const proto = CustomModulesController.prototype as any;
|
||||||
|
|
||||||
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
|
describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
|
||||||
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
|
// Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
|
// Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
|
||||||
});
|
// vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
|
||||||
|
it.each([
|
||||||
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => {
|
'list',
|
||||||
|
'getOne',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'remove',
|
||||||
|
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,26 +42,26 @@ describe('CustomModulesController — Mandant', () => {
|
|||||||
it('reicht req.tenantId an den Dienst weiter', async () => {
|
it('reicht req.tenantId an den Dienst weiter', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
const controller = new CustomModulesController(service as any);
|
const controller = new CustomModulesController(service as any);
|
||||||
await controller.list(req('t1'));
|
await controller.list(req('t1'), user);
|
||||||
await controller.getOne(req('t1'), 'x');
|
await controller.getOne(req('t1'), user, 'x');
|
||||||
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
|
await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
|
||||||
await controller.update(req('t1'), 'x', { name: 'b' });
|
await controller.update(req('t1'), user, 'x', { name: 'b' });
|
||||||
await controller.remove(req('t1'), 'x');
|
await controller.remove(req('t1'), user, 'x');
|
||||||
expect(service.list).toHaveBeenCalledWith('t1');
|
expect(service.list).toHaveBeenCalledWith('t1', user);
|
||||||
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
|
expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
|
||||||
expect(service.create.mock.calls[0][0]).toBe('t1');
|
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
|
||||||
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
|
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
|
||||||
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
|
expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||||
const controller = new CustomModulesController(makeService() as any);
|
const controller = new CustomModulesController(makeService() as any);
|
||||||
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(
|
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);
|
).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 () => {
|
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
|
||||||
@@ -67,6 +72,27 @@ describe('CustomModulesController — Mandant', () => {
|
|||||||
);
|
);
|
||||||
expect(out).not.toHaveProperty('tenantId');
|
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)', () => {
|
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
|
||||||
import { CustomModulesService } from './custom-modules.service';
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem
|
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
|
||||||
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01);
|
* angemeldete Benutzer darf lesen und eigene (persoenliche) Eintraege anlegen,
|
||||||
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`:
|
* 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
|
* 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.
|
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
||||||
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
|
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
|
||||||
@@ -40,34 +43,44 @@ export class CustomModulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Req() req: AuthenticatedRequest) {
|
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||||
return this.service.list(this.requireTenantId(req));
|
return this.service.list(this.requireTenantId(req), user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
async getOne(
|
||||||
return this.service.getOne(this.requireTenantId(req), id);
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(this.requireTenantId(req), user, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
async create(
|
||||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) {
|
@Req() req: AuthenticatedRequest,
|
||||||
return this.service.create(this.requireTenantId(req), dto);
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateCustomModuleDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(this.requireTenantId(req), user, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async update(
|
async update(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateCustomModuleDto,
|
@Body() dto: UpdateCustomModuleDto,
|
||||||
) {
|
) {
|
||||||
return this.service.update(this.requireTenantId(req), id, dto);
|
return this.service.update(this.requireTenantId(req), user, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
async remove(
|
||||||
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
@Req() req: AuthenticatedRequest,
|
||||||
return this.service.remove(this.requireTenantId(req), id);
|
@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';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
||||||
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId)
|
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode
|
||||||
// uebergeben wird.
|
// gebunden wird (mit oder ohne Benutzer).
|
||||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
forTenant: vi.fn((p: unknown) => p),
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
}));
|
}));
|
||||||
@@ -24,6 +25,11 @@ function makeFakePrisma() {
|
|||||||
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
||||||
let list = [...rows.values()];
|
let list = [...rows.values()];
|
||||||
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
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));
|
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return list;
|
return list;
|
||||||
}),
|
}),
|
||||||
@@ -41,86 +47,233 @@ function makeFakePrisma() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
|
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', () => {
|
function setup() {
|
||||||
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
|
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new CustomModulesService(prisma as any);
|
return { prisma, service: new CustomModulesService(prisma as any) };
|
||||||
await service.create('t1', { ...dto, tenantId: 'evil' } as any);
|
}
|
||||||
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
|
|
||||||
|
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');
|
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
|
it('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const res: any = await service.create('t1', userA, dto);
|
||||||
await service.create('t1', { ...dto, name: 'Zebra' });
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||||
await service.create('t1', { ...dto, name: 'Anker' });
|
expect(res.personal).toBe(true);
|
||||||
await service.create('t2', { ...dto, name: 'Fremd' });
|
expect(res).not.toHaveProperty('ownerUserId');
|
||||||
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('getOne liefert die Zeile ohne tenantId', async () => {
|
it('auch ein Administrator legt ohne shared persoenlich an', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const res: any = await service.create('t1', admin, dto);
|
||||||
const created: any = await service.create('t1', dto);
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1');
|
||||||
const row: any = await service.getOne('t1', created.id);
|
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.name).toBe('Wiki');
|
||||||
|
expect(row.personal).toBe(false);
|
||||||
expect(row).not.toHaveProperty('tenantId');
|
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 () => {
|
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
|
||||||
const service = new CustomModulesService(makeFakePrisma() as any);
|
const { service } = setup();
|
||||||
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
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 () => {
|
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t2', admin, { ...dto, shared: true });
|
||||||
const created: any = await service.create('t2', dto);
|
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
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.update).not.toHaveBeenCalled();
|
||||||
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update aendert nur gesetzte Felder', async () => {
|
it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t1', userA, dto);
|
||||||
const created: any = await service.create('t1', dto);
|
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
await service.update('t1', created.id, { name: 'Neu' });
|
NotFoundException,
|
||||||
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
);
|
||||||
|
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 () => {
|
it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
const created: any = await service.create('t1', dto);
|
await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true });
|
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);
|
expect(prisma.rows.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => {
|
it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t1', userA, dto);
|
||||||
vi.mocked(forTenant).mockClear();
|
await service.update('t1', userA, created.id, {
|
||||||
const created: any = await service.create('t1', dto);
|
name: 'Neu',
|
||||||
await service.list('t1');
|
shared: true,
|
||||||
await service.getOne('t1', created.id);
|
ownerUserId: 'ub',
|
||||||
await service.update('t1', created.id, { name: 'a' });
|
} as any);
|
||||||
await service.remove('t1', created.id);
|
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
||||||
expect(forTenant).toHaveBeenCalledTimes(5);
|
});
|
||||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
});
|
||||||
expect(call).toEqual([prisma, 't1']);
|
|
||||||
}
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
@@ -9,34 +10,132 @@ const CUSTOM_MODULE_SELECT = {
|
|||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
category: true,
|
category: true,
|
||||||
|
ownerUserId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: 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
|
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten-
|
||||||
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
|
* Eintraege mit externer https-Adresse. Zwei Arten:
|
||||||
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
|
*
|
||||||
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
|
* - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle
|
||||||
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
|
* Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren.
|
||||||
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
|
* - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und
|
||||||
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
|
* 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()
|
@Injectable()
|
||||||
export class CustomModulesService {
|
export class CustomModulesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async list(tenantId: string) {
|
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
async list(tenantId: string, caller: CustomModuleCaller) {
|
||||||
return tenantPrisma.customModule.findMany({
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
where: { tenantId },
|
const rows = await tenantPrisma.customModule.findMany({
|
||||||
|
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
select: CUSTOM_MODULE_SELECT,
|
select: CUSTOM_MODULE_SELECT,
|
||||||
});
|
});
|
||||||
|
return rows.map(toResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOne(tenantId: string, id: string) {
|
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 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({
|
const row = await tenantPrisma.customModule.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
|
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
|
||||||
@@ -44,42 +143,28 @@ export class CustomModulesService {
|
|||||||
if (!row || row.tenantId !== tenantId) {
|
if (!row || row.tenantId !== tenantId) {
|
||||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
}
|
}
|
||||||
const { tenantId: _omit, ...result } = row;
|
if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
|
||||||
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) {
|
|
||||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
}
|
}
|
||||||
const data: { name?: string; url?: string; category?: string } = {};
|
return row;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(tenantId: string, id: string) {
|
/**
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
* Klient fuer Aendern/Loeschen: persoenlicher Eintrag -> mit Benutzer (nur der
|
||||||
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
|
* Besitzer kommt bis hierher); gemeinsamer Eintrag -> nur Administrator (403
|
||||||
if (!existing || existing.tenantId !== tenantId) {
|
* sonst, der Eintrag ist fuer alle sichtbar, sein Bestehen ist kein
|
||||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
* 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 } });
|
if (!isAdmin(caller)) {
|
||||||
return { deleted: true };
|
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 { MODULE_CATEGORIES } from '@tessera/shared';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Validate,
|
Validate,
|
||||||
@@ -60,7 +62,23 @@ export class CreateCustomModuleDto {
|
|||||||
|
|
||||||
@IsIn([...MODULE_CATEGORIES])
|
@IsIn([...MODULE_CATEGORIES])
|
||||||
category!: (typeof MODULE_CATEGORIES)[number];
|
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),
|
||||||
|
) {}
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet
|
|||||||
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
| bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff |
|
||||||
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
| settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen |
|
||||||
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
| proxmox | 0 | 11 | 1 | **quick-260923-dhh (Aufgabe 5, Endstand):** 7→11 gebunden — `updateServer` (`proxmoxServer.findUnique` UND `.update`) und `deleteServer` (`proxmoxServer.findUnique` UND `.delete`) bringen vier weitere gebundene Rohtreffer, je ein Klient je Methode. Nachgemessen mit der Gate-Schleife (`grep -c` ueber `tenantPrisma\.\(proxmoxServer\|proxmoxServerStatus\)\.` in `proxmox.service.ts`: 10 fuer `proxmoxServer`, 1 fuer `proxmoxServerStatus`). Vorher: **quick-260923-dhh (Aufgabe 4):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) |
|
||||||
| custom-modules | 0 | 7 | 0 | **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. |
|
| custom-modules | 0 | 6 | 0 | **Nachgemessen quick-260929-dzu:** 0/6/0 — persönliche Einträge je Benutzer: `create` trägt jetzt zwei Klienten in getrennten Zweigen (gemeinsam ohne Benutzer, persönlich mit Benutzer, je ein `tenantPrisma.customModule.create`), die gemeinsame Ladefunktion `loadVisible` trägt das einzige `findUnique` für `getOne`/`update`/`remove` (vorher je Methode eines): `list` 1, `create` 2, `loadVisible` 1, `update` 1, `remove` 1. Das Ergebnis ist ein Treffer weniger als bei quick-260929-9wc, obwohl der Zugriff strenger geworden ist. Vorher: **quick-260929-9wc:** neu, sieben gebundene Rohtreffer in `custom-modules.service.ts` (`list` 1, `getOne` 1, `create` 1, `update` 2, `remove` 2), nachgemessen mit der Gate-Schleife: 0/7/0. Kein ungebundener Zugriff, kein Systemkontext. |
|
||||||
| **Summe** | **61** | **224** | **6** | **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
| **Summe** | **61** | **223** | **6** | **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft |
|
||||||
|
|
||||||
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 79 Paare)
|
## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 79 Paare)
|
||||||
|
|
||||||
@@ -362,6 +362,10 @@ Tabelle hinzugekommen, ohne sie fortzuschreiben. Jetzt +1 `muss-mandantengebunde
|
|||||||
`muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`,
|
`muss-mandantengebunden`, 21 `keine-mandantengebundene-tabelle`, 14 `beides`,
|
||||||
2 `bewusst-uebergreifend`.
|
2 `bewusst-uebergreifend`.
|
||||||
|
|
||||||
|
quick-260929-dzu: keine neue (Datei, Modell)-Zeile, Verteilung unverändert (79 Paare); geändert
|
||||||
|
haben sich nur die Zeile `custom-modules.service.ts`/`customModule` (Benutzerdimension) und die
|
||||||
|
Bereichs-/Summenzeile (Gebunden 224→223, nachgemessen mit der Gate-Schleife).
|
||||||
|
|
||||||
## Der Hintergrunddienst als Falle — sechs Fälle
|
## Der Hintergrunddienst als Falle — sechs Fälle
|
||||||
|
|
||||||
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend —
|
||||||
@@ -774,7 +778,7 @@ werden.
|
|||||||
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
| apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". |
|
||||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
||||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
||||||
| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). |
|
| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). **quick-260929-dzu — persönliche Einträge:** neue Spalte `ownerUserId` (NULL = gemeinsam, gesetzt = persönlich, nur für den Besitzer sichtbar). Klasse und Stand unverändert (`muss-mandantengebunden`, `gebunden`); der Zeilenschutz bekommt die Benutzerdimension nach dem Muster `SearchProvider` (Migration 20260929130000): vier nach Befehl getrennte Regeln — Lesen: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId` NULL ODER eigene Zeile), Schreiben (INSERT/UPDATE/DELETE): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Persönliche Zugriffe binden mit Benutzer (`forTenant(prisma, tenantId, user.id)`); das Schreiben GEMEINSAMER Einträge bindet bewusst OHNE Benutzer, weil die Regel einem Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — davor prüft der Dienst die Rolle (nur Administrator, sonst 403). Fremde persönliche Einträge sind für jeden anderen Benutzer, auch Administratoren, ununterscheidbar 404. Sechs mandantengebundene Rohtreffer (siehe Bereichszeile). |
|
||||||
|
|
||||||
## Was diese Etappe NICHT entscheidet
|
## Was diese Etappe NICHT entscheidet
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user