ADR 0007: Categorie-toegang op kenmerk

  • Status: geaccepteerd
  • Datum: 2026-08-26
  • Beslissers: Roy Milder

Context

Een categorie (in de UI “project”) bevat soms berichten die maar voor een deel van de gebruikers bedoeld zijn. Het aanleidinggevende voorbeeld: een zorgorganisatie met een cliëntenraad en een familieraad. Beide groepen hebben dezelfde rol, dus de bestaande rolgebaseerde read/write op een categorie kan ze niet uit elkaar houden. Wat ze wel onderscheidt is een kenmerk (tag) op hun profiel.

Er bestond al een mechanisme, Category.tagCategory, maar dat werkte op kaartniveau: de categorie wees naar een hele kenmerkcategorie, en vervolgens moest elke afzonderlijke kaart een tag uit die kenmerkcategorie dragen die ook op de gebruiker stond. Dat model schoot op twee manieren tekort.

Het was te zwak. De filter werd alleen toegepast in userService.getFeedForUser. De categorie bleef zichtbaar in de navigatie, in zoekresultaten en in de sitemap, en GET /cards/:id keek uitsluitend naar rollen. Een directe link naar een afgeschermd bericht werkte dus gewoon.

Het was ook te omslachtig. De redactie moest elk bericht handmatig taggen, en een kaart zonder tag matchte $in: [] en was daarmee voor niemand zichtbaar.

Bij het ontwerp is vastgesteld dat segmentatie per bericht binnen één categorie niet nodig is: twee doelgroepen betekent twee categorieën. Geen enkele tenant had tagCategory ooit ingevuld, dus er was geen legacy en geen migratie.

Beslissing

We schermen categorieën af op kenmerkniveau in plaats van op kaartniveau, naar het model dat agenda’s al gebruiken (Calendar.tags). Het bestaande, ongebruikte veld Category.tags is daarvoor in gebruik genomen; tagCategory is verwijderd.

De regel staat op één plek, in apps/api/src/api/category/categoryTagAccess.ts, in twee vormen:

  • buildCategoryTagFilter(user) levert een Mongo-conditie voor lijstquery’s: een overlappend kenmerk, of een categorie zonder kenmerken. Geeft null terug als er niet gefilterd moet worden.
  • mayAccessCategoryByTags(category, user) is de in-memory variant voor plekken waar de categorie al opgehaald is.

De semantiek: een gebruiker ziet een categorie als hij minstens één overlappend kenmerk heeft, of als de categorie helemaal geen kenmerken draagt. Meerdere kenmerken is dus een OR. Het kenmerk werkt bovenop de rol en niet ervoor in de plaats.

Beheerders (admin, tapster, moderator, moderator_light) slaan de filter over. Zonder die uitzondering raakt een beheerder zonder het kenmerk de categorie kwijt uit navigatie, zoeken en feed, en kan hij hem niet meer beheren. service_account staat bewust niet in die lijst: integraties lezen geen afgeschermde categorieën.

Het kenmerk is daarmee een middel om de juiste doelgroep te bedienen, geen vertrouwelijkheidsgarantie tegenover de organisatie zelf.

Gevolgen

Positief

  • De beheerder koppelt één keer een kenmerk aan de categorie in plaats van elk bericht apart te taggen.
  • De regel staat op één plek, dus een nieuw leespad erft hem door de gedeelde helper te gebruiken in plaats van de logica te herhalen.
  • Doordat de filter in de categorie-laag zit, erven navigatie, feed, zoeken, sitemap en menu hem automatisch.
  • Een categorie zonder kenmerken gedraagt zich precies zoals voorheen, dus bestaande inrichting is ongemoeid gebleven.

Negatief

  • De user-parameter is verplicht gemaakt op getReadableCategories, getReadableCategoriesForFeed en getSearchableCategories. Dat is met opzet, zodat een vergeten aanroep een compilefout geeft in plaats van stil open te vallen, maar het raakt wel elke aanroeper.
  • Mongoose’ afgeleide User-type deelt structureel geen properties met de types in categoryTagAccess.ts. Op de aanroepplekken staat daarom een as any. Het alternatief, de types in de helper verbreden, is afgewezen omdat dat het publieke type verzwakt voor alle consumenten.
  • De afscherming raakt inmiddels tien leespaden. Een nieuw leespad dat de helper niet gebruikt, valt stil open.

Risico’s

  • userService.getCorkBoardCategoryIdsForRoles memoïseert per rol-set en kent geen gebruiker, dus daar wordt niet op kenmerken gefilterd. In plaats daarvan slaat dat pad categorieën met een niet-lege tags over. Zet iemand toch een kenmerk op een prikbordcategorie, dan verdwijnt die uit de prikbordnotificatie in plaats van te lekken.
  • Notificatie-ontvangers worden uitsluitend op rol bepaald, dus het onderwerp van een bericht kan bij iemand zonder het kenmerk in de mailbox landen. Bestaand gedrag, bewust buiten scope gehouden. Zie issue #3434.
  • mayAccessCategoryByTags geeft true bij een categorie zonder kenmerken. Levert een aanroeper een categorie aan waarvan tags door een projectie is weggelaten, dan valt de check stil open. De doc-comment op de functie legt vast dat de aanroeper een categorie met een echt tags-veld moet aanleveren.

Alternatieven

Het bestaande tagCategory-model repareren. We hadden de per-kaart-filter kunnen uitbreiden naar alle leespaden in plaats van hem te vervangen. Afgewezen omdat het model zelf niet past bij wat gevraagd werd: de beheerder wil een categorie als geheel afschermen, niet elk bericht apart taggen. Repareren had de omslachtigheid gehouden en alleen de gaten gedicht.

Alleen filteren in de feed. De kleinste diff: de bestaande plek uitbreiden en verder niets aanraken. Afgewezen omdat de categorie dan in de navigatie blijft staan, doorzoekbaar blijft en in de sitemap blijft. Dat is precies het gat dat er al zat.

Beheerders ook afschermen. Sterkste garantie, maar dan kan niemand meer modereren of een foutief bericht corrigeren zonder zichzelf het kenmerk te geven. In de praktijk loopt dat vast op beheer. Een tussenvorm (beheerders zien alles in de backoffice, maar in de gebruikersomgeving gelden de kenmerken ook voor hen) is overwogen en afgewezen omdat beheerders dan op twee plekken iets anders zien.

Een typecontrole op de gekoppelde tag. We hadden kunnen eisen dat een gekoppeld kenmerk uit een kenmerkcategorie van type PROJECT komt. Afgewezen: de beheerder koppelt in de UI een concrete tag uit dezelfde pool die ook op gebruikersprofielen staat, en een typecontrole zou alleen stille mismatches opleveren.