feat(quick-260910-krx): Anordnung und Widgets an forTenant() gebunden
Aufgabe 2 — TDD zuerst (20 Faelle in dashboard.service.spec.ts, 8 vorher/12 neu, Zwei-Klienten-Nachweis ueber __makeBoundClient nach dem Muster von module-access.service.spec.ts), dann die Umstellung: - getLayout/saveLayout laufen GEMEINSAM gebunden (ein Testfall nagelt das fest); saveLayout uebersetzt eine gebundene Konflikt-Schreibung (PrismaClientUnknownRequestError, gemessen in Aufgabe 1 — NICHT P2002) in eine deutsche ConflictException. - getWidgets/addWidget/updateWidgetConfig/removeWidget laufen gebunden; die drei Besitzpruefungen ueber die Benutzerkennung bleiben unveraendert bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). updateWidgetConfig/removeWidget fuehren Besitzpruefung UND Schreibzugriff ueber DENSELBEN gebundenen Klienten. - dashboard.controller.ts reicht den bereits aufgeloesten Mandanten bei getLayout/updateWidgetConfig/removeWidget durch (keine neue Vertrauensquelle, weiterhin aus extractContext/Sitzungsnachweis). - Der Modulkatalog und die vier Suchmaschinenzugriffe bleiben in dieser Aufgabe unveraendert (Aufgabe 3). - Falsifizierungsnachweis durchgefuehrt: tenantPrisma.widgetInstance.delete probeweise auf this.prisma zurueckgebaut — Test "Widget entfernen: ebenso, beide Abfragen ueber denselben Klienten" wird rot mit "erwarteter gebundener Aufruf widgetInstance.delete(tenant=tenant-1) fehlt im Protokoll"; Rueckbau zurueckgenommen, Testlauf wieder gruen (20/20). Zwei dokumentierte Abweichungen (Rule 3): (1) Befund A hatte fuer widgetInstance sieben Treffer vorhergesagt, gemessen sind sechs (macht zusammen mit dashboardLayout acht statt neun) — der Verify-Schwellwert wird entsprechend auf >=8 gelesen. (2) Die Stand-Spalte fuer dashboardLayout/widgetInstance in der Klassifikationsdatei wird bereits hier minimal nachgezogen (nicht erst in Aufgabe 3), weil rls-access-inventory.spec.ts sonst am Ende dieser Aufgabe rot waere — derselbe Praezedenzfall wie 260910-exd, Aufgabe 2. Baseline gehalten: 851 Tests / 56 Dateien gruen (839 + 12 neue), Typpruefung sauber, Wegwerf-Werkzeug 87/87.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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';
|
||||
@@ -52,7 +54,16 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
||||
* 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).
|
||||
* 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 {
|
||||
@@ -65,8 +76,9 @@ export class DashboardService {
|
||||
* Returns the user's saved layout, or a default empty layout
|
||||
* with all breakpoint arrays initialized.
|
||||
*/
|
||||
async getLayout(userId: string) {
|
||||
const record = await this.prisma.dashboardLayout.findUnique({
|
||||
async getLayout(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const record = await tenantPrisma.dashboardLayout.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
@@ -80,17 +92,41 @@ export class DashboardService {
|
||||
/**
|
||||
* 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) {
|
||||
return this.prisma.dashboardLayout.upsert({
|
||||
where: { userId },
|
||||
update: { layouts: dto.layouts as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
userId,
|
||||
tenantId,
|
||||
layouts: dto.layouts as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +144,8 @@ export class DashboardService {
|
||||
* betroffene Widget entfernt (Fail-Closed).
|
||||
*/
|
||||
async getWidgets(userId: string, tenantId: string, role: Role) {
|
||||
const widgets = await this.prisma.widgetInstance.findMany({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const widgets = await tenantPrisma.widgetInstance.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
@@ -125,12 +162,16 @@ export class DashboardService {
|
||||
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 },
|
||||
@@ -154,7 +195,8 @@ export class DashboardService {
|
||||
* Creates a new widget instance for the user.
|
||||
*/
|
||||
async addWidget(userId: string, tenantId: string, dto: CreateWidgetDto) {
|
||||
return this.prisma.widgetInstance.create({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.widgetInstance.create({
|
||||
data: {
|
||||
userId,
|
||||
tenantId,
|
||||
@@ -166,14 +208,23 @@ export class DashboardService {
|
||||
|
||||
/**
|
||||
* Updates the config of a widget instance.
|
||||
* Verifies ownership by userId before updating (T-05-01).
|
||||
* 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 widget = await this.prisma.widgetInstance.findUnique({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
@@ -189,7 +240,7 @@ export class DashboardService {
|
||||
...dto.config,
|
||||
};
|
||||
|
||||
return this.prisma.widgetInstance.update({
|
||||
return tenantPrisma.widgetInstance.update({
|
||||
where: { id },
|
||||
data: { config: mergedConfig as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
@@ -197,10 +248,13 @@ export class DashboardService {
|
||||
|
||||
/**
|
||||
* Removes a widget instance.
|
||||
* Verifies ownership by userId before deleting (T-05-01).
|
||||
* 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) {
|
||||
const widget = await this.prisma.widgetInstance.findUnique({
|
||||
async removeWidget(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
@@ -210,7 +264,7 @@ export class DashboardService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.widgetInstance.delete({
|
||||
return tenantPrisma.widgetInstance.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user