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=falseof 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_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.
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:
- 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.findByUuid.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).
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.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.
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