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:
@@ -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}`);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user