diff --git a/apps/api/src/groups/groups.service.ts b/apps/api/src/groups/groups.service.ts index df0192b..2db039d 100644 --- a/apps/api/src/groups/groups.service.ts +++ b/apps/api/src/groups/groups.service.ts @@ -485,12 +485,16 @@ export class GroupsService { * exportiert sein. * * Prüft zusätzlich, dass der Zielbenutzer zu DIESEM Mandanten gehört - * (Befund E, T-JTS-02, 260909-jts): die ausgelieferte Policy auf - * GroupMembership prüft ausschließlich die Gruppenseite - * (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`), gemessen - * in Aufgabe 1 — die Benutzerseite prüft sie NICHT. Nach dem Vorbild von - * addMembers() zwei Methoden höher: Zielbenutzer auf den Mandanten - * filtern, bei keinem Treffer folgenlos zurückkehren statt zu werfen. + * (T-JTS-02, 260909-jts/260910-jab): die Policy auf GroupMembership prüfte + * bis 260910-jab ausschließlich die Gruppenseite + * (`groupId IN (SELECT id FROM "Group" WHERE tenantId = ...)`) — die + * Benutzerseite NICHT (Befund E, gemessen in Aufgabe 1 von 260909-jts). + * Seit 20260910120000_rls_widen_membership_grant_and_platform_read prüft + * 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) { const tenantPrisma = forTenant(this.prisma, tenantId) as any; diff --git a/apps/api/src/groups/module-grants.service.spec.ts b/apps/api/src/groups/module-grants.service.spec.ts index aa4a59b..e7c4b16 100644 --- a/apps/api/src/groups/module-grants.service.spec.ts +++ b/apps/api/src/groups/module-grants.service.spec.ts @@ -278,6 +278,15 @@ describe('ModuleGrantsService.grant', () => { 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 () => { const prisma = makeFakePrisma(); seedBase(prisma); diff --git a/apps/api/src/groups/module-grants.service.ts b/apps/api/src/groups/module-grants.service.ts index a8be223..c9dfcf2 100644 --- a/apps/api/src/groups/module-grants.service.ts +++ b/apps/api/src/groups/module-grants.service.ts @@ -91,13 +91,18 @@ export class ModuleGrantsService { } // Die Mandanten-Gegenpruefung bleibt ausdruecklich erhalten (T-JTS-03, - // 260909-jts, Aufgabe 1): die ausgelieferte Regel auf ModuleGrant - // prueft ausschliesslich die Mandantenkennung der Zeile selbst - // ("tenantId" = current_tenant_id()), NICHT die referenzierte Gruppe. - // Eine Zeile mit korrekter eigener Mandantenkennung, die auf die - // Gruppe eines fremden Mandanten zeigt, verletzt diese Regel - // nachweislich nicht (gemessen gegen die echte Migration in Aufgabe 1). - // Diese Anwendungspruefung ist damit der einzige Schutz gegen diese + // 260909-jts/260910-jab): bis Migration + // 20260910120000_rls_widen_membership_grant_and_platform_read pruefte + // die Regel auf ModuleGrant ausschliesslich die Mandantenkennung der + // Zeile selbst ("tenantId" = current_tenant_id()), + // NICHT die referenzierte Gruppe oder den referenzierten Benutzer — eine + // Zeile mit korrekter eigener Mandantenkennung, die auf die Gruppe/den + // 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 // Datenbank" entfallen. await this.assertTargetBelongsToTenant(tenantId, groupId, userId); diff --git a/apps/api/src/module-registry/module-access.service.ts b/apps/api/src/module-registry/module-access.service.ts index a6e7ee2..da90ae0 100644 --- a/apps/api/src/module-registry/module-access.service.ts +++ b/apps/api/src/module-registry/module-access.service.ts @@ -45,9 +45,15 @@ export class ModuleAccessService { // — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die // bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen: // sie sind das zweite Netz, nicht redundant — dieselbe Begruendung wie - // in `module-grants.service.ts` (die Regel auf `GroupMembership` prueft - // nachweislich nur die Gruppenseite, T-JTS-02, und die Regel auf - // `ModuleGrant` nur die Mandantenkennung der Zeile, T-JTS-03). + // in `module-grants.service.ts`. Seit + // 20260910120000_rls_widen_membership_grant_and_platform_read (260910-jab) + // 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; if (role === 'ADMIN' || role === 'SUPER_ADMIN') { diff --git a/apps/api/src/prisma/rls-coverage.spec.ts b/apps/api/src/prisma/rls-coverage.spec.ts index 590d2b0..1e2da49 100644 --- a/apps/api/src/prisma/rls-coverage.spec.ts +++ b/apps/api/src/prisma/rls-coverage.spec.ts @@ -81,7 +81,8 @@ function tablesWithPolicy(sql: string): Set { const JOIN_PATTERN_TABLES: Record = { PasswordResetToken: 'geschuetzt ueber Join userId -> User -> 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 = { diff --git a/apps/api/src/tenders/tender-rss-feed.service.spec.ts b/apps/api/src/tenders/tender-rss-feed.service.spec.ts index 5443586..2f1c331 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.spec.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.spec.ts @@ -4,15 +4,21 @@ import { forTenant } from '../prisma/prisma-tenant.extension'; import { TenderRssFeedSourceService } from './tender-rss-feed.service'; /** - * Bindung an forTenant() (260909-laa, WINDOWS #19): nur `createForUser` - * beruehrt ausschliesslich persoenliche Zeilen mit gesetztem Mandanten und - * bindet deshalb. `listForUser`/`createPlatform`/`remove` beruehren (auch) - * plattformweite Zeilen (`userId`/`tenantId` NULL) und MUESSEN ungebunden - * bleiben — eine Bindung wuerde die plattformweite Quelle unter jedem - * Mandanten verschwinden lassen bzw. das Einfuegen/Entfernen ohne Mandant - * ablehnen (Aufgabe 1, Befund D). `__makeBoundClient()` liefert denselben - * protokollierenden Wrapper wie bei den vier vollstaendig gebundenen - * Diensten dieses Bereichs (Muster aus `groups.service.spec.ts`). + * Bindung an forTenant() (260909-laa/260910-jab, WINDOWS #19): bis + * 260910-jab band nur `createForUser` (persoenliche Zeilen mit gesetztem + * Mandanten). `listForUser` bindet seit + * 20260910120000_rls_widen_membership_grant_and_platform_read ZUSAETZLICH — + * die neue Leseregel schliesst die plattformweiten Zeilen ausdruecklich + * ein, eine Bindung macht sie nicht mehr unsichtbar. `createPlatform`/ + * `remove` beruehren weiterhin (auch) plattformweite Zeilen + * (`userId`/`tenantId` NULL) und MUESSEN ungebunden bleiben — eine Bindung + * 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', () => ({ forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__makeBoundClient(tenantId)), @@ -273,7 +279,7 @@ describe('TenderRssFeedSourceService', () => { 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']); }); @@ -294,10 +300,10 @@ describe('TenderRssFeedSourceService', () => { { 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']); - 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']); }); @@ -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 () => { const prisma = makeFakePrisma(); const service = new TenderRssFeedSourceService(prisma as any); @@ -505,10 +511,16 @@ describe('TenderRssFeedSourceService', () => { 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 service = new TenderRssFeedSourceService(prisma as any); - vi.mocked(forTenant).mockClear(); await service.createPlatform({ url: 'https://platform.example-tenders.invalid/rss.xml', @@ -516,12 +528,13 @@ describe('TenderRssFeedSourceService', () => { }); 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 service = new TenderRssFeedSourceService(prisma as any); vi.mocked(forTenant).mockClear(); @@ -534,7 +547,7 @@ describe('TenderRssFeedSourceService', () => { 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 service = new TenderRssFeedSourceService(prisma as any); const created = await service.createPlatform({ @@ -557,7 +570,7 @@ describe('TenderRssFeedSourceService', () => { 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'); }); diff --git a/apps/api/src/tenders/tender-rss-feed.service.ts b/apps/api/src/tenders/tender-rss-feed.service.ts index 73e43d8..7a2c068 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.ts @@ -48,16 +48,29 @@ export class TenderRssFeedSourceService { * Every platform-wide feed (`userId = null`) plus this user's own * personal feeds, oldest first (D-02). * - * Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): dieser Pfad - * liest auch die plattformweiten Zeilen (`userId = null`, `tenantId = - * null`) — die ausgelieferte Policy auf `TenderRssFeedSource` lautet - * `"tenantId" = current_tenant_id()` und vergleicht `NULL` nie gleich. - * Ein gebundener Lesezugriff würde die plattformweite Quelle - * (`service.bund.de`) für JEDEN Mandanten verschwinden lassen — gemessen - * in `tenderrssfeed-plattformzeile-unter-jedem-mandanten-unsichtbar`. + * Gebunden seit 20260910120000_rls_widen_membership_grant_and_platform_read + * (WINDOWS #19, 260910-jab, Aufgabe 2) — die Regelaenderung DREHT die + * Fehlerrichtung dieses Pfades um. Vorher (ausgelieferte Policy + * `"tenantId" = current_tenant_id()`, vergleicht `NULL` nie gleich) hätte + * ein gebundener Lesezugriff die plattformweite Quelle + * (`service.bund.de`) für JEDEN Mandanten unsichtbar gemacht — deshalb + * 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) { - return this.prisma.tenderRssFeedSource.findMany({ + async listForUser(userId: string, tenantId: string) { + const tenantPrisma = forTenant(this.prisma, tenantId) as any; + return tenantPrisma.tenderRssFeedSource.findMany({ where: { OR: [{ userId: null }, { userId }] }, orderBy: { createdAt: 'asc' }, }); @@ -106,10 +119,18 @@ export class TenderRssFeedSourceService { * MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself * enforces no authorization (T-17-08, done in TendersController). * - * Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): das Einfuegen - * setzt `tenantId = NULL` — ein gebundenes INSERT liefe in die - * WITH-CHECK-Wirkung der ausgelieferten Policy und wuerde abgewiesen, - * gemessen in `tenderrssfeed-gebundenes-einfuegen-ohne-mandant-abgelehnt`. + * Bewusst UNGEBUNDEN (WINDOWS #19/#24, 260910-jab, Aufgabe 2, an der neuen + * Regel richtiggestellt): das Einfuegen setzt `tenantId = NULL` — ein + * gebundenes INSERT liefe jetzt in die ausdrueckliche `WITH CHECK`-Klausel + * 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) { this.assertUrlAllowed(dto.url); @@ -135,13 +156,23 @@ export class TenderRssFeedSourceService { * `ForbiddenException` — so the response never confirms whether a * feed with that id exists at all. * - * Bewusst UNGEBUNDEN (WINDOWS #19, 260909-laa, Aufgabe 1): fuer einen - * Administrator deckt dieser Pfad auch das Entfernen einer plattformweiten - * Zeile ab (`userId = null`) — gebunden koennte niemand mehr eine - * plattformweite Quelle entfernen. Den einen bedingten `deleteMany` in - * zwei Anweisungen zu zerlegen, um nur die persoenliche Haelfte zu - * binden, wuerde genau das Pruef-/Nutzungsfenster wieder oeffnen, das - * dieser Kommentar oben vermeidet — deshalb bleibt die gesamte Methode + * Bewusst UNGEBUNDEN (WINDOWS #19/#24, 260910-jab, Aufgabe 2, an der neuen + * Regel richtiggestellt): fuer einen Administrator deckt dieser Pfad auch + * das Entfernen einer plattformweiten Zeile ab (`userId = null`) — ein + * gebundenes DELETE liefe in die ausdrueckliche Loeschregel + * (`tenant_delete_policy`, + * 20260910120000_rls_widen_membership_grant_and_platform_read), die einen + * 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. */ async remove(id: string, ctx: { userId: string; isAdmin: boolean }) { diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 07569f2..72cd614 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -104,7 +104,7 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1', role: Role = Role. */ function makeFakeRssFeedService() { return { - listForUser: vi.fn(async (_userId: string) => [] as any[]), + listForUser: vi.fn(async (_userId: string, _tenantId: string) => [] as any[]), createForUser: vi.fn( async (ctx: { userId: string; tenantId: string }, dto: any) => ({ 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)', () => { - 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 scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; 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')); - expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1'); + expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1', 'tenant1'); expect(result).toEqual([ { 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 }, diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 6650cc5..3b72b88 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -264,10 +264,10 @@ export class TendersController { @Get('rss-feeds') @UseModule('tender-radar') async listRssFeeds(@Req() req: Request) { - const { userId } = this.extractTriageContext(req); - const feeds = await this.tenderRssFeedSource.listForUser(userId); + const { userId, tenantId } = this.extractTriageContext(req); + const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId); - return feeds.map(({ userId: ownerUserId, ...rest }) => ({ + return feeds.map(({ userId: ownerUserId, ...rest }: any) => ({ ...rest, isPlatformWide: ownerUserId === null, }));