feat(quick-260911-cwh): Bereich calendar binden — alle 12 Zugriffe ueber forTenant() (Aufgabe 2)

- calendar.service.spec.ts: NEU, Zwei-Klienten-Nachweis ueber __makeBoundClient
  (Muster dkv.service.spec.ts), Attrappen fuer CryptoService und die drei
  Provider, 23 Testfaelle: getSources/addSource, updateSource inkl. drei
  Erhaltungsfaelle, alle drei Besitzpruefungen je Ausnahmeart, testConnection
  Erfolgs-/Fehlerpfad, aggregateEvents/fetchAndCacheEvents inkl. beider
  Synchronstatus-Rueckschreibungen, Cache-Verhalten inkl. Nutzer-Trennung,
  testConnectionFromConfig ohne DB-Zugriff, Wachhund fuer genau einen
  gebundenen Klienten je Aufruf
- calendar.service.ts: alle 12 Zugriffe auf forTenant() umgestellt, ein
  tenantPrisma-Klient je Methode (getSources, addSource, updateSource,
  deleteSource, testConnection, fetchAndCacheEvents); Cache-Schluessel-Urteil
  und die kein-sechster-Hintergrunddienst-Begruendung als Kommentare
  festgehalten
- calendar.controller.ts: alle sechs kontextnutzenden Handler reichen
  Benutzer- UND Mandantenkennung aus extractContext durch, keine neue
  Vertrauensquelle
- Falsifizierungsnachweis durchgefuehrt: eine Rueckschreibung testweise
  entbunden, benannter Test ging rot ("aggregateEvents, Erfolgspfad"), Fund
  bestaetigt, zurueckgenommen
- docs/mandantentrennung-zugriffsklassifikation.md: Bestandsaufnahme-Zeile
  calendar.service.ts/calendarSource auf gebunden gezogen

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 09:55:30 +02:00
parent bf5fc4d4c7
commit 77cb124f59
4 changed files with 649 additions and 32 deletions
+72 -21
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CryptoService } from '../crypto/crypto.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
@@ -98,14 +99,47 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
/**
* Service for calendar source CRUD and event aggregation.
*
* Source config is per-user (D-09), not per-tenant.
* Source config is per-user AND per-tenant bound (Mandantentrennung Etappe
* 2, 260911-cwh) — `CalendarSource` carries a mandatory `tenantId`
* (D-09/T-05-*: per-user ownership; row-level security: per-tenant
* isolation). Every method with database access binds its own
* `tenantPrisma` via `forTenant()`.
*
* The three ownership checks (`updateSource`/`deleteSource`/
* `testConnection`, comparing `existing.userId` against the calling user)
* are kept UNCHANGED alongside the binding, not replaced by it: the RLS
* policy on `CalendarSource` carries no user dimension (measured
* 260911-cwh, Aufgabe 1 — `calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`),
* so a colleague of the SAME tenant would otherwise see and modify a
* fellow user's encrypted Exchange/CalDAV credentials. Until the RLS
* policy itself gains a user dimension (Etappe-3-Entscheidung (2)), these
* application-level checks remain the only protection between users of the
* same tenant.
*
* Credentials encrypted at rest via CryptoService (T-05-10).
*/
@Injectable()
export class CalendarService {
private readonly logger = new Logger(CalendarService.name);
/** Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`. */
/**
* Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`.
*
* Cache-key judgment (260911-cwh, Aufgabe 1, Befund F — chain checked
* link by link at execution time): `userId` here is `User.id`
* (`apps/api/prisma/schema.prisma`, `model User`, `@id @default(uuid())`),
* reached via `calendar.controller.ts` `extractContext()`
* (`req.user?.id`), which is `JwtStrategy.validate()`'s `id: payload.sub`
* (`apps/api/src/auth/strategies/jwt.strategy.ts`), which is
* `sub: user.id` at token-issue time (`apps/api/src/auth/auth.service.ts`,
* lines 143/332) — the database identity, not a login name. The
* Etappe-3-Entscheidung (1) (tenant-scoped uniqueness for
* `User.username`/`User.email`) does NOT touch `User.id`, which remains
* a platform-wide UUID no tenant can share. The key therefore stays
* without a tenant component. If any link of this chain changes, the key
* needs a tenant component — the decision follows the measurement, not
* this comment.
*/
private readonly eventCache = new Map<string, CacheEntry>();
constructor(
@@ -120,8 +154,9 @@ export class CalendarService {
* Returns all calendar sources for a user WITHOUT encryptedPassword.
* Adds a `hasCredentials` boolean so the UI knows if credentials are set.
*/
async getSources(userId: string) {
const sources = await this.prisma.calendarSource.findMany({
async getSources(userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const sources = await tenantPrisma.calendarSource.findMany({
where: { userId },
select: {
...SOURCE_SAFE_SELECT,
@@ -160,7 +195,8 @@ export class CalendarService {
data.encryptedPassword = this.crypto.encrypt(dto.password);
}
const created = await this.prisma.calendarSource.create({
const tenantPrisma = forTenant(this.prisma, tenantId);
const created = await tenantPrisma.calendarSource.create({
data: data as any,
select: SOURCE_SAFE_SELECT,
});
@@ -172,8 +208,9 @@ export class CalendarService {
* Updates a calendar source. Ownership check ensures user can only modify their own sources.
* Re-encrypts password if provided; T-05-12 ownership enforcement.
*/
async updateSource(id: string, userId: string, dto: UpdateCalendarSourceDto) {
const existing = await this.prisma.calendarSource.findUnique({
async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.calendarSource.findUnique({
where: { id },
select: { userId: true, type: true },
});
@@ -207,7 +244,7 @@ export class CalendarService {
: null;
}
const updated = await this.prisma.calendarSource.update({
const updated = await tenantPrisma.calendarSource.update({
where: { id },
data: data as any,
select: {
@@ -223,8 +260,9 @@ export class CalendarService {
/**
* Deletes a calendar source. Ownership check enforced (T-05-12).
*/
async deleteSource(id: string, userId: string) {
const existing = await this.prisma.calendarSource.findUnique({
async deleteSource(id: string, userId: string, tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.calendarSource.findUnique({
where: { id },
select: { userId: true },
});
@@ -236,7 +274,7 @@ export class CalendarService {
throw new ForbiddenException('Not your calendar source');
}
await this.prisma.calendarSource.delete({ where: { id } });
await tenantPrisma.calendarSource.delete({ where: { id } });
return { deleted: true };
}
@@ -244,8 +282,9 @@ export class CalendarService {
* Test connection to a calendar source via its provider.
* Updates lastSyncAt/lastSyncError on the source record.
*/
async testConnection(id: string, userId: string): Promise<{ success: boolean; error?: string }> {
const source = await this.prisma.calendarSource.findUnique({ where: { id } });
async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const source = await tenantPrisma.calendarSource.findUnique({ where: { id } });
if (!source) throw new NotFoundException('Calendar source not found');
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source');
@@ -264,7 +303,7 @@ export class CalendarService {
try {
const success = await provider.testConnection(decryptedSource);
await this.prisma.calendarSource.update({
await tenantPrisma.calendarSource.update({
where: { id },
data: {
lastSyncAt: success ? new Date() : undefined,
@@ -275,7 +314,7 @@ export class CalendarService {
return { success };
} catch (error) {
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
await this.prisma.calendarSource.update({
await tenantPrisma.calendarSource.update({
where: { id },
data: { lastSyncError: errorMsg },
});
@@ -326,6 +365,7 @@ export class CalendarService {
*/
async aggregateEvents(
userId: string,
tenantId: string,
from?: string,
to?: string,
): Promise<CalendarEvent[]> {
@@ -339,13 +379,13 @@ export class CalendarService {
if (cached && cached.expiresAt > Date.now()) {
// Serve cached immediately, trigger background refresh if close to expiry
if (cached.expiresAt - Date.now() < CACHE_TTL_MS / 2) {
this.refreshCacheInBackground(userId, fromDate, toDate, cacheKey);
this.refreshCacheInBackground(userId, tenantId, fromDate, toDate, cacheKey);
}
return cached.events;
}
// Fetch fresh
const events = await this.fetchAndCacheEvents(userId, fromDate, toDate, cacheKey);
const events = await this.fetchAndCacheEvents(userId, tenantId, fromDate, toDate, cacheKey);
return events;
}
@@ -354,11 +394,13 @@ export class CalendarService {
*/
private async fetchAndCacheEvents(
userId: string,
tenantId: string,
from: Date,
to: Date,
cacheKey: string,
): Promise<CalendarEvent[]> {
const sources = await this.prisma.calendarSource.findMany({
const tenantPrisma = forTenant(this.prisma, tenantId);
const sources = await tenantPrisma.calendarSource.findMany({
where: { userId, isVisible: true },
});
@@ -384,7 +426,7 @@ export class CalendarService {
const events = await provider.fetchEvents(decryptedSource, from, to);
// Update sync status on success
await this.prisma.calendarSource.update({
await tenantPrisma.calendarSource.update({
where: { id: source.id },
data: { lastSyncAt: new Date(), lastSyncError: null },
});
@@ -395,7 +437,7 @@ export class CalendarService {
this.logger.warn(
`Failed to fetch events from source ${source.id} (${source.type}): ${(error as Error).message}`,
);
await this.prisma.calendarSource.update({
await tenantPrisma.calendarSource.update({
where: { id: source.id },
data: { lastSyncError: 'Event fetch failed' },
});
@@ -426,14 +468,23 @@ export class CalendarService {
/**
* Refreshes cache in the background without blocking the response.
*
* This is a request context that outlives the request (Befund B,
* 260911-cwh): it is NOT the "read across tenants, then bind per tenant"
* shape of the background-service section in
* docs/mandantentrennung-zugriffsklassifikation.md — it carries the
* tenant of the ORIGINAL request that triggered it (`aggregateEvents`)
* and cannot have any other tenant, because it never reads across
* tenants in the first place.
*/
private refreshCacheInBackground(
userId: string,
tenantId: string,
from: Date,
to: Date,
cacheKey: string,
): void {
this.fetchAndCacheEvents(userId, from, to, cacheKey).catch((error) => {
this.fetchAndCacheEvents(userId, tenantId, from, to, cacheKey).catch((error) => {
this.logger.warn(`Background cache refresh failed: ${(error as Error).message}`);
});
}