Files
tessera-ctl/apps/api/src/dashboard/dashboard.service.ts
T
schalli 07fc653f52 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
2026-09-11 17:39:17 +02:00

370 lines
14 KiB
TypeScript

import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, Role } from '@prisma/client';
import { ModuleAccessService } from '../module-registry/module-access.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
import { CreateWidgetDto } from './dto/create-widget.dto';
import { SaveLayoutDto } from './dto/save-layout.dto';
import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
import { getModuleSlugForWidgetType } from './widget-module-map';
/**
* Default search providers (D-15).
* Returned as part of getSearchProviders even when no DB rows exist.
* userId null = global defaults — cannot be deleted by users.
*/
const DEFAULT_SEARCH_PROVIDERS = [
{
id: 'google',
userId: null,
tenantId: null,
name: 'Google',
urlTemplate: 'https://www.google.com/search?q={query}',
isDefault: true,
createdAt: new Date('2024-01-01'),
},
{
id: 'bing',
userId: null,
tenantId: null,
name: 'Bing',
urlTemplate: 'https://www.bing.com/search?q={query}',
isDefault: true,
createdAt: new Date('2024-01-01'),
},
{
id: 'ddg',
userId: null,
tenantId: null,
name: 'DuckDuckGo',
urlTemplate: 'https://duckduckgo.com/?q={query}',
isDefault: true,
createdAt: new Date('2024-01-01'),
},
];
/**
* Service managing per-user dashboard layouts and widget instances.
*
* Layout (position/size) and widget config are stored in separate models
* to avoid unnecessary saves when only one changes (RESEARCH anti-pattern).
*
* All operations are scoped by userId for security (T-05-01, T-05-02) — the
* 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` 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 {
constructor(
private readonly prisma: PrismaService,
private readonly moduleAccessService: ModuleAccessService,
) {}
/**
* Returns the user's saved layout, or a default empty layout
* with all breakpoint arrays initialized.
*/
async getLayout(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const record = await tenantPrisma.dashboardLayout.findUnique({
where: { userId },
});
if (!record) {
return { lg: [], md: [], sm: [], xs: [], xxs: [] };
}
return record.layouts;
}
/**
* Upserts the user's dashboard layout.
* Creates a new record if none exists, updates if it does.
*
* `userId` is platform-wide `@unique` (no tenant component) — a tenant
* whose user id was, by hand, moved off its actually-visible row could hit
* an `upsert` conflict on a row it cannot see under RLS. Measured
* (260910-krx, Aufgabe 1): a bound conflicting upsert against such a row
* throws `Prisma.PrismaClientUnknownRequestError` (NOT the `P2002` known
* error that the `tenders` area's translation pattern catches — this is a
* different Prisma error class, `.code`/`.meta` are `undefined`, the only
* signal is the raw `.message` text). Translated below into an
* understandable German message instead of a raw 500, same intent as
* `tender-notification-pref.service.ts`, different detection. Not
* reachable via any application path today (a user's tenant id never
* changes after creation) — the honest fix is a schema change and is
* 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, userId);
try {
return await tenantPrisma.dashboardLayout.upsert({
where: { userId },
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
create: {
userId,
tenantId,
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
throw new ConflictException(
'Die Dashboard-Anordnung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
);
}
throw error;
}
}
/**
* Returns all widget instances for a given user, gefiltert um Widgets
* eines für den Benutzer gesperrten Moduls (D-22, PERM-07).
*
* Die bestehende Query bleibt unverändert die erste Aktion. Steht unter
* den geladenen Widgets kein einziger Typ in `WIDGET_MODULE_MAP` — der
* Zustand am Ende dieser Phase, weil die Tabelle leer ist — wird die
* Liste unverändert zurückgegeben, ohne einen Zugriffs-Lookup. Nur bei
* mindestens einem modulgebundenen Widget wird die Zugriffsauflösung
* aus 15-01 einmal aufgerufen (D-01: dieselbe Auflösung wie Guard und
* Sidebar, keine zweite Implementierung). Lässt sich ein eingetragener
* Modul-Slug nicht auf einen `Module`-Datensatz auflösen, wird das
* betroffene Widget entfernt (Fail-Closed).
*/
async getWidgets(userId: string, tenantId: string, role: Role) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widgets = await tenantPrisma.widgetInstance.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
});
const boundSlugs = [
...new Set(
widgets
.map((w) => getModuleSlugForWidgetType(w.widgetType))
.filter((slug): slug is string => slug !== undefined),
),
];
if (boundSlugs.length === 0) {
return widgets;
}
// getAccessibleModuleIds() already binds internally (260910-exd,
// module-access.service.ts) — do NOT wrap it a second time here.
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
tenantId,
userId,
role,
);
// Module catalogue: deliberately left UNBOUND — see the reasoning at
// the bottom of this file (260910-krx, Aufgabe 3).
const modules = await this.prisma.module.findMany({
where: { slug: { in: boundSlugs } },
select: { id: true, slug: true },
});
const slugToModuleId = new Map(modules.map((m) => [m.slug, m.id]));
return widgets.filter((w) => {
const slug = getModuleSlugForWidgetType(w.widgetType);
if (slug === undefined) {
return true;
}
const moduleId = slugToModuleId.get(slug);
if (moduleId === undefined) {
return false;
}
return accessibleModuleIds.has(moduleId);
});
}
/**
* Creates a new widget instance for the user.
*/
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.widgetInstance.create({
data: {
userId,
tenantId,
widgetType: dto.widgetType,
config: (dto.config ?? {}) as unknown as Prisma.InputJsonValue,
},
});
}
/**
* Updates the config of a widget instance.
* Verifies ownership by userId before updating (T-05-01) — REAL, not
* decorative (unlike the `ldap`/`dkv` findUnique-then-write shape that
* produced this effort's first two vulnerabilities): `widget.userId !==
* userId` genuinely compares against the session-sourced user id and
* subsumes the tenant dimension. Both queries below run over the SAME
* bound client and the same tenant id — reading and writing are never
* split across the binding, or the check could pass on a row the write no
* longer sees, or vice versa (260910-krx, Aufgabe 1, Befund D).
*/
async updateWidgetConfig(
id: string,
userId: string,
tenantId: string,
dto: UpdateWidgetConfigDto,
) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id },
});
if (!widget || widget.userId !== userId) {
throw new NotFoundException(
`Widget with id '${id}' not found`,
);
}
// Merge existing config with new config
const mergedConfig = {
...(widget.config as Record<string, unknown>),
...dto.config,
};
return tenantPrisma.widgetInstance.update({
where: { id },
data: { config: mergedConfig as unknown as Prisma.InputJsonValue },
});
}
/**
* Removes a widget instance.
* Verifies ownership by userId before deleting (T-05-01) — same real
* ownership check as `updateWidgetConfig` above, same reasoning: both
* queries run over the SAME bound client and tenant id.
*/
async removeWidget(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const widget = await tenantPrisma.widgetInstance.findUnique({
where: { id },
});
if (!widget || widget.userId !== userId) {
throw new NotFoundException(
`Widget with id '${id}' not found`,
);
}
return tenantPrisma.widgetInstance.delete({
where: { id },
});
}
// --- Search Providers (05-02, D-15) ---
/**
* Returns the three default providers merged with any user-custom providers.
* Defaults are always returned even with an empty DB (no seed migration needed).
* The three defaults come from the TypeScript constant above (decision
* 05-02), never from the database — they are unaffected by the binding
* below and are always prepended unchanged.
*/
async getSearchProviders(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const custom = await tenantPrisma.searchProvider.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
});
return [...DEFAULT_SEARCH_PROVIDERS, ...custom];
}
/**
* Creates a user-custom search provider. `tenantId` stays a required
* parameter of this method — the only write path this model has (260910-krx,
* Aufgabe 1, Befund F, WINDOWS #19): no application path exists that
* creates a tenant-less row, which is why the RLS rule on `SearchProvider`
* was deliberately left unchanged/strict in migration 20260910120000.
*/
async addSearchProvider(
userId: string,
tenantId: string,
dto: CreateSearchProviderDto,
) {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
return tenantPrisma.searchProvider.create({
data: {
userId,
tenantId,
name: dto.name,
urlTemplate: dto.urlTemplate,
isDefault: false,
},
});
}
/**
* Removes a user-custom search provider.
* Verifies ownership — default providers (userId null) cannot be deleted
* (T-05-07) — REAL, same reasoning as `updateWidgetConfig`/`removeWidget`
* 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, userId);
// Default providers have hardcoded IDs that won't exist in DB
const provider = await tenantPrisma.searchProvider.findUnique({
where: { id },
});
if (!provider || provider.userId !== userId) {
throw new NotFoundException(
`Search provider with id '${id}' not found`,
);
}
return tenantPrisma.searchProvider.delete({
where: { id },
});
}
}
// --- Modulkatalog: bewusst ungebunden (260910-krx, Aufgabe 3) --------------
//
// Der eine verbleibende ungebundene Modellzugriff dieser Datei (das
// `module`-Modell in `getWidgets`, ueber den ungebundenen Basisclient)
// betrifft den plattformweiten Modulkatalog (`Module`).
// MESSUNG (rls-scratch-check.mjs, Pruefung `module-tabelle-traegt-keinen-
// zeilenschutz`, uebernommen aus dem Bereich `module-registry`, 260910-exd
// Befund E): die Tabelle traegt heute KEINEN Zeilenschutz — `pg_class.
// relrowsecurity` ist `false`, eine Bindung waere heute WIRKUNGSLOS, nicht
// katastrophal. BEDINGUNG: sie wuerde katastrophal, WENN Etappe 3 dieser
// Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer jeden
// Mandanten. Die Katalogaufloesung, die dieser Dienst fuer den Widget-
// Modulfilter aufruft (`ModuleAccessService.getAccessibleModuleIds`), bindet
// bereits seit 260910-exd in ihrem eigenen Dienst — dieser Zugriff wird hier
// NICHT ein zweites Mal gebunden.