feat(quick-260910-jab): listForUser binden, die vier Aufzeichnungen im Quelltext richtigstellen
- TenderRssFeedSourceService.listForUser() nimmt jetzt (userId, tenantId) entgegen und laeuft ueber einen gebundenen Klienten (forTenant) — die neue Leseregel schliesst plattformweite Zeilen ein, die Reparatur haette den ungebundenen Pfad sonst still auf nur die plattformweiten Zeilen reduziert (Befund F). createPlatform/remove bleiben bewusst ungebunden, Kommentare an der neuen Regel richtiggestellt. - TendersController.listRssFeeds reicht die Mandantenkennung aus dem Aufrufzusammenhang durch. - Vier Aufzeichnungen im Quelltext (module-access.service.ts, groups.service.ts, module-grants.service.ts, rls-coverage.spec.ts) sagen jetzt, dass die Datenbankregel seit 20260910120000_rls_widen_membership_ grant_and_platform_read beide Seiten prueft; die Anwendungspruefungen bleiben unveraendert bestehen (zweites Netz, wirkt vor dem Scharfschalten als einziger Schutz). - Zwei-Klienten-Nachweis in module-grants.service.spec.ts ergaenzt (Kommentar, warum die beiden Cross-Tenant-Tests nach der Regelaenderung nicht entfallen duerfen) und in tender-rss-feed.service.spec.ts umgekehrt (listForUser bindet jetzt). - Rule 1: implizites any beim Destrukturieren in listRssFeeds (feeds ist seit der Bindung `any`) mit expliziter Annotation behoben. - Falsifizierungsnachweis durchgefuehrt: Bindungsaufruf zurueckgenommen, genau ein Test wurde rot (AssertionError, 0 statt der erwarteten Aufrufe), Ruecknahme rueckgaengig gemacht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -485,12 +485,16 @@ export class GroupsService {
|
|||||||
* exportiert sein.
|
* exportiert sein.
|
||||||
*
|
*
|
||||||
* Prüft zusätzlich, dass der Zielbenutzer zu DIESEM Mandanten gehört
|
* Prüft zusätzlich, dass der Zielbenutzer zu DIESEM Mandanten gehört
|
||||||
* (Befund E, T-JTS-02, 260909-jts): die ausgelieferte Policy auf
|
* (T-JTS-02, 260909-jts/260910-jab): die Policy auf GroupMembership prüfte
|
||||||
* GroupMembership prüft ausschließlich die Gruppenseite
|
* bis 260910-jab ausschließlich die Gruppenseite
|
||||||
* (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`), gemessen
|
* (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`) — die
|
||||||
* in Aufgabe 1 — die Benutzerseite prüft sie NICHT. Nach dem Vorbild von
|
* Benutzerseite NICHT (Befund E, gemessen in Aufgabe 1 von 260909-jts).
|
||||||
* addMembers() zwei Methoden höher: Zielbenutzer auf den Mandanten
|
* Seit 20260910120000_rls_widen_membership_grant_and_platform_read prüft
|
||||||
* filtern, bei keinem Treffer folgenlos zurückkehren statt zu werfen.
|
* die Datenbankregel selbst BEIDE Seiten — diese Anwendungsprüfung bleibt
|
||||||
|
* trotzdem bestehen: der Schalter ist weiterhin aus (#18), die
|
||||||
|
* Datenbankregel wirkt heute nicht. Nach dem Vorbild von addMembers() zwei
|
||||||
|
* Methoden höher: Zielbenutzer auf den Mandanten filtern, bei keinem
|
||||||
|
* Treffer folgenlos zurückkehren statt zu werfen.
|
||||||
*/
|
*/
|
||||||
async addUserToDefaultGroup(tenantId: string, userId: string) {
|
async addUserToDefaultGroup(tenantId: string, userId: string) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|||||||
@@ -278,6 +278,15 @@ describe('ModuleGrantsService.grant', () => {
|
|||||||
expect(prisma.__grantCount()).toBe(0);
|
expect(prisma.__grantCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Diese beiden Faelle (T-JTS-03, 260910-jab, Aufgabe 2) beweisen, dass
|
||||||
|
// assertTargetBelongsToTenant() weiterhin im Anwendungscode scheitert —
|
||||||
|
// nicht erst in der Datenbank. Seit
|
||||||
|
// 20260910120000_rls_widen_membership_grant_and_platform_read zieht auch
|
||||||
|
// die Datenbankregel dieselbe Grenze, aber erst NACH dem Scharfschalten
|
||||||
|
// (#18 ist weiterhin aus). Wuerde assertTargetBelongsToTenant() im
|
||||||
|
// Vertrauen auf "das macht jetzt die Datenbank" entfernt, werden GENAU
|
||||||
|
// diese beiden Faelle rot: der Fake hier hat keine RLS-Policy, nur das
|
||||||
|
// reale ModuleGrant/Group/User-Schema tut das.
|
||||||
it('wirft NotFoundException für eine groupId aus einem anderen Mandanten und legt nichts an', async () => {
|
it('wirft NotFoundException für eine groupId aus einem anderen Mandanten und legt nichts an', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
seedBase(prisma);
|
seedBase(prisma);
|
||||||
|
|||||||
@@ -91,13 +91,18 @@ export class ModuleGrantsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Die Mandanten-Gegenpruefung bleibt ausdruecklich erhalten (T-JTS-03,
|
// Die Mandanten-Gegenpruefung bleibt ausdruecklich erhalten (T-JTS-03,
|
||||||
// 260909-jts, Aufgabe 1): die ausgelieferte Regel auf ModuleGrant
|
// 260909-jts/260910-jab): bis Migration
|
||||||
// prueft ausschliesslich die Mandantenkennung der Zeile selbst
|
// 20260910120000_rls_widen_membership_grant_and_platform_read pruefte
|
||||||
// ("tenantId" = current_tenant_id()), NICHT die referenzierte Gruppe.
|
// die Regel auf ModuleGrant ausschliesslich die Mandantenkennung der
|
||||||
// Eine Zeile mit korrekter eigener Mandantenkennung, die auf die
|
// Zeile selbst ("tenantId" = current_tenant_id()),
|
||||||
// Gruppe eines fremden Mandanten zeigt, verletzt diese Regel
|
// NICHT die referenzierte Gruppe oder den referenzierten Benutzer — eine
|
||||||
// nachweislich nicht (gemessen gegen die echte Migration in Aufgabe 1).
|
// Zeile mit korrekter eigener Mandantenkennung, die auf die Gruppe/den
|
||||||
// Diese Anwendungspruefung ist damit der einzige Schutz gegen diese
|
// Benutzer eines fremden Mandanten zeigt, verletzte diese Regel
|
||||||
|
// nachweislich nicht (gemessen in Aufgabe 1 von 260909-jts). Die
|
||||||
|
// Datenbank zieht diese Grenze inzwischen ebenfalls (260910-jab, Aufgabe
|
||||||
|
// 1) — diese zweite Ziehung wirkt aber erst NACH dem Scharfschalten
|
||||||
|
// (#18, der Schalter ist weiterhin aus). Diese Anwendungspruefung bleibt
|
||||||
|
// deshalb bis dahin der EINZIGE und danach der ERSTE Schutz gegen diese
|
||||||
// Form der Rechteausweitung und darf nicht als "macht jetzt die
|
// Form der Rechteausweitung und darf nicht als "macht jetzt die
|
||||||
// Datenbank" entfallen.
|
// Datenbank" entfallen.
|
||||||
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
|
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
|
||||||
|
|||||||
@@ -45,9 +45,15 @@ export class ModuleAccessService {
|
|||||||
// — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die
|
// — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die
|
||||||
// bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen:
|
// bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen:
|
||||||
// sie sind das zweite Netz, nicht redundant — dieselbe Begruendung wie
|
// sie sind das zweite Netz, nicht redundant — dieselbe Begruendung wie
|
||||||
// in `module-grants.service.ts` (die Regel auf `GroupMembership` prueft
|
// in `module-grants.service.ts`. Seit
|
||||||
// nachweislich nur die Gruppenseite, T-JTS-02, und die Regel auf
|
// 20260910120000_rls_widen_membership_grant_and_platform_read (260910-jab)
|
||||||
// `ModuleGrant` nur die Mandantenkennung der Zeile, T-JTS-03).
|
// prueft die Regel auf `GroupMembership` beide Seiten der Beziehung
|
||||||
|
// (Gruppe UND Benutzer, T-JTS-02 geschlossen) und die Regel auf
|
||||||
|
// `ModuleGrant` zusaetzlich die referenzierte Gruppe/den referenzierten
|
||||||
|
// Benutzer (T-JTS-03 geschlossen) — das zweite Netz bleibt trotzdem
|
||||||
|
// bestehen: der Schalter ist weiterhin aus (#18), die Datenbankregel
|
||||||
|
// wirkt heute nicht, und die Anwendungspruefung ist bis zum
|
||||||
|
// Scharfschalten der einzige tatsaechliche Schutz.
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
|
||||||
if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
|
if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ function tablesWithPolicy(sql: string): Set<string> {
|
|||||||
const JOIN_PATTERN_TABLES: Record<string, string> = {
|
const JOIN_PATTERN_TABLES: Record<string, string> = {
|
||||||
PasswordResetToken: 'geschuetzt ueber Join userId -> User -> tenantId',
|
PasswordResetToken: 'geschuetzt ueber Join userId -> User -> tenantId',
|
||||||
LdapFieldMapping: 'geschuetzt ueber Join ldapConfigId -> LdapConfig -> tenantId',
|
LdapFieldMapping: 'geschuetzt ueber Join ldapConfigId -> LdapConfig -> tenantId',
|
||||||
GroupMembership: 'geschuetzt ueber Join groupId -> Group -> tenantId',
|
GroupMembership:
|
||||||
|
'geschuetzt ueber Join groupId -> Group -> tenantId UND userId -> User -> tenantId (beide Seiten seit 20260910120000_rls_widen_membership_grant_and_platform_read, T-JTS-02 geschlossen)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DELIBERATELY_EXCLUDED_TABLES: Record<string, string> = {
|
const DELIBERATELY_EXCLUDED_TABLES: Record<string, string> = {
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
|||||||
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
import { TenderRssFeedSourceService } from './tender-rss-feed.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bindung an forTenant() (260909-laa, WINDOWS #19): nur `createForUser`
|
* Bindung an forTenant() (260909-laa/260910-jab, WINDOWS #19): bis
|
||||||
* beruehrt ausschliesslich persoenliche Zeilen mit gesetztem Mandanten und
|
* 260910-jab band nur `createForUser` (persoenliche Zeilen mit gesetztem
|
||||||
* bindet deshalb. `listForUser`/`createPlatform`/`remove` beruehren (auch)
|
* Mandanten). `listForUser` bindet seit
|
||||||
* plattformweite Zeilen (`userId`/`tenantId` NULL) und MUESSEN ungebunden
|
* 20260910120000_rls_widen_membership_grant_and_platform_read ZUSAETZLICH —
|
||||||
* bleiben — eine Bindung wuerde die plattformweite Quelle unter jedem
|
* die neue Leseregel schliesst die plattformweiten Zeilen ausdruecklich
|
||||||
* Mandanten verschwinden lassen bzw. das Einfuegen/Entfernen ohne Mandant
|
* ein, eine Bindung macht sie nicht mehr unsichtbar. `createPlatform`/
|
||||||
* ablehnen (Aufgabe 1, Befund D). `__makeBoundClient()` liefert denselben
|
* `remove` beruehren weiterhin (auch) plattformweite Zeilen
|
||||||
* protokollierenden Wrapper wie bei den vier vollstaendig gebundenen
|
* (`userId`/`tenantId` NULL) und MUESSEN ungebunden bleiben — eine Bindung
|
||||||
* Diensten dieses Bereichs (Muster aus `groups.service.spec.ts`).
|
* wuerde das Einfuegen/Entfernen ohne Mandant ablehnen, unveraendert seit
|
||||||
|
* jeher (Aufgabe 1 von 260909-laa, Befund D; WINDOWS #24 haelt diese
|
||||||
|
* Einschraenkung als eigenen offenen Punkt fest). `__makeBoundClient()`
|
||||||
|
* liefert denselben protokollierenden Wrapper wie bei den vier
|
||||||
|
* vollstaendig gebundenen Diensten dieses Bereichs (Muster aus
|
||||||
|
* `groups.service.spec.ts`) — KEINE Identitaets-Attrappe: der ungebundene
|
||||||
|
* Fake protokolliert nicht, der gebundene schon.
|
||||||
*/
|
*/
|
||||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)),
|
||||||
@@ -273,7 +279,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
label: 'b',
|
label: 'b',
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await service.listForUser('u-anyone');
|
const list = await service.listForUser('u-anyone', 'tenant-a');
|
||||||
expect(list.map((f: any) => f.label)).toEqual(['a', 'b']);
|
expect(list.map((f: any) => f.label)).toEqual(['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,10 +300,10 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
{ url: 'https://b-only.example-tenders.invalid/rss.xml', label: 'b-only' },
|
{ url: 'https://b-only.example-tenders.invalid/rss.xml', label: 'b-only' },
|
||||||
);
|
);
|
||||||
|
|
||||||
const listForA = await service.listForUser('user-a');
|
const listForA = await service.listForUser('user-a', 'tenant-a');
|
||||||
expect(listForA.map((f: any) => f.label).sort()).toEqual(['a-only', 'platform-feed']);
|
expect(listForA.map((f: any) => f.label).sort()).toEqual(['a-only', 'platform-feed']);
|
||||||
|
|
||||||
const listForB = await service.listForUser('user-b');
|
const listForB = await service.listForUser('user-b', 'tenant-b');
|
||||||
expect(listForB.map((f: any) => f.label).sort()).toEqual(['b-only', 'platform-feed']);
|
expect(listForB.map((f: any) => f.label).sort()).toEqual(['b-only', 'platform-feed']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -489,9 +495,9 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Bindung an forTenant() — WINDOWS #19 (260909-laa, Aufgabe 2) --------
|
// --- Bindung an forTenant() — WINDOWS #19 (260909-laa/260910-jab) -------
|
||||||
|
|
||||||
describe('Bindung an forTenant() — WINDOWS #19 (260909-laa)', () => {
|
describe('Bindung an forTenant() — WINDOWS #19 (260909-laa/260910-jab)', () => {
|
||||||
it('createForUser() bindet Zaehler UND Anlage an den uebergebenen Mandanten', async () => {
|
it('createForUser() bindet Zaehler UND Anlage an den uebergebenen Mandanten', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
@@ -505,10 +511,16 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
expectBoundCall(prisma, 'tenant-a', 'create');
|
expectBoundCall(prisma, 'tenant-a', 'create');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listForUser() bindet NICHT — forTenant() wird nicht aufgerufen (WINDOWS #19)', async () => {
|
// Umkehr von 'listForUser() bindet NICHT' (260910-jab, Aufgabe 2): seit
|
||||||
|
// 20260910120000_rls_widen_membership_grant_and_platform_read schliesst
|
||||||
|
// die Leseregel die plattformweiten Zeilen ausdruecklich ein — ein
|
||||||
|
// gebundener Lesezugriff macht sie NICHT mehr unsichtbar. listForUser()
|
||||||
|
// bindet deshalb jetzt, ueber denselben Zwei-Klienten-Nachweis
|
||||||
|
// (`__makeBoundClient`/`boundCallLog`) wie die uebrigen Dienste dieses
|
||||||
|
// Bereichs — keine Identitaets-Attrappe.
|
||||||
|
it('listForUser() bindet — forTenant() wird mit der uebergebenen Mandantenkennung aufgerufen', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
vi.mocked(forTenant).mockClear();
|
|
||||||
|
|
||||||
await service.createPlatform({
|
await service.createPlatform({
|
||||||
url: 'https://platform.example-tenders.invalid/rss.xml',
|
url: 'https://platform.example-tenders.invalid/rss.xml',
|
||||||
@@ -516,12 +528,13 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(forTenant).mockClear();
|
vi.mocked(forTenant).mockClear();
|
||||||
|
|
||||||
await service.listForUser('u-anyone');
|
await service.listForUser('u-anyone', 'tenant-a');
|
||||||
|
|
||||||
expect(forTenant).not.toHaveBeenCalled();
|
expect(forTenant).toHaveBeenCalledWith(prisma, 'tenant-a');
|
||||||
|
expectBoundCall(prisma, 'tenant-a', 'findMany');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createPlatform() bindet NICHT — forTenant() wird nicht aufgerufen (WINDOWS #19)', async () => {
|
it('createPlatform() bindet NICHT — forTenant() wird nicht aufgerufen (WINDOWS #19/#24: unter der Anwendungsrolle laesst sich eine plattformweite Zeile weder anlegen noch entfernen, unveraendert seit 260910-jab)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
vi.mocked(forTenant).mockClear();
|
vi.mocked(forTenant).mockClear();
|
||||||
@@ -534,7 +547,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
expect(forTenant).not.toHaveBeenCalled();
|
expect(forTenant).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove() bindet NICHT — forTenant() wird nicht aufgerufen, auch nicht fuer einen Administrator (WINDOWS #19)', async () => {
|
it('remove() bindet NICHT — forTenant() wird nicht aufgerufen, auch nicht fuer einen Administrator (WINDOWS #19/#24, unveraendert seit 260910-jab)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new TenderRssFeedSourceService(prisma as any);
|
const service = new TenderRssFeedSourceService(prisma as any);
|
||||||
const created = await service.createPlatform({
|
const created = await service.createPlatform({
|
||||||
@@ -557,7 +570,7 @@ describe('TenderRssFeedSourceService', () => {
|
|||||||
label: 'platform',
|
label: 'platform',
|
||||||
});
|
});
|
||||||
|
|
||||||
const list = await service.listForUser('u-anyone');
|
const list = await service.listForUser('u-anyone', 'tenant-a');
|
||||||
|
|
||||||
expect(list.map((f: any) => f.label)).toContain('platform');
|
expect(list.map((f: any) => f.label)).toContain('platform');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,16 +48,29 @@ export class TenderRssFeedSourceService {
|
|||||||
* Every platform-wide feed (`userId = null`) plus this user's own
|
* Every platform-wide feed (`userId = null`) plus this user's own
|
||||||
* personal feeds, oldest first (D-02).
|
* personal feeds, oldest first (D-02).
|
||||||
*
|
*
|
||||||
* Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): dieser Pfad
|
* Gebunden seit 20260910120000_rls_widen_membership_grant_and_platform_read
|
||||||
* liest auch die plattformweiten Zeilen (`userId = null`, `tenantId =
|
* (WINDOWS #19, 260910-jab, Aufgabe 2) — die Regelaenderung DREHT die
|
||||||
* null`) — die ausgelieferte Policy auf `TenderRssFeedSource` lautet
|
* Fehlerrichtung dieses Pfades um. Vorher (ausgelieferte Policy
|
||||||
* `"tenantId" = current_tenant_id()` und vergleicht `NULL` nie gleich.
|
* `"tenantId" = current_tenant_id()`, vergleicht `NULL` nie gleich) hätte
|
||||||
* Ein gebundener Lesezugriff würde die plattformweite Quelle
|
* ein gebundener Lesezugriff die plattformweite Quelle
|
||||||
* (`service.bund.de`) für JEDEN Mandanten verschwinden lassen — gemessen
|
* (`service.bund.de`) für JEDEN Mandanten unsichtbar gemacht — deshalb
|
||||||
* in `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar`.
|
* blieb dieser Pfad bis 260910-jab bewusst ungebunden und liefert
|
||||||
|
* ungebunden nach dem Scharfschalten NICHTS (eine schreiende Leere,
|
||||||
|
* gemessen in `tenderrssfeed-ungebunden-nur-die-plattformzeile`). Die neue
|
||||||
|
* Leseregel (`tenant_platform_read_policy`) schließt die plattformweiten
|
||||||
|
* Zeilen jetzt ausdrücklich ein — ungebunden läge dieser Pfad nach dem
|
||||||
|
* Scharfschalten deshalb bei NUR den plattformweiten Zeilen: eine kurze,
|
||||||
|
* glaubhafte Teilantwort statt einer leeren, die den Nutzer die eigenen
|
||||||
|
* fehlenden Feeds nie melden ließe. Gebunden liefert derselbe Pfad das
|
||||||
|
* Richtige: eigene UND plattformweite Zeilen (gemessen in
|
||||||
|
* `tenderrssfeed-plattformzeile-gebunden-sichtbar` und
|
||||||
|
* `tenderrssfeed-eigene-zeile-gebunden-weiterhin-sichtbar`). Der
|
||||||
|
* bestehende `OR`-Filter bleibt zusätzlich stehen — er ist nach der
|
||||||
|
* Bindung nicht überflüssig, sondern das zweite Netz.
|
||||||
*/
|
*/
|
||||||
async listForUser(userId: string) {
|
async listForUser(userId: string, tenantId: string) {
|
||||||
return this.prisma.tenderRssFeedSource.findMany({
|
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||||
|
return tenantPrisma.tenderRssFeedSource.findMany({
|
||||||
where: { OR: [{ userId: null }, { userId }] },
|
where: { OR: [{ userId: null }, { userId }] },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
@@ -106,10 +119,18 @@ export class TenderRssFeedSourceService {
|
|||||||
* MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself
|
* MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself
|
||||||
* enforces no authorization (T-17-08, done in TendersController).
|
* enforces no authorization (T-17-08, done in TendersController).
|
||||||
*
|
*
|
||||||
* Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): das Einfuegen
|
* Bewusst UNGEBUNDEN (WINDOWS #19/#24, 260910-jab, Aufgabe 2, an der neuen
|
||||||
* setzt `tenantId = NULL` — ein gebundenes INSERT liefe in die
|
* Regel richtiggestellt): das Einfuegen setzt `tenantId = NULL` — ein
|
||||||
* WITH-CHECK-Wirkung der ausgelieferten Policy und wuerde abgewiesen,
|
* gebundenes INSERT liefe jetzt in die ausdrueckliche `WITH CHECK`-Klausel
|
||||||
* gemessen in `tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt`.
|
* der Einfuegeregel (`tenant_insert_policy`,
|
||||||
|
* 20260910120000_rls_widen_membership_grant_and_platform_read) und wuerde
|
||||||
|
* abgewiesen, gemessen in
|
||||||
|
* `tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt`. Das gilt
|
||||||
|
* VOR wie NACH dieser Regelaenderung unveraendert — eine plattformweite
|
||||||
|
* Zeile laesst sich unter der Anwendungsrolle grundsaetzlich nicht
|
||||||
|
* anlegen, weil jede Schreibregel einen Mandanten verlangt. Kein
|
||||||
|
* Verwaltungsweg dafuer existiert heute; WINDOWS #24 haelt das als eigenen
|
||||||
|
* offenen Punkt fest, der NICHT mit #19 verschwindet.
|
||||||
*/
|
*/
|
||||||
async createPlatform(dto: TenderRssFeedDto) {
|
async createPlatform(dto: TenderRssFeedDto) {
|
||||||
this.assertUrlAllowed(dto.url);
|
this.assertUrlAllowed(dto.url);
|
||||||
@@ -135,13 +156,23 @@ export class TenderRssFeedSourceService {
|
|||||||
* `ForbiddenException` — so the response never confirms whether a
|
* `ForbiddenException` — so the response never confirms whether a
|
||||||
* feed with that id exists at all.
|
* feed with that id exists at all.
|
||||||
*
|
*
|
||||||
* Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): fuer einen
|
* Bewusst UNGEBUNDEN (WINDOWS #19/#24, 260910-jab, Aufgabe 2, an der neuen
|
||||||
* Administrator deckt dieser Pfad auch das Entfernen einer plattformweiten
|
* Regel richtiggestellt): fuer einen Administrator deckt dieser Pfad auch
|
||||||
* Zeile ab (`userId = null`) — gebunden koennte niemand mehr eine
|
* das Entfernen einer plattformweiten Zeile ab (`userId = null`) — ein
|
||||||
* plattformweite Quelle entfernen. Den einen bedingten `deleteMany` in
|
* gebundenes DELETE liefe in die ausdrueckliche Loeschregel
|
||||||
* zwei Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu
|
* (`tenant_delete_policy`,
|
||||||
* binden, wuerde genau das Pruef-/Nutzungsfenster wieder oeffnen, das
|
* 20260910120000_rls_widen_membership_grant_and_platform_read), die einen
|
||||||
* dieser Kommentar oben vermeidet — deshalb bleibt die gesamte Methode
|
* Mandanten verlangt, und traefe die plattformweite Zeile NIE (0
|
||||||
|
* betroffene Zeilen, kein Fehler — gemessen in
|
||||||
|
* `tenderrssfeed-gebundenes-loeschen-der-plattformzeile-abgelehnt`). Das
|
||||||
|
* gilt VOR wie NACH dieser Regelaenderung unveraendert: eine
|
||||||
|
* plattformweite Zeile laesst sich unter der Anwendungsrolle
|
||||||
|
* grundsaetzlich nicht entfernen. Kein Verwaltungsweg dafuer existiert
|
||||||
|
* heute; WINDOWS #24 haelt das als eigenen offenen Punkt fest, der NICHT
|
||||||
|
* mit #19 verschwindet. Den einen bedingten `deleteMany` in zwei
|
||||||
|
* Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu binden,
|
||||||
|
* wuerde ausserdem das Pruef-/Nutzungsfenster wieder oeffnen, das dieser
|
||||||
|
* Kommentar oben (T-17-07) vermeidet — deshalb bleibt die gesamte Methode
|
||||||
* ungebunden, nicht nur ihre plattformweite Haelfte.
|
* ungebunden, nicht nur ihre plattformweite Haelfte.
|
||||||
*/
|
*/
|
||||||
async remove(id: string, ctx: { userId: string; isAdmin: boolean }) {
|
async remove(id: string, ctx: { userId: string; isAdmin: boolean }) {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1', role: Role = Role.
|
|||||||
*/
|
*/
|
||||||
function makeFakeRssFeedService() {
|
function makeFakeRssFeedService() {
|
||||||
return {
|
return {
|
||||||
listForUser: vi.fn(async (_userId: string) => [] as any[]),
|
listForUser: vi.fn(async (_userId: string, _tenantId: string) => [] as any[]),
|
||||||
createForUser: vi.fn(
|
createForUser: vi.fn(
|
||||||
async (ctx: { userId: string; tenantId: string }, dto: any) => ({
|
async (ctx: { userId: string; tenantId: string }, dto: any) => ({
|
||||||
id: 'feed-1',
|
id: 'feed-1',
|
||||||
@@ -901,7 +901,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)',
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D-14/D-08, ownership split Phase 17 Plan 02 D-02)', () => {
|
describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D-14/D-08, ownership split Phase 17 Plan 02 D-02)', () => {
|
||||||
it('GET /rss-feeds delegates to tenderRssFeedSource.listForUser(userId) and maps isPlatformWide, stripping userId', async () => {
|
it('GET /rss-feeds delegates to tenderRssFeedSource.listForUser(userId, tenantId) and maps isPlatformWide, stripping userId', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any;
|
||||||
const rssFeedService = makeFakeRssFeedService();
|
const rssFeedService = makeFakeRssFeedService();
|
||||||
@@ -921,7 +921,7 @@ describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D
|
|||||||
|
|
||||||
const result = await controller.listRssFeeds(makeFakeRequest('u1', 'tenant1'));
|
const result = await controller.listRssFeeds(makeFakeRequest('u1', 'tenant1'));
|
||||||
|
|
||||||
expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1');
|
expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1', 'tenant1');
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', isPlatformWide: true },
|
{ id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', isPlatformWide: true },
|
||||||
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', isPlatformWide: false },
|
{ id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', isPlatformWide: false },
|
||||||
|
|||||||
@@ -264,10 +264,10 @@ export class TendersController {
|
|||||||
@Get('rss-feeds')
|
@Get('rss-feeds')
|
||||||
@UseModule('tender-radar')
|
@UseModule('tender-radar')
|
||||||
async listRssFeeds(@Req() req: Request) {
|
async listRssFeeds(@Req() req: Request) {
|
||||||
const { userId } = this.extractTriageContext(req);
|
const { userId, tenantId } = this.extractTriageContext(req);
|
||||||
const feeds = await this.tenderRssFeedSource.listForUser(userId);
|
const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId);
|
||||||
|
|
||||||
return feeds.map(({ userId: ownerUserId, ...rest }) => ({
|
return feeds.map(({ userId: ownerUserId, ...rest }: any) => ({
|
||||||
...rest,
|
...rest,
|
||||||
isPlatformWide: ownerUserId === null,
|
isPlatformWide: ownerUserId === null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user