Files
tessera-ctl/apps/api/src/groups/module-grants.service.ts
T
schalli 6b237351e9 feat(quick-260910-jab): listForUser binden, die vier Aufzeichnungen im Quelltext richtigstellen
- TenderRssFeedSourceService.listForUser() nimmt jetzt (userId, tenantId)
  entgegen und laeuft ueber einen gebundenen Klienten (forTenant) — die neue
  Leseregel schliesst plattformweite Zeilen ein, die Reparatur haette den
  ungebundenen Pfad sonst still auf nur die plattformweiten Zeilen reduziert
  (Befund F). createPlatform/remove bleiben bewusst ungebunden, Kommentare an
  der neuen Regel richtiggestellt.
- TendersController.listRssFeeds reicht die Mandantenkennung aus dem
  Aufrufzusammenhang durch.
- Vier Aufzeichnungen im Quelltext (module-access.service.ts,
  groups.service.ts, module-grants.service.ts, rls-coverage.spec.ts) sagen
  jetzt, dass die Datenbankregel seit 20260910120000_rls_widen_membership_
  grant_and_platform_read beide Seiten prueft; die Anwendungspruefungen
  bleiben unveraendert bestehen (zweites Netz, wirkt vor dem Scharfschalten
  als einziger Schutz).
- Zwei-Klienten-Nachweis in module-grants.service.spec.ts ergaenzt (Kommentar,
  warum die beiden Cross-Tenant-Tests nach der Regelaenderung nicht entfallen
  duerfen) und in tender-rss-feed.service.spec.ts umgekehrt (listForUser
  bindet jetzt).
- Rule 1: implizites any beim Destrukturieren in listRssFeeds (feeds ist seit
  der Bindung `any`) mit expliziter Annotation behoben.
- Falsifizierungsnachweis durchgefuehrt: Bindungsaufruf zurueckgenommen,
  genau ein Test wurde rot (AssertionError, 0 statt der erwarteten
  Aufrufe), Ruecknahme rueckgaengig gemacht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-10 14:35:13 +02:00

313 lines
12 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für
* einzelne Benutzer anlegen und entziehen, plus die Datenlieferung für die
* Freigabe-Matrix (D-15) und das Benutzer-Detail (D-16).
*
* Liest/schreibt dieselben ModuleGrant-Zeilen, die
* ModuleAccessService.getAccessibleModuleIds (15-01) für die Leseseite
* konsumiert — eine Schreib- und eine Leseseite auf einem Datensatz.
*
* D-23: jede erfolgreiche Mutation schreibt ausschließlich eine Logzeile
* über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine
* Ansicht im Admin-UI.
*
* D-04: der Datensatz trägt keine Rechtestufe, und dieser Service bietet
* keine Methode, die eine solche setzen könnte.
*/
@Injectable()
export class ModuleGrantsService {
private readonly logger = new Logger(ModuleGrantsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Prüft, dass die referenzierte Gruppe bzw. der referenzierte Benutzer
* zum Mandanten aus dem JWT gehört, und wirft andernfalls
* NotFoundException.
*
* tenantId stammt vertrauenswürdig aus dem Token — groupId/userId kommen
* dagegen aus dem Request-Body eines Admin-Clients. Ohne diese
* Gegenprüfung könnte ein Admin eines Mandanten einen Grant auf eine
* Gruppe oder einen Benutzer eines anderen Mandanten legen und darüber
* Zugriff verschaffen (T-15-01). Im Bestandscode gibt es dafür kein
* Vorbild — die bisherigen Ownership-Prüfungen (z. B.
* DashboardService.removeWidget) betreffen nur direktes Eigentum, nicht
* eine zweite Mandantengrenze über eine Relation.
*/
private async assertTargetBelongsToTenant(
tenantId: string,
groupId?: string,
userId?: string,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
if (groupId) {
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
});
if (!group) {
throw new NotFoundException(`Gruppe '${groupId}' nicht gefunden`);
}
}
if (userId) {
const user = await tenantPrisma.user.findFirst({
where: { id: userId, tenantId },
});
if (!user) {
throw new NotFoundException(`Benutzer '${userId}' nicht gefunden`);
}
}
}
/**
* Legt einen Grant für eine Gruppe ODER einen einzelnen Benutzer an (nie
* beides, nie keines — D-04). Prüfreihenfolge: Entweder-oder der beiden
* Referenzen (BadRequestException mit Klartext, damit das Admin-UI nicht
* den rohen Postgres-Constraint-Namen sieht), dann die Mandanten-
* Gegenprüfung, dann die aktive TenantModuleActivation des Mandanten für
* die moduleId (ein Grant auf ein nicht aktiviertes Modul wäre
* wirkungslos, D-02), dann create. Ein P2002 aus dem partiellen
* Unique-Index (zwei parallele Klicks auf dieselbe Matrix-Zelle) wird als
* Erfolg behandelt und liefert den bestehenden Datensatz zurück statt
* eines HTTP 500.
*/
async grant(
tenantId: string,
data: { moduleId: string; groupId?: string; userId?: string },
) {
const { moduleId, groupId, userId } = data;
if ((groupId && userId) || (!groupId && !userId)) {
throw new BadRequestException(
'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines',
);
}
// Die Mandanten-Gegenpruefung bleibt ausdruecklich erhalten (T-JTS-03,
// 260909-jts/260910-jab): bis Migration
// 20260910120000_rls_widen_membership_grant_and_platform_read pruefte
// die Regel auf ModuleGrant ausschliesslich die Mandantenkennung der
// Zeile selbst ("tenantId" = current_tenant_id()),
// NICHT die referenzierte Gruppe oder den referenzierten Benutzer — eine
// Zeile mit korrekter eigener Mandantenkennung, die auf die Gruppe/den
// Benutzer eines fremden Mandanten zeigt, verletzte diese Regel
// nachweislich nicht (gemessen in Aufgabe 1 von 260909-jts). Die
// Datenbank zieht diese Grenze inzwischen ebenfalls (260910-jab, Aufgabe
// 1) — diese zweite Ziehung wirkt aber erst NACH dem Scharfschalten
// (#18, der Schalter ist weiterhin aus). Diese Anwendungspruefung bleibt
// deshalb bis dahin der EINZIGE und danach der ERSTE Schutz gegen diese
// Form der Rechteausweitung und darf nicht als "macht jetzt die
// Datenbank" entfallen.
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
where: { tenantId_moduleId: { tenantId, moduleId } },
});
if (!activation?.isActive) {
throw new BadRequestException(
`Modul '${moduleId}' ist für diesen Mandanten nicht aktiviert`,
);
}
const target = groupId ? `group=${groupId}` : `user=${userId}`;
try {
const created = await tenantPrisma.moduleGrant.create({
data: {
tenantId,
moduleId,
groupId: groupId ?? null,
userId: userId ?? null,
},
});
this.logger.log(
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`,
);
return created;
} catch (err: any) {
if (err?.code === 'P2002') {
const existing = await tenantPrisma.moduleGrant.findFirst({
where: {
tenantId,
moduleId,
groupId: groupId ?? null,
userId: userId ?? null,
},
});
if (existing) {
this.logger.log(
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
);
return existing;
}
}
throw err;
}
}
/**
* Entzieht einen Grant. deleteMany statt delete: folgenlos, wenn nichts
* passt, kein vorheriger Lookup nötig. tenantId im where ist gleichzeitig
* der IDOR-Schutz (T-15-02) — ein Ziel eines fremden Mandanten trifft
* null Zeilen.
*/
async revoke(
tenantId: string,
data: { moduleId: string; groupId?: string; userId?: string },
) {
const { moduleId, groupId, userId } = data;
const target = groupId ? `group=${groupId}` : `user=${userId}`;
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
await tenantPrisma.moduleGrant.deleteMany({
where: {
tenantId,
moduleId,
...(groupId ? { groupId } : {}),
...(userId ? { userId } : {}),
},
});
this.logger.log(
`Grant entzogen: tenant=${tenantId} module=${moduleId} ${target}`,
);
}
/**
* Datenlieferung für die Freigabe-Matrix (D-15): die aktiven Module, die
* Gruppen und die Gruppen-Grants des Mandanten in einer Antwort. Module
* sind nach category und dann name sortiert, Gruppen nach name — die
* explizite Sortierung hält Spalten-/Zeilenreihenfolge über Aufrufe
* hinweg stabil.
*/
async getMatrix(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const [activations, groups, groupGrants] = await Promise.all([
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
include: { module: true },
}),
tenantPrisma.group.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, groupId: { not: null } },
select: { moduleId: true, groupId: true },
}),
]);
const modules = activations
.map((a: any) => a.module)
.sort(
(a: any, b: any) =>
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
);
return {
modules,
groups,
grants: groupGrants.map((g: any) => ({
moduleId: g.moduleId as string,
groupId: g.groupId as string,
})),
};
}
/**
* Datenlieferung für das Benutzer-Detail (D-16): zwei unabhängige
* Antworten in einem Objekt.
*
* `groups` stammt aus GroupMembership und ist bewusst unabhängig von
* ModuleGrant — eine Mitgliedschaft ohne jede Modul-Freigabe bleibt
* dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
* Direkt-Grant) und behält dafür je Eintrag exakt die Form
* { module, viaGroups, direct }.
*
* Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern
* `group.internalName ?? group.name` — Nullish, nicht Oder, damit ein
* bereits auf null normalisierter Leerstring (Task 2) nicht versehentlich
* über eine Truthiness-Prüfung nachträglich anders interpretiert wird.
* Die Sortierung von `groups` läuft danach automatisch über den
* angezeigten Namen, weil sie auf demselben Feld (`name`) aufsetzt —
* anders als GroupsService.listForTenant(), das weiterhin über die
* Datenbankspalte `name` sortiert.
*/
async getUserAccess(tenantId: string, userId: string) {
await this.assertTargetBelongsToTenant(tenantId, undefined, userId);
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const [activations, groupGrants, directGrants, memberships] = await Promise.all([
tenantPrisma.tenantModuleActivation.findMany({
where: { tenantId, isActive: true },
include: { module: true },
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, group: { memberships: { some: { userId } } } },
include: { group: true },
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId },
select: { moduleId: true },
}),
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
// es entsteht kein zweiter gebundener Client. Der `where`-Filter
// über die Beziehung zur Gruppe (`group: { tenantId }`) bleibt
// ZUSÄTZLICH stehen: GroupMembership trägt keine eigene tenantId-
// Spalte, und die ausgelieferte Regel auf dieser Tabelle bezieht
// ihre Sichtbarkeit ausschließlich über die Gruppenseite (gemessen
// in Aufgabe 1) — der Anwendungsfilter ist deshalb nicht redundant,
// sondern das zweite Netz.
tenantPrisma.groupMembership.findMany({
where: { userId, group: { tenantId } },
include: { group: { select: { id: true, name: true, internalName: true } } },
}),
]);
const directModuleIds = new Set(directGrants.map((g: any) => g.moduleId as string));
const groupNamesByModule = new Map<string, string[]>();
for (const g of groupGrants as any[]) {
if (!g.group) continue;
const names = groupNamesByModule.get(g.moduleId) ?? [];
names.push(g.group.internalName ?? g.group.name);
groupNamesByModule.set(g.moduleId, names);
}
const modules = activations
.map((a: any) => a.module)
.sort(
(a: any, b: any) =>
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
);
const groups = (memberships as any[])
.filter((m) => m.group)
.map((m) => ({
id: m.group.id as string,
name: (m.group.internalName ?? m.group.name) as string,
source: m.source as string,
}))
.sort((a, b) => a.name.localeCompare(b.name));
return {
groups,
modules: modules.map((module: any) => ({
module,
viaGroups: groupNamesByModule.get(module.id) ?? [],
direct: directModuleIds.has(module.id),
})),
};
}
}