diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index d251003..c25c349 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -165,11 +165,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set(['apps/api/src/tenders/backfill * systemgebundenem Startpfad": `ldap-config.service.ts`, dessen * Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist. * Summe neu: 5 Dateien, 6 Aufrufe. + * + * quick-260923-dhh (Aufgabe 4): eine sechste Datei kommt hinzu — + * `proxmox.service.ts`/`loadActiveServersForScheduler()`, derselbe + * Startpfad-Fall wie `dkv.service.ts`: der Planer liest beim Start ALLE + * aktiven `ProxmoxServer`-Zeilen aller Mandanten (`system_read_policy` auf + * `ProxmoxServer`, Migration 20260923140000), registriert je Mandant einen + * Cron-Auftrag, und schreibt danach ausschliesslich je Zeile gebunden ueber + * `forTenant()`. Summe neu: 6 Dateien, 7 Aufrufe. */ const FORSYSTEM_ALLOWED_CALL_SITES = new Map([ ['apps/api/src/dashboard/dashboard-images.service.ts', 1], ['apps/api/src/dkv/dkv.service.ts', 1], ['apps/api/src/ldap/ldap-config.service.ts', 2], + ['apps/api/src/proxmox/proxmox.service.ts', 1], ['apps/api/src/tenders/tender-digest.scheduler.ts', 1], ['apps/api/src/tenders/tender-matching.service.ts', 1], ]); diff --git a/apps/api/src/proxmox/proxmox-scheduler.service.spec.ts b/apps/api/src/proxmox/proxmox-scheduler.service.spec.ts new file mode 100644 index 0000000..53efb86 --- /dev/null +++ b/apps/api/src/proxmox/proxmox-scheduler.service.spec.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ProxmoxSchedulerService } from './proxmox-scheduler.service'; + +/** + * ProxmoxSchedulerService.spec (Aufgabe 4) — Vorbild + * `dkv-scheduler.service.spec.ts`: echte Fake-Registry (Map-basiert, + * `getCronJob` wirft bei Unbekannt wie `@nestjs/schedule`), ECHTES `cron` + * (Peer von `@nestjs/schedule`) — `cronTime.source` und `fireOnTick()` + * sind die beobachtbaren Eigenschaften eines Auftrags. + */ + +function makeFakeRegistry() { + // biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe + const jobs = new Map(); + return { + __jobs: jobs, + addCronJob: vi.fn((name: string, job: any) => { + if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`); + jobs.set(name, job); + }), + getCronJob: vi.fn((name: string) => { + const job = jobs.get(name); + if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`); + return job; + }), + deleteCronJob: vi.fn((name: string) => { + const job = jobs.get(name); + if (!job) throw new Error(`No Cron Job was found with the given name (${name}).`); + jobs.delete(name); + }), + getCronJobs: vi.fn(() => jobs), + }; +} + +interface FakeServerRow { + id: string; + tenantId: string; + pollIntervalMin: number; + isActive: boolean; +} + +function makeFakeProxmoxService( + servers: FakeServerRow[] | Error, + options: { pollShouldThrowFor?: string[] } = {}, +) { + const polledServerIds: string[] = []; + return { + loadActiveServersForScheduler: vi.fn(async () => { + if (servers instanceof Error) throw servers; + return servers.filter((s) => s.isActive).map((s) => ({ + id: s.id, + tenantId: s.tenantId, + pollIntervalMin: s.pollIntervalMin, + })); + }), + loadActiveServersForTenantScheduling: vi.fn(async (tenantId: string) => { + if (servers instanceof Error) return []; + return servers + .filter((s) => s.isActive && s.tenantId === tenantId) + .map((s) => ({ pollIntervalMin: s.pollIntervalMin })); + }), + listActiveServerIdsForTenant: vi.fn(async (tenantId: string) => { + if (servers instanceof Error) return []; + return servers.filter((s) => s.isActive && s.tenantId === tenantId).map((s) => s.id); + }), + pollServer: vi.fn(async (_tenantId: string, serverId: string) => { + polledServerIds.push(serverId); + if (options.pollShouldThrowFor?.includes(serverId)) { + throw new Error(`poll boom for ${serverId}`); + } + return { reachable: true, errorKind: null, errorDetail: null, metrics: null, rawSample: null }; + }), + __polledServerIds: polledServerIds, + }; +} + +function makeScheduler( + servers: FakeServerRow[] | Error, + options: { pollShouldThrowFor?: string[] } = {}, +) { + const registry = makeFakeRegistry(); + const proxmoxService = makeFakeProxmoxService(servers, options); + const scheduler = new ProxmoxSchedulerService(registry as any, proxmoxService as any); + const logSpy = vi.spyOn((scheduler as any).logger, 'log').mockImplementation(() => undefined); + const errorSpy = vi.spyOn((scheduler as any).logger, 'error').mockImplementation(() => undefined); + return { registry, proxmoxService, scheduler, logSpy, errorSpy }; +} + +describe('ProxmoxSchedulerService — ein Auftrag je Mandant (Aufgabe 4, )', () => { + const registries: ReturnType[] = []; + + afterEach(() => { + for (const registry of registries) { + for (const job of registry.__jobs.values()) job.stop(); + registry.__jobs.clear(); + } + registries.length = 0; + vi.restoreAllMocks(); + }); + + it('Beim Start registriert der Planer je Mandant mit mindestens einem aktiven Server genau einen Auftrag unter proxmox-poll:', async () => { + const { registry, scheduler } = makeScheduler([ + { id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true }, + ]); + registries.push(registry); + + await scheduler.onApplicationBootstrap(); + + expect([...registry.__jobs.keys()]).toEqual(['proxmox-poll:t1']); + expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/15 * * * *'); + }); + + it('Das Abfrageintervall eines Mandanten ist das KLEINSTE pollIntervalMin seiner aktiven Server', async () => { + const { registry, scheduler } = makeScheduler([ + { id: 's1', tenantId: 't1', pollIntervalMin: 30, isActive: true }, + { id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true }, + ]); + registries.push(registry); + + await scheduler.onApplicationBootstrap(); + + expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/5 * * * *'); + }); + + it('Ein zweiter Mandant verdraengt den Auftrag des ersten nicht — beide Auftraege bestehen nebeneinander', async () => { + const { registry, scheduler } = makeScheduler([ + { id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true }, + { id: 's2', tenantId: 't2', pollIntervalMin: 10, isActive: true }, + ]); + registries.push(registry); + + await scheduler.onApplicationBootstrap(); + + expect(new Set(registry.__jobs.keys())).toEqual(new Set(['proxmox-poll:t1', 'proxmox-poll:t2'])); + }); + + it('Keine aktiven Server bedeutet: kein Auftrag, ein Protokolleintrag, kein Fehler, nichts geloescht', async () => { + const { registry, scheduler, logSpy, errorSpy } = makeScheduler([]); + registries.push(registry); + + await scheduler.onApplicationBootstrap(); + + expect(registry.__jobs.size).toBe(0); + expect(logSpy).toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('Ein Fehler beim Start wird gefangen und protokolliert, nie weitergeworfen', async () => { + const { scheduler, errorSpy } = makeScheduler(new Error('DB weg')); + + await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('Der Planer haengt an onApplicationBootstrap, nicht an onModuleInit', () => { + const registry = makeFakeRegistry(); + const scheduler = new ProxmoxSchedulerService(registry as any, {} as any); + expect(typeof (scheduler as unknown as { onApplicationBootstrap?: unknown }).onApplicationBootstrap).toBe( + 'function', + ); + expect((scheduler as unknown as { onModuleInit?: unknown }).onModuleInit).toBeUndefined(); + }); + + it('Der Tick eines Mandanten geht ueber dessen Server und fragt jeden einzeln ab; ein fehlgeschlagener Server bricht die Schleife nicht ab', async () => { + const { registry, scheduler, proxmoxService, errorSpy } = makeScheduler( + [ + { id: 's1', tenantId: 't1', pollIntervalMin: 5, isActive: true }, + { id: 's2', tenantId: 't1', pollIntervalMin: 5, isActive: true }, + ], + { pollShouldThrowFor: ['s1'] }, + ); + registries.push(registry); + await scheduler.onApplicationBootstrap(); + + registry.__jobs.get('proxmox-poll:t1').fireOnTick(); + await new Promise((resolve) => setImmediate(resolve)); + + expect((proxmoxService as any).__polledServerIds).toEqual(['s1', 's2']); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('refreshTenant zieht den Auftrag eines Mandanten sofort nach — ohne Neustart', async () => { + const { registry, scheduler } = makeScheduler([]); + registries.push(registry); + await scheduler.onApplicationBootstrap(); + expect(registry.__jobs.size).toBe(0); + + (scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => [ + { pollIntervalMin: 20 }, + ]); + + await scheduler.refreshTenant('t1'); + + expect(registry.__jobs.get('proxmox-poll:t1').cronTime.source).toBe('*/20 * * * *'); + }); + + it('refreshTenant entfernt den Auftrag, wenn keine aktiven Server mehr uebrig sind', async () => { + const { registry, scheduler } = makeScheduler([ + { id: 's1', tenantId: 't1', pollIntervalMin: 15, isActive: true }, + ]); + registries.push(registry); + await scheduler.onApplicationBootstrap(); + expect(registry.__jobs.has('proxmox-poll:t1')).toBe(true); + + (scheduler as any).proxmoxService.loadActiveServersForTenantScheduling = vi.fn(async () => []); + await scheduler.refreshTenant('t1'); + + expect(registry.__jobs.has('proxmox-poll:t1')).toBe(false); + }); +}); diff --git a/apps/api/src/proxmox/proxmox-scheduler.service.ts b/apps/api/src/proxmox/proxmox-scheduler.service.ts new file mode 100644 index 0000000..a676398 --- /dev/null +++ b/apps/api/src/proxmox/proxmox-scheduler.service.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { ProxmoxService } from './proxmox.service'; + +/** + * CronJob constructor — resolved at runtime via require() because `cron` is + * a transitive dependency of @nestjs/schedule (not a direct api dep under + * pnpm strict isolation, so `import { CronJob } from 'cron'` fails + * type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares + * it as a peer dep. Reuses the exact DkvSchedulerService resolution + * workaround verbatim. + */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + require('cron').CronJob as new (cronTime: string, onTick: () => void) => { start(): void }; + +/** + * ProxmoxSchedulerService — Hintergrundabfrage je Mandant (Aufgabe 4). + * Kombiniert die zwei Bestandsmuster (Recherche, Block 3): + * + * - Das Mandanten-Auffaechern von `DkvSchedulerService`: EIN Cron-Auftrag + * je aktivem Mandanten, Registry-Name `proxmox-poll:` — die + * Vorgaengerform mit EINEM Auftragsfeld war genau der Fehler WINDOWS #21, + * ERSATZLOS vermieden. + * - Die Lebenszyklus-Stufe von `TenderSchedulerService`: + * `implements OnApplicationBootstrap`, NICHT `OnModuleInit` — die + * Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht + * festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis + * zum zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso. + * + * Anders als bei DKV ist ein Mandant NICHT gleich ein Server: der Tick + * eines Mandanten geht ueber dessen Serverzeilen. Das Abfrageintervall + * eines Mandanten ist das KLEINSTE `pollIntervalMin` seiner aktiven Server. + * Ein fehlgeschlagener Server schreibt seinen Fehler ins Zwischenlager + * (das erledigt `ProxmoxService.pollServer` bereits selbst — ein + * geworfener Fehler waere hier ein echter Bug, nicht ein "nicht + * erreichbar") und die Schleife laeuft weiter. + */ +@Injectable() +export class ProxmoxSchedulerService implements OnApplicationBootstrap { + private readonly logger = new Logger(ProxmoxSchedulerService.name); + + /** Praefix der Registry-Namen; der volle Name ist `:`. */ + private readonly JOB_NAME_PREFIX = 'proxmox-poll'; + + constructor( + private readonly schedulerRegistry: SchedulerRegistry, + private readonly proxmoxService: ProxmoxService, + ) {} + + private jobNameFor(tenantId: string): string { + return `${this.JOB_NAME_PREFIX}:${tenantId}`; + } + + /** + * Beim Start: laedt ALLE aktiven `ProxmoxServer`-Zeilen (Systemkontext, + * `ProxmoxService.loadActiveServersForScheduler`) und registriert je + * aktivem Mandanten genau einen Cron-Auftrag. Eine LEERE Liste bedeutet + * "nichts tun" — kein Auftrag, ein Protokolleintrag, kein Fehler, nichts + * geloescht. Ein Fehler beim Start wird gefangen und protokolliert, nie + * weitergeworfen — die Anwendung startet trotzdem. + */ + async onApplicationBootstrap(): Promise { + try { + const servers = await this.proxmoxService.loadActiveServersForScheduler(); + if (!servers || servers.length === 0) { + this.logger.log('Proxmox scheduler: no active server found — cron job not registered'); + return; + } + + const byTenant = new Map(); + for (const server of servers) { + const intervals = byTenant.get(server.tenantId) ?? []; + intervals.push(server.pollIntervalMin); + byTenant.set(server.tenantId, intervals); + } + + for (const [tenantId, intervals] of byTenant) { + this.setInterval(Math.min(...intervals), tenantId); + } + + this.logger.log(`Proxmox scheduler initialized: ${byTenant.size} tenant(s)`); + } catch (err) { + this.logger.error(`Proxmox scheduler init failed: ${(err as Error).message}`); + } + } + + /** + * Erzeugt (oder ersetzt) den Poll-Auftrag GENAU EINES Mandanten. Ersetzt + * nur den Auftrag unter diesem Registry-Namen — ein zweiter Mandant + * verdraengt den Auftrag des ersten nicht. + */ + setInterval(intervalMin: number, tenantId: string): void { + const jobName = this.jobNameFor(tenantId); + + try { + this.schedulerRegistry.getCronJob(jobName).stop(); + this.schedulerRegistry.deleteCronJob(jobName); + } catch { + /* Auftrag noch nicht registriert — beim ersten Aufruf erwartet */ + } + + let cronExpr: string; + if (intervalMin < 60) { + cronExpr = `*/${intervalMin} * * * *`; + } else { + const hours = Math.floor(intervalMin / 60); + cronExpr = `0 */${hours} * * *`; + } + + const job = new CronJobClass(cronExpr, () => { + this.tick(tenantId).catch((err) => + this.logger.error( + `Proxmox poll tick failed for tenant ${tenantId}: ${(err as Error).message}`, + ), + ); + }); + + // Cast noetig — dasselbe Muster wie DkvSchedulerService/TenderSchedulerService. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.schedulerRegistry.addCronJob(jobName, job as any); + job.start(); + + this.logger.log( + `Proxmox cron job registered: every ${intervalMin} minutes for tenant ${tenantId}`, + ); + } + + /** Entfernt NUR den Poll-Auftrag dieses Mandanten. */ + stopJob(tenantId: string): void { + const jobName = this.jobNameFor(tenantId); + try { + this.schedulerRegistry.getCronJob(jobName).stop(); + this.schedulerRegistry.deleteCronJob(jobName); + this.logger.log(`Proxmox cron job stopped and removed for tenant ${tenantId}`); + } catch { + /* Nicht registriert — kein Vorgang */ + } + } + + /** + * Vom Controller nach jedem Anlegen/Speichern eines Servers gerufen, damit + * der Planer ohne Neustart nachzieht (Vorbild `DkvController`). Ohne + * aktive Server dieses Mandanten wird der Auftrag entfernt. + */ + async refreshTenant(tenantId: string): Promise { + const servers = await this.proxmoxService.loadActiveServersForTenantScheduling(tenantId); + if (!servers || servers.length === 0) { + this.stopJob(tenantId); + return; + } + this.setInterval(Math.min(...servers.map((s) => s.pollIntervalMin)), tenantId); + } + + /** + * Der Tick EINES Mandanten: geht ueber dessen aktive Server und fragt + * jeden einzeln ab. `pollServer` faengt jeden Proxmox-seitigen Fehler + * bereits selbst ab (Ergebnis statt Wurf) — dieses try/catch schuetzt + * zusaetzlich vor einem echten Programmfehler (z. B. einem + * Datenbankfehler beim Schreiben), damit ein einzelner defekter Server + * die Abfrage der uebrigen Server desselben Mandanten nicht verhindert. + */ + private async tick(tenantId: string): Promise { + const serverIds = await this.proxmoxService.listActiveServerIdsForTenant(tenantId); + for (const serverId of serverIds) { + try { + await this.proxmoxService.pollServer(tenantId, serverId); + } catch (err) { + this.logger.error( + `Proxmox poll failed for server ${serverId} (tenant ${tenantId}): ${(err as Error).message}`, + ); + } + } + } + + /** + * Alle Mandanten, fuer die derzeit ein Auftrag registriert ist — aus der + * Registry abgeleitet, fuer Tests und Diagnose. + */ + registeredTenantIds(): string[] { + const prefix = `${this.JOB_NAME_PREFIX}:`; + const names = [...this.schedulerRegistry.getCronJobs().keys()] as string[]; + return names.filter((n) => n.startsWith(prefix)).map((n) => n.slice(prefix.length)); + } +} diff --git a/apps/api/src/proxmox/proxmox.controller.ts b/apps/api/src/proxmox/proxmox.controller.ts index 769f546..0b9d276 100644 --- a/apps/api/src/proxmox/proxmox.controller.ts +++ b/apps/api/src/proxmox/proxmox.controller.ts @@ -4,6 +4,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { UseModule } from '../module-registry/module.guard'; import { CreateProxmoxServerDto } from './dto/proxmox-server.dto'; +import { ProxmoxSchedulerService } from './proxmox-scheduler.service'; import { ProxmoxService } from './proxmox.service'; /** @@ -11,13 +12,16 @@ import { ProxmoxService } from './proxmox.service'; * `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt * ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus * Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit - * Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`) - * zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). + * Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`, + * `POST servers/:id/test`) zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). */ @Controller('modules/proxmox') @UseModule('proxmox') export class ProxmoxController { - constructor(private readonly proxmoxService: ProxmoxService) {} + constructor( + private readonly proxmoxService: ProxmoxService, + private readonly scheduler: ProxmoxSchedulerService, + ) {} private requireTenantId(req: AuthenticatedRequest): string { const tenantId = req.tenantId; @@ -35,7 +39,11 @@ export class ProxmoxController { @Post('servers') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) { - return this.proxmoxService.createServer(this.requireTenantId(req), dto); + const tenantId = this.requireTenantId(req); + const created = await this.proxmoxService.createServer(tenantId, dto); + // Planer sofort nachziehen — ohne Neustart (Aufgabe 4, Vorbild DkvController). + await this.scheduler.refreshTenant(tenantId); + return created; } @Post('servers/:id/poll') @@ -43,4 +51,15 @@ export class ProxmoxController { async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) { return this.proxmoxService.pollServer(this.requireTenantId(req), id); } + + /** + * Verbindungstest (Aufgabe 4, ``): liefert bei Erfolg eine + * Erfolgsmeldung und bei Misserfolg einen der sieben Fehlerschluessel + * samt kurzer Ergaenzung, OHNE den Zwischenlagerstand zu ueberschreiben. + */ + @Post('servers/:id/test') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async test(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.proxmoxService.testConnection(this.requireTenantId(req), id); + } } diff --git a/apps/api/src/proxmox/proxmox.module.ts b/apps/api/src/proxmox/proxmox.module.ts index 6883f06..a4b7735 100644 --- a/apps/api/src/proxmox/proxmox.module.ts +++ b/apps/api/src/proxmox/proxmox.module.ts @@ -2,6 +2,7 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryService } from '../module-registry/module-registry.service'; import { ProxmoxController } from './proxmox.controller'; +import { ProxmoxSchedulerService } from './proxmox-scheduler.service'; import { seedProxmoxModule } from './proxmox.seed'; import { ProxmoxService } from './proxmox.service'; @@ -9,12 +10,13 @@ import { ProxmoxService } from './proxmox.service'; * NestJS module for the Proxmox feature (260923-dhh). Vorbild * `DomaincheckModule`: seeds itself into the module registry on startup. * `ScheduleModule` ist bereits global in `app.module.ts` registriert — der - * Planer aus Aufgabe 4 braucht hier nichts zusaetzlich. + * Planer (Aufgabe 4) braucht hier nichts zusaetzlich, nur die Aufnahme in + * `providers`. */ @Module({ imports: [ModuleRegistryModule], controllers: [ProxmoxController], - providers: [ProxmoxService], + providers: [ProxmoxService, ProxmoxSchedulerService], }) export class ProxmoxModule implements OnModuleInit { private readonly logger = new Logger(ProxmoxModule.name); diff --git a/apps/api/src/proxmox/proxmox.service.spec.ts b/apps/api/src/proxmox/proxmox.service.spec.ts index 74fad44..b901b62 100644 --- a/apps/api/src/proxmox/proxmox.service.spec.ts +++ b/apps/api/src/proxmox/proxmox.service.spec.ts @@ -68,12 +68,19 @@ function makeFakePrisma() { findMany: vi.fn(async ({ where, select }: { where?: any; select?: any } = {}) => { let rows = [...servers.values()]; if (where?.tenantId) rows = rows.filter((r) => r.tenantId === where.tenantId); + if (where?.isActive !== undefined) rows = rows.filter((r) => r.isActive === where.isActive); return rows.map((r) => applySelect(r, select)); }), - findUnique: vi.fn(async ({ where }: { where: { id: string } }) => { - const row = servers.get(where.id); - return row ? { ...row } : null; - }), + findUnique: vi.fn( + async ({ where, include }: { where: { id: string }; include?: { status?: boolean } }) => { + const row = servers.get(where.id); + if (!row) return null; + if (include?.status) { + return { ...row, status: statuses.get(row.id) ?? null }; + } + return { ...row }; + }, + ), }; const proxmoxServerStatus = { @@ -347,3 +354,83 @@ describe('ProxmoxService — Aufgabe 3 (PBS und PMG)', () => { expect(result?.errorKind).toBe('rechte'); }); }); + +describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('testConnection liefert das Ergebnis, schreibt aber NICHT ins Zwischenlager', async () => { + const prisma = makeFakePrisma(); + const service = new ProxmoxService(prisma as any, crypto as any); + const created = await service.createServer('tenant-a', TOKEN_DTO); + + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(pveResourcesBody()), { status: 200 })), + ); + + const result = await service.testConnection('tenant-a', (created as any).id); + + expect(result?.reachable).toBe(true); + const status = prisma.__statuses.get((created as any).id); + // Die leere Zwischenlagerzeile aus createServer bleibt unveraendert. + expect(status.lastPolledAt).toBeUndefined(); + expect(status.reachable).toBe(false); + }); + + it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => { + const prisma = makeFakePrisma(); + const service = new ProxmoxService(prisma as any, crypto as any); + const created = await service.createServer('tenant-a', TOKEN_DTO); + + let fetchCalls = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + fetchCalls++; + return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); + }), + ); + + const first = await service.pollServer('tenant-a', (created as any).id); + const second = await service.pollServer('tenant-a', (created as any).id); + + expect(fetchCalls).toBe(1); + expect(second).toEqual(first); + }); + + it('nach zehn Sekunden ist ein erneuter Durchlauf wieder erlaubt', async () => { + const prisma = makeFakePrisma(); + const service = new ProxmoxService(prisma as any, crypto as any); + const created = await service.createServer('tenant-a', TOKEN_DTO); + + let fetchCalls = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + fetchCalls++; + return new Response(JSON.stringify(pveResourcesBody()), { status: 200 }); + }), + ); + + await service.pollServer('tenant-a', (created as any).id); + const status = prisma.__statuses.get((created as any).id); + status.lastPolledAt = new Date(Date.now() - 11_000); // Sperre kuenstlich veraltern + + await service.pollServer('tenant-a', (created as any).id); + + expect(fetchCalls).toBe(2); + }); + + it('loadActiveServersForScheduler nutzt forSystem (D-08, der einzige Systemkontext-Aufruf des Moduls)', async () => { + const prisma = makeFakePrisma(); + const service = new ProxmoxService(prisma as any, crypto as any); + await service.createServer('tenant-a', TOKEN_DTO); + + const servers = await service.loadActiveServersForScheduler(); + expect(servers).toHaveLength(1); + expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 }); + }); +}); diff --git a/apps/api/src/proxmox/proxmox.service.ts b/apps/api/src/proxmox/proxmox.service.ts index b9dd30e..620bb25 100644 --- a/apps/api/src/proxmox/proxmox.service.ts +++ b/apps/api/src/proxmox/proxmox.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ProxmoxServer } from '@prisma/client'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth'; import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service'; import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize'; @@ -53,6 +53,14 @@ const SAFE_SERVER_SELECT = { */ const PBS_SNAPSHOT_QUERY_CAP = 10; +/** + * Zehn-Sekunden-Sperre fuer `pollServer` (Aufgabe 4, T-DHH-06): ein Klick + * auf "Jetzt aktualisieren" darf nicht zu ungebremsten Anfragen gegen die + * Fremd-API werden. Regulaer fragt ohnehin nur der Planer mit begrenzter + * Frequenz ab (D-05). + */ +const POLL_LOCK_MS = 10_000; + function truncateRaw(body: unknown): string { let text: string; try { @@ -354,16 +362,37 @@ export class ProxmoxService { /** * Fragt genau einen Server ab und schreibt das Ergebnis ins Zwischenlager. * Liefert `null`, wenn der Server unter diesem Mandanten nicht existiert. + * + * Zehn-Sekunden-Sperre (Aufgabe 4, ``, T-DHH-06): ein zweiter + * Durchlauf innerhalb von zehn Sekunden nach dem letzten fragt Proxmox + * NICHT erneut, sondern liefert den vorhandenen Zwischenlagerstand — + * Schutz davor, dass ein Klick in der Oberflaeche zu ungebremsten + * Anfragen gegen die Fremd-API wird. */ async pollServer(tenantId: string, serverId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId }, + include: { status: true }, }); if (!server || server.tenantId !== tenantId) { return null; } + const cachedStatus = server.status; + if (cachedStatus?.lastPolledAt) { + const ageMs = Date.now() - cachedStatus.lastPolledAt.getTime(); + if (ageMs < POLL_LOCK_MS) { + return { + reachable: cachedStatus.reachable, + errorKind: cachedStatus.errorKind as ProxmoxErrorKind | null, + errorDetail: cachedStatus.errorDetail, + metrics: cachedStatus.metrics as ProxmoxPollResult['metrics'], + rawSample: cachedStatus.rawSample, + }; + } + } + const result = await this.pollOne(server); const now = new Date(); @@ -393,4 +422,68 @@ export class ProxmoxService { return result; } + + /** + * Verbindungstest (Aufgabe 4, ``, `POST servers/:id/test`): + * benutzt denselben Klienten und dieselbe Fehleruebersetzung wie der + * Planer, schreibt aber NICHT ins Zwischenlager — ein Testklick darf den + * zuletzt gemessenen Stand nicht ueberschreiben (Vorbild + * `TenderEmailConfigService.testConnection`/LDAP-Test). Keine + * Zehn-Sekunden-Sperre: ein Test ist ein bewusster Einzelklick, kein + * automatisierter Auffrischungsweg. + */ + async testConnection(tenantId: string, serverId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } }); + if (!server || server.tenantId !== tenantId) { + return null; + } + return this.pollOne(server); + } + + /** + * Aktive Server-IDs eines Mandanten fuer den Planer-Tick (gebunden). + */ + async listActiveServerIdsForTenant(tenantId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const rows = await tenantPrisma.proxmoxServer.findMany({ + where: { tenantId, isActive: true }, + select: { id: true }, + }); + return rows.map((r) => r.id); + } + + /** + * Abfrageintervalle der aktiven Server eines Mandanten (gebunden) — der + * Controller ruft dies nach jedem Anlegen/Speichern, um den Planer + * sofort nachzuziehen (`ProxmoxSchedulerService.refreshTenant`). + */ + async loadActiveServersForTenantScheduling( + tenantId: string, + ): Promise<{ pollIntervalMin: number }[]> { + const tenantPrisma = forTenant(this.prisma, tenantId); + return tenantPrisma.proxmoxServer.findMany({ + where: { tenantId, isActive: true }, + select: { pollIntervalMin: true }, + }); + } + + /** + * Startpfad des Planers — der EINZIGE Systemkontext-Aufruf dieses Moduls + * (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`, Aufgabe 4): + * `const systemPrisma = forSystem(this.prisma);`, nur lesend, OHNE + * `include` auf das Zwischenlager — die Zwischenlagertabelle hat bewusst + * keine Systemlese-Regel, das Nachziehen laeuft je Zeile gebunden + * (Muster `DkvSchedulerService`/`DashboardImagesService`, einmal lesen, + * viele bedienen). + */ + async loadActiveServersForScheduler(): Promise< + { id: string; tenantId: string; pollIntervalMin: number }[] + > { + const systemPrisma = forSystem(this.prisma); + return systemPrisma.proxmoxServer.findMany({ + where: { isActive: true }, + select: { id: true, tenantId: true, pollIntervalMin: true }, + }); + } } diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index bc23b26..17d6965 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -175,8 +175,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | favorites | 0 | 8 | 0 | **war 7/0** — 260911-gwh (Aufgabe 2) hat `favorites.service.ts` vollständig auf `forTenant()` umgestellt: `list`, `create`, `update`, `remove`, `getIconBytes` laufen je über EINEN Klienten `tenantPrisma` (7 gebundene `favoriteLink`-Rohtreffer); `create` prüft zusätzlich über einen gebundenen `widgetInstance.findUnique`, dass das Ziel-Widget dem Aufrufer gehört (T-GWH-05, Befund F aus Aufgabe 1: der Fremdschlüssel prüft am Zeilenschutz vorbei) — der achte gebundene Rohtreffer dieser Zeile | | bug-reports | 0 | 1 | 0 | neu (260914-m97), ein gebundener Zugriff | | settings | 0 | 4 | 0 | **Nachgemessen 260921-pi9: 4 gebundene Rohtreffer** (die Tabelle nannte 3; der vierte `smtpConfig`-Zugriff kam mit 260914-m97/`bugReportRecipient` hinzu, ohne dass die Zeile nachgezogen wurde). **war 4/0** — 260911-gwh (Aufgabe 2) hat `getSmtpConfig`, `saveSmtpConfig`, `getDecryptedSmtpConfig` auf `forTenant()` umgestellt (3 gebundene `smtpConfig`-Rohtreffer). Der eine verbleibende ungebundene Rohtreffer war der umbenannte Planer-Startpfad `loadAnySmtpConfigForStartupTransport()` (Befund D, WINDOWS #30). **260914-eym:** GELÖSCHT — `MailService` baut je Versand einen Transport über `getDecryptedSmtpConfig(tenantId)` (1→0 ungebunden, 0 System, kein Systemkontext nötig); Befund K (`tenders`/`dkv`/`mail` hängen an `getDecryptedSmtpConfig`) ist damit erfüllt — WINDOWS #30 geschlossen | -| proxmox | 0 | 4 | 0 | neu (quick-260923-dhh, Aufgabe 1) — `proxmox.service.ts` bringt vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode). Nachgemessen mit der Gate-Schleife. Aufgabe 4 ergaenzt einen System-Rohtreffer fuer den Planer-Startpfad (`loadActiveServersForScheduler()`) — siehe dortigen Nachtrag | -| **Summe** | **61** | **201** | **6** | **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| proxmox | 0 | 7 | 1 | **quick-260923-dhh (Aufgabe 4, Endstand):** 4→7 gebunden, 0→1 System — `proxmox.service.ts` bringt drei weitere gebundene Rohtreffer (`pollServer` mit `include: { status: true }` bleibt EIN Klient, `testConnection`, `listActiveServerIdsForTenant`, `loadActiveServersForTenantScheduling` — vier neue Methoden, aber `pollServer`s zweiter Zugriff war schon gezaehlt, macht drei zusaetzliche) und einen System-Rohtreffer (`loadActiveServersForScheduler()`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife. Vorher: **quick-260923-dhh (Aufgabe 1):** neu, vier gebundene Rohtreffer: `createServer` (`proxmoxServer.create`), `listWithStatus` (`proxmoxServer.findMany`), `pollServer` (`proxmoxServer.findUnique` UND `proxmoxServerStatus.upsert`, DERSELBE Klient in derselben Methode) | +| **Summe** | **61** | **204** | **7** | **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 77 Paare) @@ -762,8 +762,8 @@ werden. | apps/api/src/user/user.controller.ts | user | muss-mandantengebunden | gebunden | Nutzerverwaltung innerhalb des Mandanten des anfragenden Admins (260910-das, Aufgabe 3): die Benutzerliste des ADMIN-Zweigs, alle drei Kennungswege (rollenabhaengig ueber `UserService.findById`/`findByIdForPlatformAdmin`) und alle fuenf Selbstbedienungszugriffe (Bild hochladen/loeschen/ausliefern, Akzentfarbe) laufen ueber `forTenant()`; die Rollenverzweigung zwischen mandantengebundener ADMIN-Sicht und der uebergreifenden `SUPER_ADMIN`-Sicht (ueber `UserService.findAllForPlatformAdmin`) bleibt bestehen. Der wirkungslose Selbstloesch-Riegel (Befund H, verglich gegen `currentUser.sub`, ein im Sitzungsnachweis nicht existierendes Feld) ist auf `currentUser.id` korrigiert. | | apps/api/src/user/user.service.ts | tenant | keine-mandantengebundene-tabelle | ungebunden | Schleifentreiber der neuen Plattform-Administratorsicht (`findAllForPlatformAdmin`/`findByIdForPlatformAdmin`, 260910-das, Aufgabe 2, Befund F/N) — `Tenant` hat keine `tenantId`-Spalte und traegt keinen Zeilenschutz (Aufgabe 1, `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`). | | apps/api/src/user/user.service.ts | user | beides | gemischt | Klassenkorrektur (260910-das, Aufgabe 3): wechselt von `muss-mandantengebunden` auf `beides` wegen der einen bewusst ungebundenen Suche — wortgleich derselbe Praezedenzfall wie `ldap.service.ts`/`user` in 260909-ipc (`resolveEmailForWrite`). `findById`/`create`/`update`/`deactivate`/`delete` sowie die beiden neuen Plattform-Administratorsicht-Methoden laufen ueber `forTenant()`; `create`/`update` uebersetzen eine plattformweite Eindeutigkeitsverletzung (P2002) in eine deutsche Konfliktmeldung ohne Halter/Mandant zu nennen. `findByUsername` bleibt bewusst UNGEBUNDEN: der Anmeldeweg laeuft seit Etappe 1 ueber die drei SECURITY-DEFINER-Funktionen und hat diese Methode nicht mehr als Aufrufer (260910-das, Aufgabe 1, Teil 3: genau ein Treffer, die eigene Definition); eine gebundene Suche saehe einen fremden Halter des plattformweit eindeutigen `username` nicht und meldete faelschlich "frei". | -| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1 — vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `createServer`/`listWithStatus`/`pollServer` holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId);`. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). Aufgabe 4 ergaenzt den Startpfad des Planers (`loadActiveServersForScheduler()`) ueber `forSystem()` — dieser eine Zusatzaufruf hebt den Stand auf `system-gebunden`, siehe dortigen Nachtrag. | -| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad in Aufgabe 4 liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. | +| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe bleiben unveraendert gebunden: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany`, ueber `proxmoxServerStatus` — Zeile darunter). Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). | +| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. | ## Was diese Etappe NICHT entscheidet