feat(15-05): getWidgets filters via ModuleAccessService (D-22, PERM-07)

- DashboardModule imports ModuleRegistryModule to inject ModuleAccessService
- getWidgets(userId, tenantId, role) runs the existing findMany unchanged
  first, then calls getAccessibleModuleIds exactly once — only if a loaded
  widget's type is in WIDGET_MODULE_MAP (currently always empty, so no
  lookup runs today); unresolved module slugs fail closed
- DashboardController.getWidgets forwards tenantId + role from the JWT
- dashboard.service.spec.ts (8 tests, TDD-GREEN): covers every <behavior>
  case incl. D-03 ADMIN bypass, adjacency/empty/ordering/idempotency, and
  fail-closed on an unresolved Module slug
- pnpm --filter @tessera/api test: 457/457 green; type-check clean
- manual e2e against local API + DB container: empty WIDGET_MODULE_MAP
  leaves an existing user's widget count unchanged (2/2 clock+search
  survived the filter), throwaway verification user/rows removed after
This commit is contained in:
2026-08-04 15:37:28 +02:00
parent d0ff6f0bc0
commit 0ff46acd75
4 changed files with 71 additions and 10 deletions
+57 -5
View File
@@ -2,12 +2,14 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Prisma, Role } from '@prisma/client';
import { ModuleAccessService } from '../module-registry/module-access.service';
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).
@@ -54,7 +56,10 @@ const DEFAULT_SEARCH_PROVIDERS = [
*/
@Injectable()
export class DashboardService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly moduleAccessService: ModuleAccessService,
) {}
/**
* Returns the user's saved layout, or a default empty layout
@@ -89,13 +94,60 @@ export class DashboardService {
}
/**
* Returns all widget instances for a given user.
* 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) {
return this.prisma.widgetInstance.findMany({
async getWidgets(userId: string, tenantId: string, role: Role) {
const widgets = await this.prisma.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;
}
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
tenantId,
userId,
role,
);
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);
});
}
/**