6b237351e9
- 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
313 lines
12 KiB
TypeScript
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),
|
|
})),
|
|
};
|
|
}
|
|
}
|