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:
2026-09-10 14:35:13 +02:00
parent f4f3115d5a
commit 6b237351e9
9 changed files with 133 additions and 64 deletions
+10 -6
View File
@@ -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;
@@ -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);
+12 -7
View File
@@ -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);
@@ -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') {
+2 -1
View File
@@ -81,7 +81,8 @@ function tablesWithPolicy(sql: string): Set<string> {
const JOIN_PATTERN_TABLES: Record<string, string> = {
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<string, string> = {
@@ -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');
});
+51 -20
View File
@@ -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 }) {
@@ -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 },
+3 -3
View File
@@ -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,
}));