feat(quick-260911-nke): Benutzer an 34 Aufrufstellen gesetzt, zehn Tabellen gemessen, sechs Pruefungen umgedreht

- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6,
  dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2,
  tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument
  durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst,
  Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein
  Controller angefasst, keine anwendungsseitige userId-Filterung entfernt.
- rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration
  umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/
  TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in
  runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/
  WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates).
  SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln).
  runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue
  Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen
  Tabellen inkl. gemeinsame-Zeile-Pruefungen).
- Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance,
  searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) —
  alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT
  Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung.
- Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug
  203/203 bestanden (vorher 146).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-11 17:39:17 +02:00
parent f0b531b712
commit 07fc653f52
16 changed files with 785 additions and 108 deletions
@@ -238,6 +238,8 @@ describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => {
expect(result).toHaveLength(1);
expect(result[0].hasCredentials).toBe(true);
expect((result[0] as any).encryptedPassword).toBeUndefined();
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
});
it('getSources von Nutzer A liefert NICHT die Quellen von Nutzer B desselben Mandanten — der userId-Filter bleibt, die Bindung ergaenzt ihn', async () => {
+18 -12
View File
@@ -108,13 +108,19 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
* The three ownership checks (`updateSource`/`deleteSource`/
* `testConnection`, comparing `existing.userId` against the calling user)
* are kept UNCHANGED alongside the binding, not replaced by it: the RLS
* policy on `CalendarSource` carries no user dimension (measured
* 260911-cwh, Aufgabe 1 — `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
* policy on `CalendarSource` carried no user dimension when measured
* 260911-cwh, Aufgabe 1 (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
* so a colleague of the SAME tenant would otherwise see and modify a
* fellow user's encrypted Exchange/CalDAV credentials. Until the RLS
* policy itself gains a user dimension (Etappe-3-Entscheidung (2)), these
* application-level checks remain the only protection between users of the
* same tenant.
* fellow user's encrypted Exchange/CalDAV credentials.
*
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt die
* `tenant_isolation_policy` auf `CalendarSource` die Benutzerdimension
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — jeder
* `forTenant()`-Aufruf oben reicht `userId` als drittes Argument durch. Die
* drei anwendungsseitigen Besitzpruefungen bleiben trotzdem UNVERAENDERT
* bestehen: die Datenbankregel ist ein ZWEITES Netz, kein Ersatz dafuer, und
* ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen Mandanten
* (siehe .planning/WINDOWS.md).
*
* Credentials encrypted at rest via CryptoService (T-05-10).
*/
@@ -155,7 +161,7 @@ export class CalendarService {
* Adds a `hasCredentials` boolean so the UI knows if credentials are set.
*/
async getSources(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const sources = await tenantPrisma.calendarSource.findMany({
where: { userId },
select: {
@@ -195,7 +201,7 @@ export class CalendarService {
data.encryptedPassword = this.crypto.encrypt(dto.password);
}
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const created = await tenantPrisma.calendarSource.create({
data: data as any,
select: SOURCE_SAFE_SELECT,
@@ -209,7 +215,7 @@ export class CalendarService {
* Re-encrypts password if provided; T-05-12 ownership enforcement.
*/
async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.calendarSource.findUnique({
where: { id },
select: { userId: true, type: true },
@@ -261,7 +267,7 @@ export class CalendarService {
* Deletes a calendar source. Ownership check enforced (T-05-12).
*/
async deleteSource(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const existing = await tenantPrisma.calendarSource.findUnique({
where: { id },
select: { userId: true },
@@ -283,7 +289,7 @@ export class CalendarService {
* Updates lastSyncAt/lastSyncError on the source record.
*/
async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const source = await tenantPrisma.calendarSource.findUnique({ where: { id } });
if (!source) throw new NotFoundException('Calendar source not found');
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source');
@@ -399,7 +405,7 @@ export class CalendarService {
to: Date,
cacheKey: string,
): Promise<CalendarEvent[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const sources = await tenantPrisma.calendarSource.findMany({
where: { userId, isVisible: true },
});
@@ -388,6 +388,8 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
expect(result).toEqual({ lg: [{ i: 'w1' }] });
expectBoundCall(prisma, 'tenant-1', 'dashboardLayout', 'findUnique');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-1', 'user-1');
});
it('getLayout: kein Widget/keine Anordnung vorhanden liefert die Vorgabeanordnung, keinen Fehler — heutiges Verhalten, damit eine spätere Änderung sichtbar wird', async () => {
+26 -11
View File
@@ -58,12 +58,27 @@ const DEFAULT_SEARCH_PROVIDERS = [
* three ownership checks in this file (`updateWidgetConfig`, `removeWidget`,
* `removeSearchProvider`) compare against the user id from the session proof
* and are NOT decorative: the RLS rules on `DashboardLayout`, `WidgetInstance`
* and `SearchProvider` know only the tenant dimension, not the user dimension
* (measured 260910-krx, Aufgabe 1, Befund G) — until the switch is flipped
* and `SearchProvider` knew only the tenant dimension, not the user dimension,
* when measured 260910-krx, Aufgabe 1, Befund G — until the switch is flipped
* (WINDOWS #18) they remain the only actually effective protection against
* cross-reading/cross-deleting between two users of the SAME tenant, and the
* `forTenant()` binding below ADDS a tenant boundary on top of them, it never
* replaces them.
*
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 tragen die
* Regeln auf `DashboardLayout`, `WidgetInstance` und `SearchProvider` die
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`,
* fuer `SearchProvider` zusaetzlich als vier befehlsgetrennte Regeln) — jeder
* `forTenant()`-Aufruf unten reicht `userId` als drittes Argument durch. Die
* drei anwendungsseitigen Besitzpruefungen bleiben UNVERAENDERT: zweites Netz,
* kein Ersatz. Ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen
* Mandanten (siehe .planning/WINDOWS.md). Beobachtung fuer die Kritikschrift:
* `removeWidget`/`updateWidgetConfig`/`removeSearchProvider` holen die Zeile
* per `findUnique({ where: { id } })` und vergleichen danach `userId` — nach
* dem Scharfschalten liefert `findUnique` fuer die Zeile eines Kollegen
* bereits `null` (die Regel blendet sie aus), die Anwendung meldet dann
* NotFoundException statt der heutigen Forbidden-Form — beides eine
* Abweisung, nur die Fehlerart aendert sich.
*/
@Injectable()
export class DashboardService {
@@ -77,7 +92,7 @@ export class DashboardService {
* with all breakpoint arrays initialized.
*/
async getLayout(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({
where: { userId },
});
@@ -108,7 +123,7 @@ export class DashboardService {
* deferred as a product decision to Etappe 3, same as WINDOWS #22.
*/
async saveLayout(userId: string, tenantId: string, dto: SaveLayoutDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
try {
return await tenantPrisma.dashboardLayout.upsert({
where: { userId },
@@ -144,7 +159,7 @@ export class DashboardService {
* betroffene Widget entfernt (Fail-Closed).
*/
async getWidgets(userId: string, tenantId: string, role: Role) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
@@ -195,7 +210,7 @@ export class DashboardService {
* Creates a new widget instance for the user.
*/
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.widgetInstance.create({
data: {
userId,
@@ -223,7 +238,7 @@ export class DashboardService {
tenantId: string,
dto: UpdateWidgetConfigDto,
) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id },
});
@@ -253,7 +268,7 @@ export class DashboardService {
* queries run over the SAME bound client and tenant id.
*/
async removeWidget(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id },
});
@@ -279,7 +294,7 @@ export class DashboardService {
* below and are always prepended unchanged.
*/
async getSearchProviders(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const custom = await tenantPrisma.searchProvider.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
@@ -300,7 +315,7 @@ export class DashboardService {
tenantId: string,
dto: CreateSearchProviderDto,
) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.searchProvider.create({
data: {
userId,
@@ -319,7 +334,7 @@ export class DashboardService {
* above: both queries run over the SAME bound client and tenant id.
*/
async removeSearchProvider(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
// Default providers have hardcoded IDs that won't exist in DB
const provider = await tenantPrisma.searchProvider.findUnique({
where: { id },
@@ -203,6 +203,8 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
expect(result.map((r: any) => r.id)).toEqual(['f2', 'f1']);
expectBoundCall(prisma, 't1', 'favoriteLink', 'findMany');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-a1');
});
it('liefert unter einem FREMDEN Mandanten eine leere Liste, kein Fehler (der Wert, aus dem das Widget "Noch keine Favoriten." macht)', async () => {
+14 -10
View File
@@ -23,11 +23,15 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
*
* Die Regel auf `FavoriteLink` kennt KEINE Benutzerdimension (260911-gwh,
* Aufgabe 1, Pruefung 4 — dieselbe Lehre wie `CalendarSource`/
* `DashboardLayout`/`WidgetInstance`) — die `userId`-Filter unten bleiben
* deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN
* Mandanten (Etappe-3-Entscheidung (2) traegt das nach).
* Die Regel auf `FavoriteLink` trug bei der Messung 260911-gwh (Aufgabe 1,
* Pruefung 4) KEINE Benutzerdimension — dieselbe Lehre wie `CalendarSource`/
* `DashboardLayout`/`WidgetInstance`. Nachtrag (260911-nke, Etappe 3b): seit
* Migration 20260911120000 traegt die Regel auf `FavoriteLink` die
* Benutzerdimension (`current_user_id() IS NULL OR "userId" = current_user_id()`)
* — jeder `forTenant()`-Aufruf unten reicht `userId` als drittes Argument
* durch. Die `userId`-Filter unten bleiben trotzdem UNVERAENDERT bestehen:
* zweites Netz, kein Ersatz — ein Aufrufer, der `userId` vergisst, saehe
* ohne sie den ganzen Mandanten (siehe .planning/WINDOWS.md).
*
* Access control (T-08-06 / Pitfall 3):
* - Every query is scoped by userId (prevents cross-user access).
@@ -58,7 +62,7 @@ export class FavoritesService {
async list(tenantId: string, userId: string, widgetId: string) {
if (!widgetId) throw new BadRequestException('widgetId is required');
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.favoriteLink.findMany({
where: { userId, widgetId },
orderBy: [{ position: 'asc' }, { title: 'asc' }],
@@ -72,7 +76,7 @@ export class FavoritesService {
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
*/
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
@@ -116,7 +120,7 @@ export class FavoritesService {
* Accepts null as an explicit value for iconUrl (clears stored icon).
*/
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
@@ -157,7 +161,7 @@ export class FavoritesService {
* Verifies userId ownership before deleting (T-08-06).
*/
async remove(tenantId: string, id: string, userId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId) {
@@ -183,7 +187,7 @@ export class FavoritesService {
id: string,
userId: string,
): Promise<{ contentType: string; body: Buffer }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
if (!link || link.userId !== userId || !link.iconUrl) {
@@ -132,6 +132,14 @@ export class TenderDigestScheduler implements OnModuleInit {
// Je-Treffer-Haelfte, gebunden an den Mandanten DIESER
// Kandidatenzeile (260909-laa, Aufgabe 3) — ein einziger gebundener
// Client fuer alle Zugriffe dieses Schleifendurchlaufs.
//
// Bewusst OHNE Benutzer (260911-nke, Etappe 3b): dieser Scheduler ist
// ein Hintergrunddienst, kein Nutzer-CRUD-Aufrufer — er liest UND
// schreibt fuer den Nutzer, nicht ALS ihn eingeloggt. Die `IS NULL
// OR`-Form der Regeln macht das zur bewussten Eigenschaft: ohne
// `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor
// der Migration. Ein Systemkontext fuer Hintergrunddienste ist
// Etappe 3c, nicht Teil dieser Aenderung.
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const pref = await tenantPrisma.tenderNotificationPref.findUnique({
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { TenderEmailConfigService } from './tender-email-config.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07).
@@ -375,6 +376,8 @@ describe('TenderEmailConfigService', () => {
(c: any) => c.tenantId === 't1' && c.model === 'tenderEmailConfig' && c.method === 'findUnique',
);
expect(findUniqueCalls.length).toBe(2);
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'user-k');
});
it('saveConfig() bindet den credChanged-Lesezugriff UND das upsert an den uebergebenen Mandanten', async () => {
@@ -54,6 +54,12 @@ const EMAIL_CONFIG_SAFE_SELECT = {
* uniqueness constraint on `userId` and surfaces as a translated
* ConflictException, not a raw 500 (T-LAA-07, Befund F, Aufgabe 1).
*
* Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every
* `forTenant()` call above also passes `userId` as the third argument, so
* the `tenant_isolation_policy` on TenderEmailConfig ALSO enforces
* `userId = current_user_id()` — a second net, not a replacement for the
* `userId @unique` ownership model above.
*
* Security:
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
* getConfigForApi returns `hasPassword: boolean` instead of the password.
@@ -96,7 +102,7 @@ export class TenderEmailConfigService {
* by userId (T-17-01) — a user only ever reads their own mailbox.
*/
async getConfigForApi(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
where: { userId },
select: EMAIL_CONFIG_SAFE_SELECT,
@@ -146,7 +152,7 @@ export class TenderEmailConfigService {
*/
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
const { userId, tenantId } = ctx;
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
let encryptedInboxCreds: string | undefined;
const credChanged =
@@ -234,7 +240,7 @@ export class TenderEmailConfigService {
if (!username || !password) {
try {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
if (existing?.encryptedInboxCreds) {
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
@@ -1,6 +1,7 @@
import { ConflictException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { TenderNotificationPrefService } from './tender-notification-pref.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* TenderNotificationPrefService.spec — RED-first (TDD) proof for NOTIFY-01
@@ -144,6 +145,8 @@ describe('TenderNotificationPrefService', () => {
await service.getForUser('u1', 't1');
expectBoundCall(prisma, 't1', 'findUnique');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
});
it('setForUser() bindet tenderNotificationPref.upsert an den uebergebenen Mandanten', async () => {
@@ -26,6 +26,12 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* the failure is a P2002 unique-constraint violation, not an RLS
* rejection. Translated below into a German message, same pattern as
* `tender-saved-search.service.ts`, instead of surfacing as a raw 500.
*
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
* die Regel auf TenderNotificationPref die Benutzerdimension
* (`current_user_id() IS NULL OR "userId" = current_user_id()`) — beide
* `forTenant()`-Aufrufe unten reichen `userId` als drittes Argument durch.
* Die anwendungsseitige userId-Filterung bleibt zweites Netz, kein Ersatz.
*/
@Injectable()
export class TenderNotificationPrefService {
@@ -39,7 +45,7 @@ export class TenderNotificationPrefService {
* autowrite needed to represent "using the default".
*/
async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const existing = await tenantPrisma.tenderNotificationPref.findUnique({
where: { userId },
});
@@ -57,7 +63,7 @@ export class TenderNotificationPrefService {
* than creating a new one.
*/
async setForUser(userId: string, tenantId: string, digestInterval: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
try {
return await tenantPrisma.tenderNotificationPref.upsert({
where: { userId },
@@ -509,6 +509,7 @@ describe('TenderRssFeedSourceService', () => {
expectBoundCall(prisma, 'tenant-a', 'count');
expectBoundCall(prisma, 'tenant-a', 'create');
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'user-a');
});
// Umkehr von 'listForUser() bindet NICHT' (260910-jab, Aufgabe 2): seit
@@ -530,7 +531,7 @@ describe('TenderRssFeedSourceService', () => {
await service.listForUser('u-anyone', 'tenant-a');
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a');
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a', 'u-anyone');
expectBoundCall(prisma, 'tenant-a', 'findMany');
});
@@ -69,7 +69,7 @@ export class TenderRssFeedSourceService {
* Bindung nicht überflüssig, sondern das zweite Netz.
*/
async listForUser(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.tenderRssFeedSource.findMany({
where: { OR: [{ userId: null }, { userId }] },
orderBy: { createdAt: 'asc' },
@@ -93,7 +93,7 @@ export class TenderRssFeedSourceService {
) {
this.assertUrlAllowed(dto.url);
const tenantPrisma = forTenant(this.prisma, ctx.tenantId) as any;
const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId) as any;
const existingCount = await tenantPrisma.tenderRssFeedSource.count({
where: { userId: ctx.userId },
});
@@ -130,7 +130,10 @@ export class TenderRssFeedSourceService {
* Zeile laesst sich unter der Anwendungsrolle grundsaetzlich nicht
* anlegen, weil jede Schreibregel einen Mandanten verlangt. Kein
* Verwaltungsweg dafuer existiert heute; WINDOWS #24 haelt das als eigenen
* offenen Punkt fest, der NICHT mit #19 verschwindet.
* offenen Punkt fest, der NICHT mit #19 verschwindet. Nachtrag (260911-nke,
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
* (20260911120000) — `createPlatform` bleibt bewusst ungebunden, WINDOWS #24
* unveraendert offen.
*/
async createPlatform(dto: TenderRssFeedDto) {
this.assertUrlAllowed(dto.url);
@@ -173,7 +176,10 @@ export class TenderRssFeedSourceService {
* Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu binden,
* wuerde ausserdem das Pruef-/Nutzungsfenster wieder oeffnen, das dieser
* Kommentar oben (T-17-07) vermeidet — deshalb bleibt die gesamte Methode
* ungebunden, nicht nur ihre plattformweite Haelfte.
* ungebunden, nicht nur ihre plattformweite Haelfte. Nachtrag (260911-nke,
* Etappe 3b): dieselbe Begruendung gilt fuer die neue Benutzerdimension
* (20260911120000) — `remove` bleibt bewusst ungebunden, WINDOWS #24
* unveraendert offen.
*/
async remove(id: string, ctx: { userId: string; isAdmin: boolean }) {
const { userId, isAdmin } = ctx;
@@ -1,6 +1,7 @@
import { ConflictException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { TenderTriageService } from './tender-triage.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/**
* TenderTriageService.spec — RED-first (TDD) proof for UI-03/04 (D-09/D-10/
@@ -188,6 +189,8 @@ describe('TenderTriageService', () => {
await service.setTriage('u1', 't1', 'tender-x', { isRead: true });
expectBoundCall(prisma, 't1', 'upsert');
// Benutzerdimension (260911-nke): forTenant() bekommt userId als drittes Argument.
expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1');
});
it('listForUser() bindet tenderTriage.findMany an den uebergebenen Mandanten', async () => {
@@ -25,6 +25,12 @@ export interface SetTriageInput {
* TenderSavedSearch policy in Aufgabe 1; all five policies of this area
* share the identical `"tenantId" = current_tenant_id()` text).
*
* Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt
* die Regel auf TenderTriage die Benutzerdimension (`current_user_id() IS
* NULL OR "userId" = current_user_id()`) — alle drei `forTenant()`-Aufrufe
* unten reichen `userId` als drittes Argument durch. Die anwendungsseitige
* userId-Filterung bleibt zweites Netz, kein Ersatz.
*
* Cascade (Pitfall 6): the schema's `Tender @relation(..., onDelete:
* Cascade)` removes a tender's triage rows automatically when Phase 10's
* retention job deletes the tender — no manual cleanup needed here.
@@ -69,7 +75,7 @@ export class TenderTriageService {
update.favoritedAt = dto.isFavorite ? now : null;
}
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
try {
return await tenantPrisma.tenderTriage.upsert({
where: { userId_tenderId: { userId, tenderId } },
@@ -105,7 +111,7 @@ export class TenderTriageService {
*/
async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
if (!tenderIds.length) return [];
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
return tenantPrisma.tenderTriage.findMany({
where: { userId, tenderId: { in: tenderIds } },
});
@@ -117,7 +123,7 @@ export class TenderTriageService {
* tender-query.builder.ts's buildTenderWhere.
*/
async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
const rows = await tenantPrisma.tenderTriage.findMany({
where: { userId, isFavorite: true },
select: { tenderId: true },