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 wordt INACTIVE) of via een DELETE (soft delete). Zie active en state.

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_tokens op de tenant-DB)
  • 365 dagen geldig
  • gehasht (SHA-256) opgeslagen, alleen lastFour zichtbaar 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:

  1. Loop door de Azure-groups van de gebruiker.
  2. Voor elke group die voorkomt in roleMapping: voeg de gemapte tapsterRoles toe aan de set te resolven uuids. Mappings ontbreken? Log een warning maar crash niet.
  3. Heeft de gebruiker minimaal één gematchte group? Resolveer alle uuids naar Rbac-ObjectIds via rbacService.findByUuidOrLegacyId. mappingSource: 'azureGroups'.
  4. Geen gematchte groups? Val terug op defaultRoles. mappingSource: 'default'.
  5. 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:

  • tapsterRoles per 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).
  • defaultRoles is 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:

  • sourceazureGroups, default of none
  • mappedAt — timestamp van de laatste mapping
  • mappedGroups — 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:

  • value voor path: "active" komt vaak als string "True" of "False", niet als boolean. We casten via een lokale castBool-helper en vertalen het resultaat naar state (zie active en state).
  • op: "remove" voor members/groups heeft soms géén value, 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 vanuit expressTenant. De scimAuthMiddleware zet 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 /Users met active: false levert een account op met state: ACTIVE.
  • PATCH met path: "active" antwoordt 200, maar er verandert niets aan de toegang van de gebruiker.
  • GET /Users/:id rapporteert altijd active: true terug aan Entra, omdat toScimUser user.active !== false evalueert 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 (falseINACTIVE, trueACTIVE) 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 "..." en externalId eq "..." worden ondersteund. De ServiceProviderConfig.filter.supported staat op false, zodat klanten niet de illusie krijgen dat complexere filters werken.
  • Wachtwoord-management. SCIM-gebruikers loggen niet met wachtwoord in, dus ServiceProviderConfig.changePassword.supported is false.

Verwante documenten