Files
tessera-ctl/apps/api/src/custom-modules/custom-modules.service.ts
T
schalli c703d87a1c 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>
2026-09-29 10:09:08 +02:00

171 lines
6.5 KiB
TypeScript

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';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
const CUSTOM_MODULE_SELECT = {
id: true,
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, 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) {}
/** 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, 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 },
});
if (!row || row.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
return row;
}
/**
* 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;
}
if (!isAdmin(caller)) {
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern');
}
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma;
}
}