feat(260911-gwh): GREEN — favorites/settings binden, Startpfad umbenennen, Widget-Besitzriegel
- favorites.service.ts: alle fuenf Methoden nehmen tenantId als ersten Parameter, laufen je ueber EINEN Klienten tenantPrisma (7 gebundene Favoritenzugriffe, 5 Aufrufstellen); create() prueft vor der Icon-Suche, dass das Ziel-Widget dem Aufrufer gehoert (T-GWH-05, Befund F aus Aufgabe 1 bestaetigt den Fremdschluessel-Durchgriff) -- Widget not found fuer alle drei Faelle (existiert nicht/Kollege/fremder Mandant) - favorites.controller.ts: reicht tenantId an alle fuenf Aufrufe durch, extractContext unveraendert (dashboard-Praezedenzfall) - settings.service.ts: getSmtpConfig/saveSmtpConfig/getDecryptedSmtpConfig je EIN Klient (3 gebundene Zugriffe, 3 Aufrufstellen) -- Befund K damit erfuellt; Startpfad umbenannt in loadAnySmtpConfigForStartupTransport(), bleibt bewusst ungebunden (sechster Fall der Hintergrunddienst-Falle, WINDOWS #TBD-GWH -- Aufgabe 3 vergibt die Nummer) - mail.module.ts: ruft den umbenannten Startpfad auf, Kommentar nennt beide Zustaende statt "single-tenant default" - Vier Falsifizierungsnachweise durchgefuehrt und zurueckgenommen (siehe SUMMARY): (a) 3 Faelle rot, (b) 4 Faelle rot, (c) 9 Faelle rot, (d) 4 Faelle rot Bekannt und erwartet (siehe SUMMARY, Praezedenzfall 260911-fh9): zwischen dieser Aufgabe und Aufgabe 3 ist rls-access-inventory.spec.ts rot (2 Faelle) -- die Klassifikationstabelle ist noch nicht nachgezogen, das ist Aufgabe 3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -22,6 +22,16 @@ import { FavoritesService } from './favorites.service';
|
|||||||
*
|
*
|
||||||
* All routes are protected by the global JwtAuthGuard + TenantGuard.
|
* All routes are protected by the global JwtAuthGuard + TenantGuard.
|
||||||
*
|
*
|
||||||
|
* Mandantenquelle (260911-gwh): `extractContext` liest `req.tenantId ??
|
||||||
|
* req.user?.tenantId` — WORTGLEICH mit `dashboard.controller.ts`, unter
|
||||||
|
* dessen Bindung `WidgetInstance` liegt. `FavoriteLink` haengt ueber
|
||||||
|
* `widgetId` an `WidgetInstance`; eine andere Quelle (z. B. das Claim, wie
|
||||||
|
* bei `auth` fuer Selbstbedienung) wuerde Widget und Link unter einem
|
||||||
|
* `x-tenant-id`-Wechsel eines SUPER_ADMIN in verschiedenen Mandanten
|
||||||
|
* auseinanderreissen. Das Favoriten-Frontend sendet die `x-tenant-id`-
|
||||||
|
* Kopfzeile heute nicht — die Entscheidung haengt an der Bauform, nicht am
|
||||||
|
* heutigen Aufrufer.
|
||||||
|
*
|
||||||
* Routes:
|
* Routes:
|
||||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||||
@@ -52,9 +62,9 @@ export class FavoritesController {
|
|||||||
@Query('widgetId', ParseUUIDPipe) widgetId: string,
|
@Query('widgetId', ParseUUIDPipe) widgetId: string,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
) {
|
) {
|
||||||
const { userId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
|
||||||
return this.favoritesService.list(userId, widgetId);
|
return this.favoritesService.list(tenantId, userId, widgetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -64,7 +74,7 @@ export class FavoritesController {
|
|||||||
) {
|
) {
|
||||||
const { userId, tenantId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
|
||||||
return this.favoritesService.create(userId, tenantId, dto);
|
return this.favoritesService.create(tenantId, userId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,8 +92,9 @@ export class FavoritesController {
|
|||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
) {
|
) {
|
||||||
const { userId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
const { contentType, body } = await this.favoritesService.getIconBytes(
|
const { contentType, body } = await this.favoritesService.getIconBytes(
|
||||||
|
tenantId,
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -99,9 +110,9 @@ export class FavoritesController {
|
|||||||
@Body() dto: UpdateFavoriteDto,
|
@Body() dto: UpdateFavoriteDto,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
) {
|
) {
|
||||||
const { userId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
|
||||||
return this.favoritesService.update(id, userId, dto);
|
return this.favoritesService.update(tenantId, id, userId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@@ -109,8 +120,8 @@ export class FavoritesController {
|
|||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
) {
|
) {
|
||||||
const { userId } = this.extractContext(req);
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
|
||||||
return this.favoritesService.remove(id, userId);
|
return this.favoritesService.remove(tenantId, id, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||||
@@ -13,10 +14,35 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
|||||||
/**
|
/**
|
||||||
* Service for managing per-user, per-widget favorite links.
|
* Service for managing per-user, per-widget favorite links.
|
||||||
*
|
*
|
||||||
|
* Mandantengebunden (260911-gwh): jede Methode nimmt `tenantId` als ERSTEN
|
||||||
|
* Parameter und laeuft ueber GENAU EINEN Klienten `tenantPrisma` — der
|
||||||
|
* Mandant kommt aus `extractContext` im Controller, DERSELBEN Quelle wie
|
||||||
|
* `dashboard.controller.ts` (nicht dem auth-Praezedenzfall/Claim): der Link
|
||||||
|
* haengt ueber `widgetId` an `WidgetInstance`, und `WidgetInstance` ist
|
||||||
|
* unter der dashboard-Mandantenquelle gebunden. Eine abweichende Quelle
|
||||||
|
* wuerde Widget und Link unter einem `x-tenant-id`-Wechsel eines
|
||||||
|
* SUPER_ADMIN in verschiedenen Mandanten auseinanderreissen.
|
||||||
|
*
|
||||||
|
* Die Regel auf `FavoriteLink` kennt KEINE Benutzerdimension (260911-gwh,
|
||||||
|
* Aufgabe 1, Pruefung 4 — dieselbe Lehre wie `CalendarSource`/
|
||||||
|
* `DashboardLayout`/`WidgetInstance`) — die `userId`-Filter unten bleiben
|
||||||
|
* deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern DESSELBEN
|
||||||
|
* Mandanten (Etappe-3-Entscheidung (2) traegt das nach).
|
||||||
|
*
|
||||||
* Access control (T-08-06 / Pitfall 3):
|
* Access control (T-08-06 / Pitfall 3):
|
||||||
* - Every query is scoped by userId (prevents cross-user access).
|
* - Every query is scoped by userId (prevents cross-user access).
|
||||||
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
||||||
* - update() and remove() verify userId ownership before mutating.
|
* - update() and remove() verify userId ownership before mutating.
|
||||||
|
*
|
||||||
|
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
||||||
|
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
||||||
|
* Zeilenschutz-Regel von `WidgetInstance` VORBEI (dokumentiertes
|
||||||
|
* PostgreSQL-Verhalten, gemessen in Aufgabe 1, Pruefung 7) — ohne den
|
||||||
|
* Riegel waere der Unterschied zwischen "Widget existiert nicht" (500) und
|
||||||
|
* "gehoert einem fremden Mandanten" (gelingt) ein Existenzorakel ueber
|
||||||
|
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
||||||
|
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
||||||
|
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FavoritesService {
|
export class FavoritesService {
|
||||||
@@ -29,9 +55,11 @@ export class FavoritesService {
|
|||||||
* Returns all favorites for a user's widget instance, ordered by position asc.
|
* Returns all favorites for a user's widget instance, ordered by position asc.
|
||||||
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
|
* Scoped by userId AND widgetId (Pitfall 3 — separate widgets must not share links).
|
||||||
*/
|
*/
|
||||||
async list(userId: string, widgetId: string) {
|
async list(tenantId: string, userId: string, widgetId: string) {
|
||||||
if (!widgetId) throw new BadRequestException('widgetId is required');
|
if (!widgetId) throw new BadRequestException('widgetId is required');
|
||||||
return this.prisma.favoriteLink.findMany({
|
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.favoriteLink.findMany({
|
||||||
where: { userId, widgetId },
|
where: { userId, widgetId },
|
||||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||||
});
|
});
|
||||||
@@ -39,9 +67,26 @@ export class FavoritesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new favorite link.
|
* Creates a new favorite link.
|
||||||
|
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||||
|
* discovery network call (T-GWH-05).
|
||||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||||
*/
|
*/
|
||||||
async create(userId: string, tenantId: string, dto: CreateFavoriteDto) {
|
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
|
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
|
||||||
|
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
|
||||||
|
// wuerde ein gebundenes create mit einer fremdmandantigen widgetId
|
||||||
|
// gelingen. Eine Antwort fuer alle drei Faelle: existiert nicht,
|
||||||
|
// gehoert einem Kollegen, liegt bei einem fremden Mandanten.
|
||||||
|
const widget = await tenantPrisma.widgetInstance.findUnique({
|
||||||
|
where: { id: dto.widgetId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!widget || widget.userId !== userId) {
|
||||||
|
throw new NotFoundException('Widget not found');
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize so a scheme-less entry like "ctl.de" is stored (and discovered)
|
// Normalize so a scheme-less entry like "ctl.de" is stored (and discovered)
|
||||||
// as "https://ctl.de" — otherwise the link and icon discovery both break.
|
// as "https://ctl.de" — otherwise the link and icon discovery both break.
|
||||||
const url = normalizeUrl(dto.url);
|
const url = normalizeUrl(dto.url);
|
||||||
@@ -52,7 +97,7 @@ export class FavoritesService {
|
|||||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.favoriteLink.create({
|
return tenantPrisma.favoriteLink.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -70,8 +115,9 @@ export class FavoritesService {
|
|||||||
* Verifies userId ownership before applying changes (T-08-06).
|
* Verifies userId ownership before applying changes (T-08-06).
|
||||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||||
*/
|
*/
|
||||||
async update(id: string, userId: string, dto: UpdateFavoriteDto) {
|
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (!link || link.userId !== userId) {
|
if (!link || link.userId !== userId) {
|
||||||
throw new NotFoundException('FavoriteLink not found');
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
@@ -100,7 +146,7 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.favoriteLink.update({
|
return tenantPrisma.favoriteLink.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
@@ -110,14 +156,15 @@ export class FavoritesService {
|
|||||||
* Deletes a favorite link.
|
* Deletes a favorite link.
|
||||||
* Verifies userId ownership before deleting (T-08-06).
|
* Verifies userId ownership before deleting (T-08-06).
|
||||||
*/
|
*/
|
||||||
async remove(id: string, userId: string) {
|
async remove(tenantId: string, id: string, userId: string) {
|
||||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (!link || link.userId !== userId) {
|
if (!link || link.userId !== userId) {
|
||||||
throw new NotFoundException('FavoriteLink not found');
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.favoriteLink.delete({ where: { id } });
|
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,10 +179,12 @@ export class FavoritesService {
|
|||||||
* SSRF-blocked) -- never returns a placeholder image.
|
* SSRF-blocked) -- never returns a placeholder image.
|
||||||
*/
|
*/
|
||||||
async getIconBytes(
|
async getIconBytes(
|
||||||
|
tenantId: string,
|
||||||
id: string,
|
id: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ contentType: string; body: Buffer }> {
|
): Promise<{ contentType: string; body: Buffer }> {
|
||||||
const link = await this.prisma.favoriteLink.findUnique({ where: { id } });
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||||
|
|
||||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||||
throw new NotFoundException('FavoriteLink not found');
|
throw new NotFoundException('FavoriteLink not found');
|
||||||
|
|||||||
@@ -12,13 +12,19 @@ import { MailService } from './mail.service';
|
|||||||
* with an env-var fallback (priority 2) when no DB row exists.
|
* with an env-var fallback (priority 2) when no DB row exists.
|
||||||
*
|
*
|
||||||
* Transport priority:
|
* Transport priority:
|
||||||
* 1. DB SmtpConfig (first row — single-tenant default; set via /settings/smtp)
|
* 1. DB SmtpConfig (loadAnySmtpConfigForStartupTransport — bewusst
|
||||||
|
* UNGEBUNDEN, sechster Fall der Hintergrunddienst-Falle, 260911-gwh;
|
||||||
|
* siehe deren Kopfkommentar in settings.service.ts fuer beide
|
||||||
|
* Zustaende: HEUTE zieht sie den Server EINES beliebigen Mandanten fuer
|
||||||
|
* alle Systemmails [T-GWH-03], NACH DEM SCHARFSCHALTEN liefert sie
|
||||||
|
* `null` und diese Rueckfallkette greift — WINDOWS #TBD-GWH)
|
||||||
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS
|
* 2. Env vars: MAIL_HOST / MAIL_PORT / MAIL_USER / MAIL_PASS
|
||||||
* 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD
|
* 3. Legacy env vars: TESSERA_SMTP_HOST / TESSERA_SMTP_PORT / TESSERA_SMTP_USER / TESSERA_SMTP_PASSWORD
|
||||||
* 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
|
* 4. Final hardcoded fallback: localhost:1025 (Mailhog / dev default)
|
||||||
*
|
*
|
||||||
* The factory is async because getStartupSmtpConfig() reads from the DB.
|
* The factory is async because loadAnySmtpConfigForStartupTransport() reads
|
||||||
* No circular import risk: MailModule → SettingsModule → CalendarModule (no reverse edges).
|
* from the DB. No circular import risk: MailModule → SettingsModule →
|
||||||
|
* CalendarModule (no reverse edges).
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,8 +32,9 @@ import { MailService } from './mail.service';
|
|||||||
MailerModule.forRootAsync({
|
MailerModule.forRootAsync({
|
||||||
imports: [SettingsModule],
|
imports: [SettingsModule],
|
||||||
useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
|
useFactory: async (settingsService: SettingsService, configService: ConfigService) => {
|
||||||
// Priority 1: DB SmtpConfig (getStartupSmtpConfig uses findFirst — single-tenant default)
|
// Priority 1: DB SmtpConfig — loadAnySmtpConfigForStartupTransport()
|
||||||
const db = await settingsService.getStartupSmtpConfig();
|
// stays bewusst UNGEBUNDEN (findFirst, no tenant context at boot).
|
||||||
|
const db = await settingsService.loadAnySmtpConfigForStartupTransport();
|
||||||
|
|
||||||
if (db) {
|
if (db) {
|
||||||
// T-07-11: DB password used only to build transport; never logged
|
// T-07-11: DB password used only to build transport; never logged
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { CryptoService } from '../crypto/crypto.service';
|
import { CryptoService } from '../crypto/crypto.service';
|
||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
|
|
||||||
@@ -34,9 +35,13 @@ export class SettingsService {
|
|||||||
/**
|
/**
|
||||||
* Get the SMTP config for a tenant — safe (no password field).
|
* Get the SMTP config for a tenant — safe (no password field).
|
||||||
* Returns null when no config row exists for the tenant.
|
* Returns null when no config row exists for the tenant.
|
||||||
|
*
|
||||||
|
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma` fuer diese
|
||||||
|
* Methode, wie die restlichen Anfragewege dieser Datei.
|
||||||
*/
|
*/
|
||||||
async getSmtpConfig(tenantId: string) {
|
async getSmtpConfig(tenantId: string) {
|
||||||
return this.prisma.smtpConfig.findUnique({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.smtpConfig.findUnique({
|
||||||
where: { tenantId },
|
where: { tenantId },
|
||||||
select: {
|
select: {
|
||||||
...SMTP_SAFE_SELECT,
|
...SMTP_SAFE_SELECT,
|
||||||
@@ -52,8 +57,11 @@ export class SettingsService {
|
|||||||
* When `dto.password` is empty or absent, the existing encrypted password is preserved.
|
* When `dto.password` is empty or absent, the existing encrypted password is preserved.
|
||||||
*
|
*
|
||||||
* T-07-08: Encryption via CryptoService. Never logs the plaintext password.
|
* T-07-08: Encryption via CryptoService. Never logs the plaintext password.
|
||||||
|
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma`.
|
||||||
*/
|
*/
|
||||||
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
|
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
// Determine the encrypted password to store
|
// Determine the encrypted password to store
|
||||||
let encryptedPassword: string | undefined;
|
let encryptedPassword: string | undefined;
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ export class SettingsService {
|
|||||||
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
|
...(encryptedPassword !== undefined ? { encryptedPassword } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await this.prisma.smtpConfig.upsert({
|
const result = await tenantPrisma.smtpConfig.upsert({
|
||||||
where: { tenantId },
|
where: { tenantId },
|
||||||
create: { tenantId, ...data },
|
create: { tenantId, ...data },
|
||||||
update: data,
|
update: data,
|
||||||
@@ -83,8 +91,15 @@ export class SettingsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: Get the decrypted SMTP config for a tenant.
|
* Internal: Get the decrypted SMTP config for a tenant.
|
||||||
* Used by DkvMailService to build a nodemailer transport at send time.
|
* Used by DkvMailService/TenderMailService to build a nodemailer transport
|
||||||
|
* at send time — the ONLY send path (Befund K, 260909-laa/260909-mir).
|
||||||
* NEVER log the decrypted password (T-07-10 / T-05-13).
|
* NEVER log the decrypted password (T-07-10 / T-05-13).
|
||||||
|
*
|
||||||
|
* Mandantengebunden seit 260911-gwh (Aufgabe 2): EIN Klient
|
||||||
|
* `tenantPrisma`. Vorher lief diese Methode ungebunden — nach dem
|
||||||
|
* Scharfschalten waere fuer NIEMANDEN mehr eine Mail rausgegangen
|
||||||
|
* (tender: warn+skip, dkv: throw). Die Reihenfolgebedingung aus (t4)
|
||||||
|
* Befund K und (d4) ist mit dieser Bindung erfuellt.
|
||||||
*/
|
*/
|
||||||
async getDecryptedSmtpConfig(tenantId: string): Promise<{
|
async getDecryptedSmtpConfig(tenantId: string): Promise<{
|
||||||
host: string;
|
host: string;
|
||||||
@@ -94,7 +109,8 @@ export class SettingsService {
|
|||||||
fromAddress: string;
|
fromAddress: string;
|
||||||
decryptedPassword: string | null;
|
decryptedPassword: string | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
const config = await this.prisma.smtpConfig.findUnique({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
const config = await tenantPrisma.smtpConfig.findUnique({
|
||||||
where: { tenantId },
|
where: { tenantId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +138,8 @@ export class SettingsService {
|
|||||||
* Returns true on success, false on failure.
|
* Returns true on success, false on failure.
|
||||||
*
|
*
|
||||||
* T-07-16: Returns only a boolean — no credentials or transport details in the response.
|
* T-07-16: Returns only a boolean — no credentials or transport details in the response.
|
||||||
|
* Kein eigener Datenbankzugriff — greift ueber `getDecryptedSmtpConfig`
|
||||||
|
* (bereits gebunden) auf gespeicherte Zugangsdaten zurueck.
|
||||||
*/
|
*/
|
||||||
async testSmtpConfig(
|
async testSmtpConfig(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -175,13 +193,46 @@ export class SettingsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tenant-agnostic startup accessor for the MailModule factory.
|
* Tenant-agnostic startup accessor for the MailModule factory.
|
||||||
* Returns the first SmtpConfig row in the DB (single-tenant deployments) with
|
*
|
||||||
* the password decrypted. Returns null when no row exists (env-var fallback path).
|
* BLEIBT bewusst UNGEBUNDEN (260911-gwh, sechster Fall der
|
||||||
|
* Hintergrunddienst-Falle — gleicher Bauart wie
|
||||||
|
* `DkvService.loadAnyActiveConfigForScheduler()`, WINDOWS #21, siehe
|
||||||
|
* dessen Kopfkommentar als Vorlage). Zwei Zustaende, beide gehoeren
|
||||||
|
* genannt:
|
||||||
|
*
|
||||||
|
* - HEUTE bereits falsch, nicht nur ungenau: `findFirst()` ohne jede
|
||||||
|
* Bedingung zieht bei mehreren Mandanten den SMTP-Server und die
|
||||||
|
* Absenderadresse EINES beliebigen Mandanten fuer ALLE
|
||||||
|
* Kennwort-Zuruecksetzungs- und Willkommensmails ALLER Mandanten
|
||||||
|
* (T-GWH-03 — Nutzung fremder Zugangsdaten, nicht nur Sichtbarkeit).
|
||||||
|
* - NACH DEM SCHARFSCHALTEN (WINDOWS #18) liefert dieselbe Abfrage
|
||||||
|
* `null`, `mail.module.ts` faellt auf Umgebungsvariablen und zuletzt
|
||||||
|
* `localhost:1025` zurueck — ein FALSCHER, aber vorhandener Transport
|
||||||
|
* statt einer Meldung; `MailService` faengt jeden Transportfehler
|
||||||
|
* (T-02-12) und der Controller antwortet `200`. Das Verstummen ist
|
||||||
|
* damit DOPPELT verdeckt: erst durch die Rueckfallkette, dann durch
|
||||||
|
* das Verschlucken im Versand. Das ist die Unsymmetrie zu `ldap`
|
||||||
|
* (`getAllActiveConfigs`, heute korrekt, verstummt erst spaeter) UND zu
|
||||||
|
* `dkv` (WINDOWS #21, heute bereits falsch, verstummt spaeter MIT
|
||||||
|
* Protokollzeile) — hier: heute bereits falsch, verstummt spaeter OHNE
|
||||||
|
* Protokollzeile.
|
||||||
|
*
|
||||||
|
* Binden wuerde diesen Pfad garantiert leer laufen lassen (beim Start
|
||||||
|
* gibt es strukturell keinen Mandantenkontext). Der Umbau auf Transport
|
||||||
|
* je Versand aus `getDecryptedSmtpConfig(tenantId)` — die Form, die
|
||||||
|
* `DkvMailService`/`TenderMailService` bereits haben, `MailService`
|
||||||
|
* muesste den Mandanten nur von `requestPasswordReset` entgegennehmen —
|
||||||
|
* ist eine Funktionsaenderung (Umbau des Mailmoduls), KEIN Bindungsumbau,
|
||||||
|
* NICHT dieser Auftrag. Entscheidung: EIGENER Ledger-Eintrag statt
|
||||||
|
* Anschluss an #21 (andere Datei, andere Reparatur, andere
|
||||||
|
* Verdeckungsform) — siehe WINDOWS #TBD-GWH und
|
||||||
|
* `docs/mandantentrennung-etappe2-fehlerrichtung.md`, Abschnitt
|
||||||
|
* "## Bereich settings", (s4)(a).
|
||||||
*
|
*
|
||||||
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
|
* D-06: MailModule reads this at startup (priority 1) and falls back to env vars (priority 2).
|
||||||
* T-07-11: Decrypted password is used only to build the transport — never logged.
|
* T-07-11: Decrypted password is used only to build the transport — never logged.
|
||||||
*/
|
*/
|
||||||
async getStartupSmtpConfig(): Promise<{
|
async loadAnySmtpConfigForStartupTransport(): Promise<{
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user