67b50240d6
Aufgabe 3 — TDD zuerst (7 weitere Faelle in dashboard.service.spec.ts, 20 vorher/27 nach dieser Aufgabe), dann die Umstellung: - getSearchProviders/addSearchProvider/removeSearchProvider laufen ueber forTenant(); removeSearchProvider fuehrt Besitzpruefung UND Schreibzugriff ueber DENSELBEN gebundenen Klienten. Die drei Vorgabe-Suchmaschinen aus der Konstante bleiben unveraendert vorangestellt. - Der eine Katalogzugriff (this.prisma.module in getWidgets) bleibt begruendet ungebunden: Messung und Bedingung getrennt (Tabelle traegt heute keinen Zeilenschutz, wirkungslos statt katastrophal — katastrophal erst, wenn Etappe 3 eine Regel gibt), unter Berufung auf die bestehende Werkzeugpruefung module-tabelle-traegt-keinen-zeilenschutz statt einer neuen Behauptung. Ein Wachhund-Testfall haelt den Katalogzugriff aus dem Bindungsprotokoll heraus (und beweist zuerst, dass der Katalogpfad tatsaechlich durchlaufen wird, nicht nur theoretisch geprueft ist). - docs/mandantentrennung-zugriffsklassifikation.md an allen fuenf handgepflegten Stellen nachgezogen: vier Bestandsaufnahme-Zeilen (inkl. eigenstaendiger Nachpruefung der widerlegten SearchProvider-Praemisse), Uebersichtszeile (13/0 -> 1/12), Summenzeile (95/147), Klassen-Verteilung (unveraendert 63 Paare, ausdruecklich vermerkt), Hintergrunddienst- Abschnitt (dashboard hat keinen sechsten Fall, mit Messanweisung), "Was diese Etappe NICHT entscheidet" (dienst-interner forTenant()-Weg wie alle sieben Bereiche vor ihm). - .planning/WINDOWS.md traegt Eintrag #25 (offen, Tabelle + JSON): die beweisvernichtende Schleife (leeres Dashboard -> Neuaufbau -> automatisches Zurueckschreiben -> ueberschriebene Anordnung, Widget- Dubletten) samt der Vorabpruefung fuer Etappe 4 und dem Verweis auf #22 fuer die verwandte Eindeutigkeitsfrage. Zwei weitere Falsifizierungsnachweise durchgefuehrt: (1) den Katalogzugriff probeweise gebunden (tenantPrisma.module.findMany) — acht Tests werden rot mit "TypeError: Cannot read properties of undefined (reading 'findMany')", weil `module` bewusst nicht in der Testdouble-Bindungsliste steht; Rueckbau zurueckgenommen, 27/27 wieder gruen. (2) den Stand von dashboardLayout in der Klassifikationsdatei probeweise auf "ungebunden" gesetzt — rls-access-inventory.spec.ts wird rot mit "Abweichender Stand (Dokument vs. Quelltext): ... dokumentiert=ungebunden, gemessen=gebunden"; Ruecknahme, Testlauf wieder gruen (10/10). Baseline gehalten: 858 Tests / 56 Dateien gruen, Typpruefung sauber, Wegwerf-Werkzeug 87/87. Schalter bleibt aus.
355 lines
13 KiB
TypeScript
355 lines
13 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` know only the tenant dimension, not the user dimension
|
|
* (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.
|
|
*/
|
|
@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);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
// 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.
|