Categorie-toegang op kenmerk
Een categorie (in de code Category, in de collectie challenges, in de UI “project”) kan aan kenmerken (tags) gekoppeld worden. Alleen gebruikers met minstens één van die kenmerken zien de categorie en de berichten erin. Deze pagina legt uit hoe dat werkt en waar het wordt afgedwongen.
Waarom het zo gebouwd is, en welke alternatieven zijn afgewogen, staat in ADR 0007. De uitleg voor de beheerder zelf staat in de userdocs bij apps/frontend/src/app/features/backoffice/settings/pages/projects/userdocs/index.md.
Het probleem dat het oplost
Rollen kunnen niet alles onderscheiden. Een zorgorganisatie heeft bijvoorbeeld een cliëntenraad en een familieraad. Beide groepen hebben dezelfde rol, dus Category.read (een lijst rollen) kan ze niet uit elkaar houden. Wat ze wel onderscheidt is een kenmerk op hun profiel.
Kenmerken zijn dus een tweede dimensie boven op rollen, geen vervanging. Je moet nog steeds leestoegang hebben via je rol; het kenmerk versmalt daarbinnen.
De regel
De hele feature hangt aan één module: apps/api/src/api/category/categoryTagAccess.ts. Die levert dezelfde beslissing in twee vormen.
buildCategoryTagFilter(user) geeft een Mongo-conditie voor lijstquery’s:
{ $or: [
{ tags: { $in: <kenmerk-ids van de gebruiker> } },
{ tags: { $exists: false } },
{ tags: { $size: 0 } },
] }
mayAccessCategoryByTags(category, user) is de in-memory variant, voor plekken waar de categorie al is opgehaald.
Daaruit volgt de semantiek:
| Situatie | Uitkomst |
|---|---|
| Categorie zonder kenmerken | Zichtbaar voor iedereen met de juiste rol |
| Categorie met kenmerken, gebruiker heeft er minstens één | Zichtbaar |
| Categorie met kenmerken, gebruiker heeft er geen | Onzichtbaar |
| Gebruiker zonder kenmerken | Ziet alleen categorieën zonder kenmerken |
Beheerder (admin, tapster, moderator, moderator_light) |
Ziet altijd alles |
| Service account of API-sleutel | Geen uitzondering, ziet alleen categorieën zonder kenmerken |
Meerdere kenmerken op één categorie is een OR: één match volstaat.
buildCategoryTagFilter geeft null terug wanneer er niet gefilterd moet worden, en dat betekent “niet filteren”, niet “geen toegang”. Dat is het enige punt waar de twee vormen uiteenlopen: bij een ontbrekende gebruiker filtert de Mongo-variant niet, terwijl de in-memory variant weigert. Dat is bewust, omdat systeemaanroepen (seed, digest-memoisatie) geen gebruiker hebben.
Waar het wordt afgedwongen
De filter zit in de categorie-laag, zodat alles wat categorieën ophaalt hem erft:
| Plek | Effect |
|---|---|
categoryService.getReadableCategories |
Navigatie (GET /categories/allowed) en menu (moduleService) |
categoryService.getReadableCategoriesForFeed |
Feed en sitemap |
categoryService.getSearchableCategories |
Zoekresultaten |
categoryService.getWritableCategories |
Schrijfrecht |
categoryService.getCorkBoardCategories |
Prikbord |
categoryRouter GET /categories en /categories/:id |
Categorie-metadata |
cardService.findPublishedWithPagination |
GET /me/cards, het pad achter de categoriepagina /o/:slug |
permissionService.setCardPermissions |
Kaartdetail (GET /cards/:id) |
commentService.listForCard |
Reacties op een kaart |
userService.getCorkBoardCategoryIdsForRoles |
Prikbordnotificatie |
Die laatste werkt anders dan de rest. Hij memoïseert per rol-set en kent dus geen gebruiker. In plaats van te filteren slaat hij categorieën met een niet-lege tags volledig over. Zet iemand toch een kenmerk op een prikbordcategorie, dan verdwijnt die uit de notificatie in plaats van te lekken.
Valkuilen
Een nieuw leespad erft niets vanzelf. De filter zit in de categorie-laag, maar een endpoint dat kaarten via een eigen query ophaalt, omzeilt hem. Dat is precies wat er gebeurde bij GET /me/cards: dat endpoint gaf elke gepubliceerde kaart terug voor een willekeurig meegegeven query[project], zonder rol- of kenmerkcheck. Bouw je een nieuw pad dat kaarten of categorieën naar buiten brengt, gebruik dan de helper.
Een projectie zonder tags laat de in-memory check stil open vallen. mayAccessCategoryByTags geeft true bij een categorie zonder kenmerken, en kan niet zien of tags ontbreekt of leeg is. Populeer je project met een select, neem tags dan mee. FEED_CATEGORY_PROJECTION in categoryRepository.ts bevat tags bewust niet, omdat die aanroepers aan de Mongo-kant filteren en de in-memory check daar niet draaien.
De user-parameter is verplicht, met opzet. getReadableCategories, getReadableCategoriesForFeed en getSearchableCategories eisen hem, zodat een vergeten aanroep een compilefout geeft in plaats van stil open te vallen. Maak hem niet optioneel om de compiler stil te krijgen.
Mongoose’ User-type matcht niet structureel. Op de aanroepplekken staat as any. De types in categoryTagAccess.ts zijn bewust smal gehouden; verbreed ze niet om een cast weg te werken.
Een kenmerk verwijderen verbreedt de toegang. tagService.removeTagReferencesFromAllCollections pullt verwijderde tag-ids ook uit categorieën. Blijven er geen kenmerken over, dan is de categorie per direct weer open voor iedereen met de juiste rol. Dat volgt de regel, maar het is een verbreding die wordt uitgelokt door een actie die daar niet over lijkt te gaan.
Wat het niet is
Het kenmerk bedient de juiste doelgroep; het is geen vertrouwelijkheidsgarantie tegenover de organisatie zelf. Twee dingen om te kennen voordat je het zo positioneert:
- Beheerders zien alles, ook zonder het kenmerk.
- Notificatie-ontvangers worden uitsluitend op rol bepaald, dus het onderwerp van een bericht kan bij iemand zonder het kenmerk in de mailbox landen. De link erin geeft wel “niet gevonden”. Zie issue #3434.
Het oude mechanisme
Tot en met versie 15.120 bestond Category.tagCategory: de categorie wees naar een hele kenmerkcategorie, en elke kaart moest zelf een tag uit die kenmerkcategorie dragen die ook op de gebruiker stond. Dat werkte per kaart in plaats van per categorie, werd alleen in de tijdlijn toegepast, en vergat je één kaart te taggen dan was die voor niemand meer zichtbaar. Geen enkele tenant had het ingevuld, dus het veld is verwijderd zonder migratie. Bestaande documenten mogen het houden; het wordt genegeerd.