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 via een PATCH active=false of een DELETE gedeactiveerd.

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.

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 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.findByUuid. 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).

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.
  • 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.

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