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
+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 },