c703d87a1c
- 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>
171 lines
6.5 KiB
TypeScript
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;
|
|
}
|
|
}
|