SCIM-provisioning in Tapster
Dit document legt uit hoe SCIM-provisioning vanuit Azure AD (Entra ID) werkt in Tapster, hoe authenticatie en tenant-routing zijn opgelost, en welke mapping plaatsvindt tussen Azure-groups en Tapster-rollen. Voor de concrete admin-stappen, zie de how-to: SCIM-koppeling configureren. Voor de auth-keuze, zie ADR 0002.
Wat SCIM in Tapster doet
SCIM (System for Cross-domain Identity Management) is een protocol waarmee een identity provider (in onze praktijk Azure AD) gebruikers en group-membership automatisch synchroniseert naar Tapster. De klant beheert wie toegang heeft op één centrale plek in Azure; Tapster volgt automatisch.
Concreet:
- Nieuwe Azure-gebruikers in scope krijgen automatisch een Tapster-account
met
scimProvisioned: true. - Wijzigingen in profielvelden (naam, e-mail,
active-status) worden naar Tapster gepatcht. - Group-membership wordt vertaald naar Tapster-rollen via een per-tenant mapping.
- Gebruikers die uit scope vallen, worden gedeactiveerd via een PATCH
active=false(state wordtINACTIVE) of via een DELETE (soft delete). Zieactiveenstate.
We ondersteunen alleen het Users-endpoint als SCIM-resource. Groups worden embedded meegestuurd binnen het user-object (Azure-conventie), niet als losse SCIM-resource. Voor de meeste klanten met Azure als bron is dat voldoende.
Architectuur
Het verkeer komt binnen op een dedicated route die expressTenant overslaat:
Azure SCIM
│ POST /scim/v2/<tenantUuid>/Users
│ Authorization: Bearer <SAT>
▼
scimRouter (mount in server.ts, vóór expressTenant)
│
▼
scimAuthMiddleware
│ 1. lees :tenantUuid uit URL
│ 2. lees Bearer-token uit header
│ 3. resolve tenant via tenantRepository.findByUuid
│ 4. check active + scimConfig.enabled
│ 5. mount tenant-context via asyncLocalStorage
│ 6. validateToken via serviceAccountTokenService
│ 7. zet req.tenant + req.user (service account)
▼
Scim.controller → Scim.service → userRepository
│
└─► ScimRoleMapping.service → rbacService
De route is bewust géén onderdeel van publicEndpointRouter of
protectedEndpointRouter. Beide hebben hun eigen tenant-resolutie en
auth-paden die slecht passen bij een Azure SCIM-client (die alleen een
Bearer-token meestuurt en geen sessie of host-header heeft). De aparte mount
maakt de uitzondering zichtbaar.
Authenticatie en tenant-routing
Twee dingen die normaal door verschillende middlewares worden afgehandeld, zijn voor SCIM gecombineerd in één plek.
Tenant uit het URL-pad
De Tenant-URL die de klant in Azure invult bevat de tenant-uuid:
https://api.tapster.app/scim/v2/<tenantUuid>. Daardoor weet de server al
vóór token-validatie welke tenant bedoeld is. expressTenant zou hier
terugvallen op de default-tenant, want Azure stuurt geen host- of
referer-header die naar de juiste tenant wijst.
Token via Service Account Token
De secret die de klant in Azure invult is een Service Account Token (SAT), hetzelfde tokentype dat ook andere server-to-server-integraties gebruiken. Een SAT is:
- per tenant aangemaakt (opgeslagen in
service_account_tokensop de tenant-DB) - 365 dagen geldig
- gehasht (SHA-256) opgeslagen, alleen
lastFourzichtbaar in de UI - intrekbaar via dezelfde UI waar hij is aangemaakt
Omdat SAT’s per tenant zijn, faalt een token van tenant A automatisch met 401 wanneer iemand het probeert op de URL van tenant B: de lookup gebeurt op de tenant-connection van de URL-tenant, en dáár bestaat het token niet.
Foutpaden
Alle 4xx/5xx-antwoorden gebruiken de SCIM-error envelope
(urn:ietf:params:scim:api:messages:2.0:Error):
| Scenario | Status |
|---|---|
| Geen tenant-uuid in URL | 401 |
| Geen of ongeldige Authorization-header | 401 |
| Tenant niet gevonden | 401 |
| Tenant inactief | 503 |
| SCIM niet enabled voor de tenant | 403 |
| Token ongeldig of verlopen | 401 |
| Onverwachte fout (bv. mongo niet bereikbaar) | 500 |
Het 401-antwoord is bewust uniform: we maken geen onderscheid tussen “tenant bestaat niet” en “token klopt niet”. Wel onderscheidt 403 (SCIM-uitgeschakeld) zich, omdat de klant in dat geval een knop in onze admin-UI moet aanzetten.
Throttling
Twee remmen, beide per tenant en beide Redis-backed zodat ze over alle pods gelden (issue #2424):
| Rem | Default | Env-variabele |
|---|---|---|
| Rate-limit op alle SCIM-endpoints | 60 requests per minuut | SCIM_RATE_LIMIT_MAX, SCIM_RATE_LIMIT_WINDOW_MS |
| Concurrency-cap op de schrijf-endpoints | 3 gelijktijdige writes | SCIM_CONCURRENCY_MAX |
Boven de drempel volgt 429 met Retry-After (SCIM_RETRY_AFTER_SECONDS,
default 60) in de SCIM-error envelope, waarna Entra de provisioning zelf
uitsmeert. Bij een Redis-storing zijn beide fail-open.
Wat er gebeurt bij het aanmaken van een gebruiker
Een POST /Users maakt het account aan via userService.register met twee
bewuste afwijkingen van de normale registratieflow (sendInvitation: false,
skipCreateEvent: true):
- Status is direct
ACTIVE(in de Backoffice: “Actief”). Er is geen activatiestap. - Er gaat geen uitnodigingsmail uit. De gebruiker hoort vanuit Tapster niets over het nieuwe account.
- Er wordt geen registratie-event aangemaakt. Een bulk-sync zou anders één
event-job per gebruiker genereren, wat de
event-queue floodt en via de recipient-fan-out MongoDB verzadigt (issue #2676).
Omdat het account meteen actief is, kan er ook geen uitnodiging meer voor
verstuurd worden: assertInvitable weigert dat expliciet voor ACTIVE. Een
SCIM-gebruiker komt binnen via Azure SSO (als dat voor de tenant aanstaat) of
via een inloglink (“Automagisch inloggen”, 15 minuten geldig) die de gebruiker
zelf aanvraagt of die een beheerder verstuurt.
Bij SSO matcht Tapster het UPN uit de Entra-token op het e-mailadres in Tapster. Wijkt het UPN af van het adres dat via SCIM binnenkomt, dan vindt de SSO-login de gebruiker niet.
active en state
SCIM kent één vlag (active: true|false), Tapster kent een bredere state
(ACTIVE, INACTIVE, NOT_ACTIVATED, TO_BE_REMOVED, REMOVED, TEMPORARY_DISABLED,
PENDING). De vertaling zit in ScimService en is bewust asymmetrisch
(issue #2976).
Binnenkomend (create, PUT en PATCH; Azure stuurt de waarde vaak als string
"True"/"False", die casten we eerst):
| Gevraagd | Huidige state | Resultaat |
|---|---|---|
false |
ACTIVE, NOT_ACTIVATED, PENDING | INACTIVE |
true |
INACTIVE | ACTIVE |
true |
ACTIVE, NOT_ACTIVATED, PENDING | geen wijziging |
| beide | TO_BE_REMOVED, REMOVED, TEMPORARY_DISABLED | geen wijziging, warning in de log |
Deactiveren mag dus vanuit elke niet-beschermde state, activeren alleen vanuit
INACTIVE. Zo haalt een sync geen uitnodiging in (een NOT_ACTIVATED-account
blijft in de uitnodigingsflow) en draait hij geen anonimiserings- of
blokkadetraject terug. Dat zijn bewuste beheerdersacties; een directory-sync
mag ze niet overschrijven.
Uitgaand (toScimUser) is active afgeleid van state: alles behalve
INACTIVE, TO_BE_REMOVED, REMOVED en TEMPORARY_DISABLED rapporteren we als
active: true. Een uitgenodigd account telt dus als actief. Dat is niet alleen
semantisch juist (het account bestaat en gaat werken), het voorkomt ook dat
Entra elke cycle een correctie-PATCH stuurt die wij toch niet uitvoeren.
Het User-schema heeft géén active-veld; we sturen die sleutel dan ook niet
meer mee richting de repository. Deed je dat wel, dan gooide mongoose hem in
strict mode stil weg. Precies dát was de oorzaak van issue #2976: deactiveren
vanuit Entra had geen enkel effect en GET meldde altijd active: true, zodat
Entra ook niets corrigeerde.
De toegang volgt hier direct uit: /login-by-token weigert INACTIVE,
TO_BE_REMOVED, REMOVED en TEMPORARY_DISABLED, ook op het SSO-pad.
Mapping van Azure-groups naar Tapster-rollen
Een Azure-gebruiker heeft typisch group-membership; een Tapster-gebruiker
heeft een set rollen (Rbac-records). De mapping zit in tenant.scimConfig
en wordt per tenant geconfigureerd door de Tapster-admin.
Configuratie
scimConfig:
enabled: true
azureTenantId: <optioneel>
roleMapping:
- azureGroupId: <Azure group object-id>
azureGroupName: <leesbare naam>
tapsterRoles: [<rbac-uuid>, <rbac-uuid>]
- ...
defaultRoles: [<rbac-uuid>]
De admin-UI toont daarnaast een toggle Automatisch rollen toewijzen
(scimConfig.autoAssignRoles). Die staat wel in het tenant-model, maar geen
enkele SCIM-codepad leest hem: rollen worden altijd direct toegekend. Behandel
de toggle als betekenisloos tot hij verwijderd of geïmplementeerd is.
De tapsterRoles-strings zijn Rbac-uuid-waarden, niet mongo-_id-strings.
We kozen voor uuid omdat die stabiel zijn bij re-seed van de Rbac-tabel en
direct uitwisselbaar tussen omgevingen.
Resolutie-stappen
ScimRoleMappingService.mapAzureGroupsToRoles doet:
- Loop door de Azure-groups van de gebruiker.
- Voor elke group die voorkomt in
roleMapping: voeg de gemaptetapsterRolestoe aan de set te resolven uuids. Mappings ontbreken? Log een warning maar crash niet. - Heeft de gebruiker minimaal één gematchte group? Resolveer alle uuids naar
Rbac-ObjectIds viarbacService.findByUuidOrLegacyId.mappingSource: 'azureGroups'. - Geen gematchte groups? Val terug op
defaultRoles.mappingSource: 'default'. - Ook geen defaults? Lege rollenset.
mappingSource: 'none'.
Het uuid-naar-ObjectId-pad gaat door rbacService zodat unieke uuids
gededupliceerd worden en de admin een warning ziet als een rbac-uuid in de
mapping niet bestaat (bv. handmatig verkeerd geconfigureerd of een Rbac is
verwijderd). De resolver accepteert naast rbac.uuid ook legacy
rbac._id-strings uit oude tenant-configuraties, met een warning die vraagt om
migratie.
Meerdere rollen per gebruiker
Meerdere rollen worden op drie niveaus ondersteund:
tapsterRolesper Azure-group is een lijst; de admin-UI gebruikt een multi-select.- Zit een gebruiker in meerdere gemapte groups, dan tellen de rollen op (met deduplicatie).
defaultRolesis eveneens een lijst.
Dat telt functioneel door: rechten worden bepaald als de vereniging van de
capabilities van álle rollen (permissionMatrixService.getCapabilitiesForUser),
en ook de modules-weergave kijkt naar de volledige rollenlijst.
Naast roles vult SCIM het afgeleide primaire rolveld rbac, bepaald via
dezelfde hiërarchie als bij handmatig aangemaakte accounts (Tapster >
Beheerder > Moderator > Moderator-light > Medewerker > Standaard > Anoniem).
Een aantal oudere onderdelen leunt nog op dat ene veld; daar geldt in de
praktijk de hoogste rol. Zonder gevuld rbac-veld is een account niet bruikbaar
(zie de fix in v15.40.0).
Audit-trail per user
Elke SCIM-provisioned user krijgt een scimRoleMapping-veld met:
source—azureGroups,defaultofnonemappedAt— timestamp van de laatste mappingmappedGroups— array van{ azureGroupId, azureGroupName, assignedRoles }
Plus scimGroups (de Azure-groups zoals laatst gesynchroniseerd) en
scimProvisioned: true. Het pre-validate-hook in userModel dwingt af dat
source en mappedAt aanwezig zijn voor SCIM-provisioned users; dat maakt
het onmogelijk om per ongeluk een SCIM-user zonder mapping-historie te
schrijven.
PATCH-handler en Azure’s quirks
Azure SCIM stuurt PATCH-operations die net afwijken van de strikte SCIM-RFC:
valuevoorpath: "active"komt vaak als string"True"of"False", niet als boolean. We casten via een lokalecastBool-helper en vertalen het resultaat naarstate(zieactiveenstate).op: "remove"voormembers/groupsheeft soms géénvalue, met als betekenis “leeg het hele attribuut”. We interpreteren dat als een lege group-set en herberekenen de rollen op basis daarvan.- Onbekende paden loggen we als warning maar laten we geen request mislukken. Azure’s batch-patches mogen niet aan één onbekend pad sneuvelen.
Voor path: "members" en path: "groups" triggeren we altijd een
herberekening van de Tapster-rollen via ScimRoleMappingService, omdat een
verandering in group-membership in vrijwel alle gevallen rolwijzigingen
betekent.
Verschil met andere tenant-flows
expressTenant resolveert de tenant via cache, session, host, referer of
header. SCIM gebruikt geen van die paden. Dat heeft drie gevolgen:
- Geen sessie-state, geen cookies. Elke SCIM-request is stateless.
- Geen
asyncLocalStorage-store vanuitexpressTenant. DescimAuthMiddlewarezet de store zelf op, zodat de service-laag (userRepository.getModel()etc.) op de juiste tenant-DB werkt. - Geen RBAC-permissies in de zin van de gewone routes. De ingelogde “user” is altijd het service-account van de tenant; de daadwerkelijke admin in Azure is voor Tapster onzichtbaar.
Bekende beperking: active wordt niet verwerkt
Het active-veld uit de SCIM-payload landt nergens. User heeft geen
active-veld (alleen state), dus mongoose gooit het in strict mode stil weg,
zowel bij create als bij update en patch. Gevolg:
POST /Usersmetactive: falselevert een account op metstate: ACTIVE.PATCHmetpath: "active"antwoordt 200, maar er verandert niets aan de toegang van de gebruiker.GET /Users/:idrapporteert altijdactive: trueterug aan Entra, omdattoScimUseruser.active !== falseevalueert op een veld dat niet bestaat. Entra ziet dus geen verschil en corrigeert niets.
Praktisch betekent dit dat offboarding via SCIM niet werkt zolang Entra de
standaardactie “soft delete” (active=false) gebruikt voor gebruikers die uit
scope raken. Alleen een echte DELETE /Users/:id deactiveert het account (soft
delete via userRepository.deleteById). Onboarding en rolwijzigingen werken
wel.
De fix is active mappen op state (false → INACTIVE, true → ACTIVE)
in create, update en patch, en toScimUser daarop laten antwoorden. De
inlogketen is daar klaar voor: /login-by-token weigert al de geblokkeerde
states, ook op het SSO-pad.
Wat we bewust niet ondersteunen
/Groups-endpoint als SCIM-resource. Azure stuurt groups embedded in user-payloads (het Tapster-pad), dus de SCIM-Groups-endpoint is overbodig voor onze klantensituatie. Okta of OneLogin gebruiken het wel, maar dat is geen huidige klantcase.- Volledige SCIM-filter parser. Alleen
userName eq "..."enexternalId eq "..."worden ondersteund. DeServiceProviderConfig.filter.supportedstaat opfalse, zodat klanten niet de illusie krijgen dat complexere filters werken. - Wachtwoord-management. SCIM-gebruikers loggen niet met wachtwoord in,
dus
ServiceProviderConfig.changePassword.supportedisfalse.
Verwante documenten
- ADR 0002: SCIM-auth via Service Account Token en tenant-uuid in URL
- How-to: SCIM-koppeling configureren
- Spec:
docs/superpowers/specs/2026-05-15-scim-fix-design.md - Plan:
docs/superpowers/plans/2026-05-15-scim-fix.md