-
Notifications
You must be signed in to change notification settings - Fork 0
FR Recipe SCIM Entra
Provisionner automatiquement les comptes et groupes depuis Microsoft Entra ID vers votre application, sans écrire de code de synchronisation. Entra pousse les changements (création, mise à jour, désactivation) en appelant un endpoint SCIM 2.0 que vous exposez ; Kengela fournit les handlers, la validation, la sérialisation et le mapping vers le modèle interne. Vous, vous branchez la persistance et l'authentification du jeton.
Microsoft Entra ID ──HTTP(S) SCIM 2.0──▶ Votre endpoint /scim/v2/*
(Enterprise App, │
Provisioning "Automatic") ▼
@kengela/scim-server (handlers PURS)
handleUsersPost / handleUsersPatch / …
handleGroupsPost / handleGroupsPatch / …
handleServiceProviderConfig / handleSchemas / …
│
┌─────────────────┴──────────────────┐
▼ ▼
ScimStore (votre @kengela/iam-mapping
adapter Prisma) profileFromScim → DirectoryProfile
│ evaluateMappings → rôles + unité
▼
Base de l'application
Entra parle SCIM. Le cœur @kengela/scim-server traduit chaque requête SCIM en un appel de
port de persistance et renvoie une réponse SCIM conforme. Le mapping des rôles internes
est un second temps, alimenté par @kengela/iam-mapping.
-
Handlers Users —
handleUsersPost,handleUsersPostStrict,handleUsersGet,handleUsersList,handleUsersPatch,handleUsersPut,handleUsersDelete. -
Handlers Groups —
handleGroupsPost,handleGroupsGet,handleGroupsList,handleGroupsPatch,handleGroupsPut,handleGroupsDelete. -
Découverte (auto-description) —
handleServiceProviderConfig,handleResourceTypes,handleSchemas(+ leurs générateurs pursserviceProviderConfig(),resourceTypes(),schemaDefinitions()). -
Validation —
validateScimUser,validateScimGroup→ScimValidationResult. -
Sérialisation / parsing —
toScimUser,toScimGroup,userListResponse,groupListResponse,scimError,parseUserPatch,parseGroupMemberPatch,parseUserNameFilter,parseExternalIdFilter,parseDisplayNameFilter,parsePagination, et les extracteurs de corpsemailOf/givenNameOf/familyNameOf/displayNameOf/groupDisplayNameOf/externalIdOf/activeOf/memberIdsOf. -
Mapping —
profileFromScim,evaluateMappings(dans@kengela/iam-mapping).
-
L'implémentation du port
ScimStore(la vraie persistance Prisma/SQL). -
Le montage HTTP : un routeur Express ou un contrôleur NestJS qui parse la requête,
résout le
tenantId, appelle le handler et sérialise laScimResponse. -
L'authentification du Bearer token que Entra envoie dans
Authorization.
Les handlers sont purs :
(store, ScimRequest) => Promise<ScimResponse>. Aucune dépendance à Express/Nest, aucune I/O directe. Ils sont testables sans réseau ni base.
npm install @kengela/scim-server @kengela/iam-mapping @kengela/contractsESM only ("type": "module"), TypeScript strict. Les imports internes sont en .js
(NodeNext).
Attention à la nomenclature — deux ports coexistent :
| Port | Paquet | Rôle |
|---|---|---|
ScimStore |
@kengela/scim-server |
Port NARROW consommé par les handlers. CRUD SCIM complet Users + Groups. C'est celui que vous implémentez pour cette recette. |
ScimRepository |
@kengela/contracts |
Port APPLICATIVE historique à 2 méthodes (upsertUserByEmail, deactivateUser). Orienté « pull/upsert par profil », pas CRUD SCIM. Non requis par les handlers. |
Les handlers SCIM de cette recette parlent à ScimStore (port CRUD riche, lignes
ScimUserRow…). ScimRepository (contracts) est un port de fédération MINIMAL et orienté
réconciliation — exactement deux méthodes vérifiées dans contracts/src/index.ts :
// @kengela/contracts
export interface ScimRepository {
upsertUserByEmail(
tenantId: TenantId,
profile: DirectoryProfile, // variante contracts (minimale)
): Promise<{ readonly id: string; readonly created: boolean }>;
deactivateUser(tenantId: TenantId, id: string): Promise<void>;
}Les deux ports ne sont pas interchangeables : ScimRepository est une VUE de
synchronisation par-dessus ScimStore. Le pont ScimStore → ScimRepository n'est pas
livré par le cœur, À DESSEIN (documenté dans contracts-projection.ts) : il devrait dépendre
à la fois de @kengela/scim-server et de @kengela/contracts, or iam-mapping est un paquet
CŒUR dont scim-server dépend déjà — l'inverse créerait un cycle. Sa place est donc un
adapter de composition côté app. Le vrai point dur — projeter n'importe quelle source IdP vers
une forme commune — est résolu par toContractsProfile (§5). Une fois le profil contracts
obtenu, l'appel est direct :
const profile = toContractsProfile(profileFromScim(body), { source: 'scim', active });
await scimRepository.upsertUserByEmail(tenantId, profile);import type { TenantId } from '@kengela/contracts';
export interface ScimStore {
getUser(tenantId: TenantId, id: string): Promise<ScimUserRow | null>;
findUserByEmail(tenantId: TenantId, email: string): Promise<ScimUserRow | null>;
listUsers(tenantId: TenantId, options: ScimUserListOptions): Promise<ScimListPage<ScimUserRow>>;
createUser(tenantId: TenantId, input: ScimUserWriteInput): Promise<ScimUserRow>;
replaceUser(
tenantId: TenantId,
id: string,
input: ScimUserWriteInput,
): Promise<ScimUserRow | null>;
patchUser(tenantId: TenantId, id: string, patch: ScimUserPatch): Promise<ScimUserRow | null>;
deactivateUser(tenantId: TenantId, id: string): Promise<ScimUserRow | null>;
getGroup(tenantId: TenantId, id: string): Promise<ScimGroupRow | null>;
listGroups(
tenantId: TenantId,
options: ScimGroupListOptions,
): Promise<ScimListPage<ScimGroupRow>>;
createGroup(tenantId: TenantId, input: ScimGroupWriteInput): Promise<ScimGroupRow>;
replaceGroup(
tenantId: TenantId,
id: string,
input: ScimGroupWriteInput,
): Promise<ScimGroupRow | null>;
patchGroup(
tenantId: TenantId,
id: string,
ops: readonly GroupMemberPatch[],
): Promise<ScimGroupRow | null>;
deleteGroup(tenantId: TenantId, id: string): Promise<boolean>;
}Invariants exigés par le contrat (types.ts) :
-
findUserByEmail: réconciliation insensible à la casse (idempotence du provisioning ;handleUsersPosts'en sert pour ne jamais créer de doublon). -
deactivateUser: désactive (active=false), ne supprime jamais physiquement (déprovisionnement RGPD-safe).handleUsersDeleteappelle cette méthode. -
listUsers/listGroups:totalResults= total filtré avant pagination.
interface ScimUserRow {
readonly id: string;
readonly userName: string; // porte l'e-mail (clé de réconciliation)
readonly externalId: string | null;
readonly firstName: string | null;
readonly lastName: string | null;
readonly displayName: string | null;
readonly active: boolean;
readonly createdAt: string; // ISO 8601
readonly lastModified: string; // ISO 8601
}
interface ScimUserWriteInput {
// POST + PUT
readonly userName: string;
readonly externalId: string | null;
readonly firstName: string | null;
readonly lastName: string | null;
readonly displayName: string | null;
readonly active: boolean;
}
interface ScimUserPatch {
// PATCH normalisé
readonly active: boolean | null; // null = non touché
readonly identity: {
// undefined = non touché ; null = effacé
readonly firstName?: string | null;
readonly lastName?: string | null;
readonly displayName?: string | null;
};
}
type GroupMemberPatch =
| { readonly kind: 'add'; readonly members: readonly string[] }
| { readonly kind: 'remove'; readonly members: readonly string[] }
| { readonly kind: 'replace'; readonly members: readonly string[] };import type {
ScimStore,
ScimUserRow,
ScimGroupRow,
ScimUserWriteInput,
ScimUserPatch,
ScimGroupWriteInput,
GroupMemberPatch,
ScimUserListOptions,
ScimGroupListOptions,
ScimListPage,
} from '@kengela/scim-server';
import type { TenantId } from '@kengela/contracts';
import type { PrismaClient } from '@prisma/client';
const iso = (d: Date) => d.toISOString();
function toUserRow(u: {
id: string;
userName: string;
externalId: string | null;
firstName: string | null;
lastName: string | null;
displayName: string | null;
active: boolean;
createdAt: Date;
updatedAt: Date;
}): ScimUserRow {
return {
id: u.id,
userName: u.userName,
externalId: u.externalId,
firstName: u.firstName,
lastName: u.lastName,
displayName: u.displayName,
active: u.active,
createdAt: iso(u.createdAt),
lastModified: iso(u.updatedAt),
};
}
export class PrismaScimStore implements ScimStore {
constructor(private readonly db: PrismaClient) {}
async getUser(tenantId: TenantId, id: string): Promise<ScimUserRow | null> {
const u = await this.db.scimUser.findFirst({ where: { id, tenantId } });
return u ? toUserRow(u) : null;
}
async findUserByEmail(tenantId: TenantId, email: string): Promise<ScimUserRow | null> {
const u = await this.db.scimUser.findFirst({
where: { tenantId, userName: { equals: email, mode: 'insensitive' } }, // insensible à la casse
});
return u ? toUserRow(u) : null;
}
async listUsers(tenantId: TenantId, o: ScimUserListOptions): Promise<ScimListPage<ScimUserRow>> {
const where = {
tenantId,
...(o.userName ? { userName: { equals: o.userName, mode: 'insensitive' as const } } : {}),
...(o.externalId ? { externalId: o.externalId } : {}), // caseExact
};
const [total, rows] = await this.db.$transaction([
this.db.scimUser.count({ where }),
this.db.scimUser.findMany({
where,
skip: o.startIndex - 1,
take: o.count,
orderBy: { createdAt: 'asc' },
}),
]);
return {
resources: rows.map(toUserRow),
totalResults: total,
startIndex: o.startIndex,
itemsPerPage: rows.length,
};
}
async createUser(tenantId: TenantId, i: ScimUserWriteInput): Promise<ScimUserRow> {
return toUserRow(await this.db.scimUser.create({ data: { tenantId, ...i } }));
}
async replaceUser(
tenantId: TenantId,
id: string,
i: ScimUserWriteInput,
): Promise<ScimUserRow | null> {
const found = await this.db.scimUser.findFirst({ where: { id, tenantId } });
if (!found) return null;
return toUserRow(await this.db.scimUser.update({ where: { id }, data: { ...i } }));
}
async patchUser(tenantId: TenantId, id: string, p: ScimUserPatch): Promise<ScimUserRow | null> {
const found = await this.db.scimUser.findFirst({ where: { id, tenantId } });
if (!found) return null;
const data: Record<string, unknown> = {};
if (p.active !== null) data['active'] = p.active;
if (p.identity.firstName !== undefined) data['firstName'] = p.identity.firstName; // null = effacé
if (p.identity.lastName !== undefined) data['lastName'] = p.identity.lastName;
if (p.identity.displayName !== undefined) data['displayName'] = p.identity.displayName;
return toUserRow(await this.db.scimUser.update({ where: { id }, data }));
}
async deactivateUser(tenantId: TenantId, id: string): Promise<ScimUserRow | null> {
const found = await this.db.scimUser.findFirst({ where: { id, tenantId } });
if (!found) return null;
return toUserRow(await this.db.scimUser.update({ where: { id }, data: { active: false } }));
}
// ── Groups ────────────────────────────────────────────────────────────────
async getGroup(tenantId: TenantId, id: string): Promise<ScimGroupRow | null> {
/* … */ return null;
}
async listGroups(
tenantId: TenantId,
o: ScimGroupListOptions,
): Promise<ScimListPage<ScimGroupRow>> {
/* même schéma que listUsers, filtre displayName eq */ return {
resources: [],
totalResults: 0,
startIndex: o.startIndex,
itemsPerPage: 0,
};
}
async createGroup(tenantId: TenantId, i: ScimGroupWriteInput): Promise<ScimGroupRow> {
/* … */ throw new Error('impl');
}
async replaceGroup(
tenantId: TenantId,
id: string,
i: ScimGroupWriteInput,
): Promise<ScimGroupRow | null> {
return null;
}
async patchGroup(
tenantId: TenantId,
id: string,
ops: readonly GroupMemberPatch[],
): Promise<ScimGroupRow | null> {
// appliquer add/remove/replace sur la table de jointure membre↔groupe, borné au tenant
return null;
}
async deleteGroup(tenantId: TenantId, id: string): Promise<boolean> {
return false;
}
}L'adapter fait quatre choses : (1) authentifie le Bearer token, (2) résout le
tenantId, (3) construit un ScimRequest, (4) appelle le handler et sérialise la
ScimResponse en application/scim+json.
export interface ScimRequest {
readonly tenantId: TenantId;
readonly pathId?: string; // segment /:id
readonly query?: {
readonly filter?: string;
readonly startIndex?: string | number;
readonly count?: string | number;
};
readonly body?: unknown; // JSON déjà désérialisé
}
export interface ScimResponse {
readonly status: number;
readonly body?: Readonly<Record<string, unknown>>;
}import express from 'express';
import {
handleUsersPost,
handleUsersGet,
handleUsersList,
handleUsersPatch,
handleUsersPut,
handleUsersDelete,
handleGroupsPost,
handleGroupsGet,
handleGroupsList,
handleGroupsPatch,
handleGroupsPut,
handleGroupsDelete,
handleServiceProviderConfig,
handleResourceTypes,
handleSchemas,
validateScimUser,
validateScimGroup,
scimError,
} from '@kengela/scim-server';
export function scimRouter(store: ScimStore, resolveTenant: (req: express.Request) => TenantId) {
const r = express.Router();
r.use(express.json({ type: ['application/json', 'application/scim+json'] }));
// (1) Auth du Bearer token — voir §8
r.use((req, res, next) => {
if (!isValidBearer(req.header('authorization'))) {
return res
.status(401)
.type('application/scim+json')
.json(scimError(401, 'Jeton porteur invalide.', 'invalidCredentials'));
}
next();
});
const send = (res: express.Response, out: { status: number; body?: object }) =>
out.body === undefined
? res.status(out.status).end()
: res.status(out.status).type('application/scim+json').json(out.body);
const reqOf = (req: express.Request): ScimRequest => ({
tenantId: resolveTenant(req),
pathId: req.params['id'],
query: {
filter: req.query['filter'] as string | undefined,
startIndex: req.query['startIndex'] as string | undefined,
count: req.query['count'] as string | undefined,
},
body: req.body,
});
// ── Découverte (aucun store) ──────────────────────────────────────────────
r.get('/ServiceProviderConfig', (_req, res) => send(res, handleServiceProviderConfig()));
r.get('/ResourceTypes/:id?', (req, res) => send(res, handleResourceTypes(req.params['id'])));
r.get('/Schemas/:id?', (req, res) => send(res, handleSchemas(req.params['id'])));
// ── Users ─────────────────────────────────────────────────────────────────
r.post('/Users', async (req, res) => {
const v = validateScimUser(req.body); // validation d'entrée
if (!v.valid)
return send(res, { status: 400, body: scimError(400, v.errors.join(' '), 'invalidValue') });
send(res, await handleUsersPost(store, reqOf(req)));
});
r.get('/Users', async (req, res) => send(res, await handleUsersList(store, reqOf(req))));
r.get('/Users/:id', async (req, res) => send(res, await handleUsersGet(store, reqOf(req))));
r.put('/Users/:id', async (req, res) => {
const v = validateScimUser(req.body);
if (!v.valid)
return send(res, { status: 400, body: scimError(400, v.errors.join(' '), 'invalidValue') });
send(res, await handleUsersPut(store, reqOf(req)));
});
r.patch('/Users/:id', async (req, res) => send(res, await handleUsersPatch(store, reqOf(req))));
r.delete('/Users/:id', async (req, res) => send(res, await handleUsersDelete(store, reqOf(req))));
// ── Groups ──────────────────────────────────────────────────────────────── (mêmes 6 verbes,
// valider avec validateScimGroup sur POST/PUT)
r.post('/Groups', async (req, res) => {
const v = validateScimGroup(req.body);
if (!v.valid)
return send(res, { status: 400, body: scimError(400, v.errors.join(' '), 'invalidValue') });
send(res, await handleGroupsPost(store, reqOf(req)));
});
r.get('/Groups', async (req, res) => send(res, await handleGroupsList(store, reqOf(req))));
r.get('/Groups/:id', async (req, res) => send(res, await handleGroupsGet(store, reqOf(req))));
r.put('/Groups/:id', async (req, res) => send(res, await handleGroupsPut(store, reqOf(req))));
r.patch('/Groups/:id', async (req, res) => send(res, await handleGroupsPatch(store, reqOf(req))));
r.delete('/Groups/:id', async (req, res) =>
send(res, await handleGroupsDelete(store, reqOf(req))),
);
return r;
}Montage : app.use('/scim/v2', scimRouter(store, resolveTenant)).
Un @Controller('scim/v2') reproduit exactement le même câblage : une méthode par
verbe/ressource, un ScimAuthGuard pour le Bearer, un helper qui transforme le résultat en
réponse application/scim+json. Les handlers restant purs, le contrôleur ne contient que du
transport.
Point d'attention Kengela : ne PAS décorer avec
@Controller({ version })sans avoir activéenableVersioning, sinon 404. Le versionnage SCIM (/v2) se fait dans le chemin, pas via l'URI versioning Nest.
validateScimUser / validateScimGroup renvoient { valid: boolean; errors: readonly string[] }.
Contrôles : schemas présent/non vide/URNs reconnues ; attribut requis présent (userName
pour User, displayName pour Group) ; types scalaires ; multi-valués bien formés. C'est une
validation fail-closed de VOTRE schéma, à l'entrée comme en sortie (round-trip :
toScimUser(row) repasse validateScimUser). Les filtres et la pagination sont parsés par
les handlers eux-mêmes via parseUserNameFilter/parseExternalIdFilter/parsePagination ;
inutile de les traiter dans l'adapter.
Deux temps distincts : la persistance SCIM (§3-4) accepte le flux Entra, puis un job de mapping projette ces données vers les rôles applicatifs.
import { profileFromScim, evaluateMappings } from '@kengela/iam-mapping';
const profile = profileFromScim(scimBody); // scimBody = corps SCIM brut poussé par Entra
// → DirectoryProfile (variante iam-mapping) :
// { email, externalId, firstName, lastName, displayName, attributes, groups, claims }Deux DirectoryProfile cohabitent — ne pas les confondre :
@kengela/iam-mapping (riche) |
@kengela/contracts (minimal) |
|
|---|---|---|
| Retourné par |
profileFromScim, profileFromGraph, … |
port DirectorySourcePort
|
email |
string (obligatoire, lowercasé) |
email?: string |
externalId |
string | null |
string (obligatoire) |
| Identité |
firstName/lastName/displayName
|
displayName? seul |
attributes |
DirectoryAttributes typé |
Record<string, unknown> |
active / source
|
absents | présents |
claims |
présent (règles avancées) | absent |
profileFromScim produit la variante riche. C'est elle que le moteur de règles consomme.
Pour alimenter le port ScimRepository / DirectorySourcePort de contracts (variante
minimale), n'écrivez pas de projection à la main : @kengela/iam-mapping exporte la
fonction PURE toContractsProfile(rich, { source, active }) qui projette le
DirectoryProfile riche vers la forme minimale de contracts. Les deux champs absents du
profil riche (active, source) sont fournis explicitement par l'appelant :
import { profileFromScim, toContractsProfile } from '@kengela/iam-mapping';
import { activeOf } from '@kengela/scim-server';
// scimBody = corps SCIM brut poussé par Entra
const rich = profileFromScim(scimBody); // DirectoryProfile riche (iam-mapping)
const active = activeOf(scimBody); // état d'activation lu du corps SCIM
const profile = toContractsProfile(rich, { source: 'scim', active });
// → DirectoryProfile MINIMAL (contracts) : { externalId, email?, displayName?,
// groups, attributes, active, source }Ce que toContractsProfile garantit, par lecture de contracts-projection.ts :
-
externalIdnon-null :rich.externalId, à défaut l'e-mail (repli stable) ; jamaisundefinedcôté contracts. -
email/displayName: omis (et nonundefined) s'ils sont vides — respecteexactOptionalPropertyTypes. -
firstName/lastName: reversés dansattributes(le profil contracts n'a pas de champ nom dédié) — rien n'est perdu. -
claimsbruts NON reportés : volume + PII potentielle.
profileFromScim accepte un ScimAttributeMap optionnel (config tenant) pour surcharger les
chemins lus, champ par champ. Défauts (SCIM_DEFAULT_ATTRIBUTE_KEYS) : email =
userName puis emails[primary], firstName = name.givenName, department =
enterprise.department, etc. L'extension enterprise est lue sous l'URN
urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.
import type { IdpMappingRule } from '@kengela/iam-mapping';
const rules: IdpMappingRule[] = [
{
id: 'admins',
priority: 0,
stopOnMatch: true,
any: [{ source: 'GROUP', op: 'iequals', value: 'Kengela-Admins' }],
assignRoleKeys: ['ADM'],
},
{
id: 'compta',
priority: 10,
all: [{ source: 'ATTRIBUTE', key: 'department', op: 'iequals', value: 'Finance' }],
assignRoleKeys: ['VAL'],
orgUnit: { by: 'name', fromAttribute: 'department' },
},
];
const result = evaluateMappings(profile, rules);
// → { roleKeys, orgUnitDirectives, matchedRuleIds }Le moteur est déterministe (tri par priority puis id), accumule les rôles en union,
respecte stopOnMatch. Les règles sont configurables par tenant (jamais en dur). Les
conditions testent les groups, les claims OIDC ou les attributes SCIM, avec les
opérateurs equals/iequals/contains/matches/in/present. matches compile une
regex bornée anti-ReDoS (safeRegexTest, fail-closed).
Le pipeline complet côté app : profileFromScim(body) → evaluateMappings(profile, rules)
→ appliquer roleKeys + orgUnitDirectives via vos repos (grants + rattachement).
Dans le portail Entra : Entreprise applications → (votre app) → Provisioning, mode Automatic. Section Admin Credentials :
-
Tenant URL = l'URL publique de votre endpoint, terminant par le point de montage SCIM,
p. ex.
https://app.exemple.com/scim/v2. Entra ajoute lui-même/Users,/Groups, etc. -
Secret Token = le Bearer token que vous générez et que votre app validera (§8). Entra
l'enverra dans l'en-tête
Authorization: Bearer <token>de chaque requête. - Bouton Test Connection : Entra appelle
GET /ServiceProviderConfig,GET /Schemas,GET /ResourceTypes, puis unGET /Users?filter=userName eq "..."et unGET /Users?filter=externalId eq "...". Les deux filtres sont supportés parhandleUsersList— indispensable pour que le test passe.
Attribute Mappings (section Mappings) : conserver les mappings SCIM standard d'Entra.
Les défauts d'Entra correspondent aux chemins lus par profileFromScim :
| Attribut Entra | Chemin SCIM émis | Lu par |
|---|---|---|
userPrincipalName |
userName |
emailOf / email
|
mail |
emails[type eq "work"].value |
emailOf (repli) |
givenName |
name.givenName |
givenNameOf |
surname |
name.familyName |
familyNameOf |
displayName |
displayName |
displayNameOf |
objectId |
externalId |
externalIdOf |
isSoftDeleted (inversé) |
active |
activeOf |
department |
enterprise:department |
profileFromScim (attributs) |
Pour provisionner aussi les groupes, activer « Provision Microsoft Entra ID Groups » et
assigner les groupes à l'application. Entra crée alors les groupes via POST /Groups et gère
les membres via PATCH /Groups/:id (members[value eq "<id>"] pour les retraits ciblés,
géré par parseGroupMemberPatch).
Microsoft fournit un validateur SCIM (« Test the SCIM endpoint compatibility », module PowerShell / Postman collection publiée par Microsoft) qui rejoue la suite d'appels attendue par Entra. À défaut, le bouton Test Connection du portail exerce le chemin critique.
Endpoints obligatoires SCIM 2.0 et leur couverture :
| Endpoint | Fournisseur Kengela | Statut |
|---|---|---|
/Users (POST/GET/PUT/PATCH/DELETE + list+filter) |
handlers users.ts
|
✅ couvert |
/Groups (POST/GET/PUT/PATCH/DELETE + list+filter) |
handlers groups.ts
|
✅ couvert |
/ServiceProviderConfig |
handleServiceProviderConfig (discovery.ts) |
✅ couvert |
/Schemas (+ /Schemas/:urn) |
handleSchemas (discovery.ts) |
✅ couvert |
/ResourceTypes (+ /ResourceTypes/:id) |
handleResourceTypes (discovery.ts) |
✅ couvert |
discovery.ts couvre les trois endpoints de découverte. serviceProviderConfig() déclare
les capacités réelles du cœur : patch supporté, filter supporté (borné à MAX_PAGE_SIZE),
bulk/sort/etag/changePassword non supportés, schéma d'authentification
oauthbearertoken. schemaDefinitions() décrit core User (RFC 7643 §4.1), extension
enterprise (§4.3) et Group (§4.2) — exactement ce que toScimUser/toScimGroup savent
porter, et ce que validateScimUser vérifie (round-trip garanti).
Points que le validateur Entra contrôle et qui sont déjà gérés :
-
Idempotence : un
POST /Usersd'un e-mail existant renvoie 200 sans doublon (handleUsersPostréconcilie viafindUserByEmail). Si l'IdP attend un rejet strict de doublon (409uniqueness), câblerhandleUsersPostStrictà la place. -
Filtre par
externalId:GET /Users?filter=externalId eq "..."supporté. -
Déprovisionnement :
DELETE /Users/:iddésactive (204), ne supprime pas. -
Erreurs SCIM :
scimErrorproduit l'enveloppe RFC 7644 §3.12 (statusen chaîne +scimType+detail).
┌──────────────────────────────────────────────────────────────────────────────┐
│ FOURNI PAR KENGELA (aucune ligne à écrire) │
│ • Handlers Users + Groups (CRUD SCIM, réconciliation, désactivation) │
│ • Découverte : ServiceProviderConfig / Schemas / ResourceTypes (discovery.ts) │
│ • Validation : validateScimUser / validateScimGroup │
│ • Sérialisation/parsing : toScimUser, parseUserPatch, parseGroupMemberPatch, │
│ filtres eq bornés, pagination, scimError │
│ • Mapping : profileFromScim → DirectoryProfile, evaluateMappings → rôles │
├──────────────────────────────────────────────────────────────────────────────┤
│ À ÉCRIRE PAR L'APPLICATION │
│ • ScimStore : l'implémentation Prisma/SQL réelle (§3) │
│ – insensibilité à la casse sur findUserByEmail │
│ – désactivation ≠ suppression │
│ – totalResults avant pagination │
│ • Montage de l'endpoint : routeur Express OU contrôleur NestJS (§4) │
│ – parse corps + query, résolution tenantId, sérialisation scim+json │
│ • Authentification du Bearer token (§6) : │
│ – comparer le jeton Entra en TEMPS CONSTANT (timing-safe) │
│ – stocker le secret hors code (Vault/env), rotation possible │
│ – 401 + scimError('invalidCredentials') si absent/invalide │
│ • Application des résultats de mapping (grants + rattachement org) │
└──────────────────────────────────────────────────────────────────────────────┘
Sécurité du Bearer : Entra n'utilise pas OAuth2 côté client par défaut mais un jeton
long-vécu ; traitez-le comme un secret de premier plan. Comparaison à temps constant
(crypto.timingSafeEqual), jamais de log du jeton, HTTPS obligatoire, et idéalement une
allow-list d'IP Entra en amont. C'est le seul verrou d'accès à un endpoint qui écrit dans
votre annuaire : ne le sous-traitez pas à un middleware générique sans le vérifier.
Un seul fichier qui assemble tout le code fonctionnel de la page : l'adapter ScimStore
Prisma, l'authentification Bearer à temps constant, le routeur Express (découverte + Users +
Groups) et le pipeline de mapping SCIM → rôles → ScimRepository.
import { timingSafeEqual } from 'node:crypto';
import express from 'express';
import type { PrismaClient } from '@prisma/client';
import type { TenantId } from '@kengela/contracts';
import type { ScimRepository } from '@kengela/contracts';
import {
handleUsersPost,
handleUsersGet,
handleUsersList,
handleUsersPatch,
handleUsersPut,
handleUsersDelete,
handleGroupsPost,
handleGroupsGet,
handleGroupsList,
handleGroupsPatch,
handleGroupsPut,
handleGroupsDelete,
handleServiceProviderConfig,
handleResourceTypes,
handleSchemas,
validateScimUser,
validateScimGroup,
scimError,
activeOf,
type ScimStore,
type ScimUserRow,
type ScimGroupRow,
type ScimUserWriteInput,
type ScimUserPatch,
type ScimGroupWriteInput,
type GroupMemberPatch,
type ScimUserListOptions,
type ScimGroupListOptions,
type ScimListPage,
type ScimRequest,
} from '@kengela/scim-server';
import {
profileFromScim,
toContractsProfile,
evaluateMappings,
type IdpMappingRule,
} from '@kengela/iam-mapping';
// ── 1. Adapter de persistance : ScimStore (Prisma) ──────────────────────────
const iso = (d: Date) => d.toISOString();
function toUserRow(u: {
id: string;
userName: string;
externalId: string | null;
firstName: string | null;
lastName: string | null;
displayName: string | null;
active: boolean;
createdAt: Date;
updatedAt: Date;
}): ScimUserRow {
return {
id: u.id,
userName: u.userName,
externalId: u.externalId,
firstName: u.firstName,
lastName: u.lastName,
displayName: u.displayName,
active: u.active,
createdAt: iso(u.createdAt),
lastModified: iso(u.updatedAt),
};
}
export class PrismaScimStore implements ScimStore {
constructor(private readonly db: PrismaClient) {}
async getUser(tenantId: TenantId, id: string): Promise<ScimUserRow | null> {
const u = await this.db.scimUser.findFirst({ where: { id, tenantId } });
return u ? toUserRow(u) : null;
}
async findUserByEmail(tenantId: TenantId, email: string): Promise<ScimUserRow | null> {
const u = await this.db.scimUser.findFirst({
where: { tenantId, userName: { equals: email, mode: 'insensitive' } }, // insensible à la casse
});
return u ? toUserRow(u) : null;
}
async listUsers(tenantId: TenantId, o: ScimUserListOptions): Promise<ScimListPage<ScimUserRow>> {
const where = {
tenantId,
...(o.userName ? { userName: { equals: o.userName, mode: 'insensitive' as const } } : {}),
...(o.externalId ? { externalId: o.externalId } : {}), // caseExact
};
const [total, rows] = await this.db.$transaction([
this.db.scimUser.count({ where }),
this.db.scimUser.findMany({
where,
skip: o.startIndex - 1,
take: o.count,
orderBy: { createdAt: 'asc' },
}),
]);
return {
resources: rows.map(toUserRow),
totalResults: total, // total filtré AVANT pagination
startIndex: o.startIndex,
itemsPerPage: rows.length,
};
}
async createUser(tenantId: TenantId, i: ScimUserWriteInput): Promise<ScimUserRow> {
return toUserRow(await this.db.scimUser.create({ data: { tenantId, ...i } }));
}
async replaceUser(
tenantId: TenantId,
id: string,
i: ScimUserWriteInput,
): Promise<ScimUserRow | null> {
const found = await this.db.scimUser.findFirst({ where: { id, tenantId } });
if (!found) return null;
return toUserRow(await this.db.scimUser.update({ where: { id }, data: { ...i } }));
}
async patchUser(tenantId: TenantId, id: string, p: ScimUserPatch): Promise<ScimUserRow | null> {
const found = await this.db.scimUser.findFirst({ where: { id, tenantId } });
if (!found) return null;
const data: Record<string, unknown> = {};
if (p.active !== null) data['active'] = p.active;
if (p.identity.firstName !== undefined) data['firstName'] = p.identity.firstName; // null = effacé
if (p.identity.lastName !== undefined) data['lastName'] = p.identity.lastName;
if (p.identity.displayName !== undefined) data['displayName'] = p.identity.displayName;
return toUserRow(await this.db.scimUser.update({ where: { id }, data }));
}
async deactivateUser(tenantId: TenantId, id: string): Promise<ScimUserRow | null> {
const found = await this.db.scimUser.findFirst({ where: { id, tenantId } });
if (!found) return null; // désactive, ne supprime JAMAIS
return toUserRow(await this.db.scimUser.update({ where: { id }, data: { active: false } }));
}
// Groups : même schéma, filtre displayName eq, patch add/remove/replace borné au tenant.
async getGroup(_tenantId: TenantId, _id: string): Promise<ScimGroupRow | null> {
return null;
}
async listGroups(
_tenantId: TenantId,
o: ScimGroupListOptions,
): Promise<ScimListPage<ScimGroupRow>> {
return { resources: [], totalResults: 0, startIndex: o.startIndex, itemsPerPage: 0 };
}
async createGroup(_tenantId: TenantId, _i: ScimGroupWriteInput): Promise<ScimGroupRow> {
throw new Error('impl');
}
async replaceGroup(
_tenantId: TenantId,
_id: string,
_i: ScimGroupWriteInput,
): Promise<ScimGroupRow | null> {
return null;
}
async patchGroup(
_tenantId: TenantId,
_id: string,
_ops: readonly GroupMemberPatch[],
): Promise<ScimGroupRow | null> {
return null;
}
async deleteGroup(_tenantId: TenantId, _id: string): Promise<boolean> {
return false;
}
}
// ── 2. Auth Bearer à temps constant (secret hors code) ──────────────────────
function isValidBearer(header: string | undefined): boolean {
const expected = process.env.SCIM_BEARER_TOKEN ?? '';
if (!header?.startsWith('Bearer ') || expected === '') return false;
const provided = header.slice('Bearer '.length);
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b); // comparaison timing-safe
}
// ── 3. Routeur Express (découverte + Users + Groups) ────────────────────────
export function scimRouter(store: ScimStore, resolveTenant: (req: express.Request) => TenantId) {
const r = express.Router();
r.use(express.json({ type: ['application/json', 'application/scim+json'] }));
r.use((req, res, next) => {
if (!isValidBearer(req.header('authorization'))) {
return res
.status(401)
.type('application/scim+json')
.json(scimError(401, 'Jeton porteur invalide.', 'invalidCredentials'));
}
next();
});
const send = (res: express.Response, out: { status: number; body?: object }) =>
out.body === undefined
? res.status(out.status).end()
: res.status(out.status).type('application/scim+json').json(out.body);
const reqOf = (req: express.Request): ScimRequest => ({
tenantId: resolveTenant(req),
pathId: req.params['id'],
query: {
filter: req.query['filter'] as string | undefined,
startIndex: req.query['startIndex'] as string | undefined,
count: req.query['count'] as string | undefined,
},
body: req.body,
});
// Découverte (aucun store) — les trois endpoints de discovery.ts.
r.get('/ServiceProviderConfig', (_req, res) => send(res, handleServiceProviderConfig()));
r.get('/ResourceTypes/:id?', (req, res) => send(res, handleResourceTypes(req.params['id'])));
r.get('/Schemas/:id?', (req, res) => send(res, handleSchemas(req.params['id'])));
// Users.
r.post('/Users', async (req, res) => {
const v = validateScimUser(req.body);
if (!v.valid)
return send(res, { status: 400, body: scimError(400, v.errors.join(' '), 'invalidValue') });
send(res, await handleUsersPost(store, reqOf(req)));
});
r.get('/Users', async (req, res) => send(res, await handleUsersList(store, reqOf(req))));
r.get('/Users/:id', async (req, res) => send(res, await handleUsersGet(store, reqOf(req))));
r.put('/Users/:id', async (req, res) => {
const v = validateScimUser(req.body);
if (!v.valid)
return send(res, { status: 400, body: scimError(400, v.errors.join(' '), 'invalidValue') });
send(res, await handleUsersPut(store, reqOf(req)));
});
r.patch('/Users/:id', async (req, res) => send(res, await handleUsersPatch(store, reqOf(req))));
r.delete('/Users/:id', async (req, res) => send(res, await handleUsersDelete(store, reqOf(req))));
// Groups.
r.post('/Groups', async (req, res) => {
const v = validateScimGroup(req.body);
if (!v.valid)
return send(res, { status: 400, body: scimError(400, v.errors.join(' '), 'invalidValue') });
send(res, await handleGroupsPost(store, reqOf(req)));
});
r.get('/Groups', async (req, res) => send(res, await handleGroupsList(store, reqOf(req))));
r.get('/Groups/:id', async (req, res) => send(res, await handleGroupsGet(store, reqOf(req))));
r.put('/Groups/:id', async (req, res) => send(res, await handleGroupsPut(store, reqOf(req))));
r.patch('/Groups/:id', async (req, res) => send(res, await handleGroupsPatch(store, reqOf(req))));
r.delete('/Groups/:id', async (req, res) =>
send(res, await handleGroupsDelete(store, reqOf(req))),
);
return r;
}
// ── 4. Pipeline mapping SCIM → rôles + projection contracts ─────────────────
const rules: IdpMappingRule[] = [
{
id: 'admins',
priority: 0,
stopOnMatch: true,
any: [{ source: 'GROUP', op: 'iequals', value: 'Kengela-Admins' }],
assignRoleKeys: ['ADM'],
},
{
id: 'compta',
priority: 10,
all: [{ source: 'ATTRIBUTE', key: 'department', op: 'iequals', value: 'Finance' }],
assignRoleKeys: ['VAL'],
orgUnit: { by: 'name', fromAttribute: 'department' },
},
];
export async function onScimUserPushed(
tenantId: TenantId,
scimBody: Record<string, unknown>,
scimRepository: ScimRepository,
): Promise<void> {
const rich = profileFromScim(scimBody); // DirectoryProfile riche
const result = evaluateMappings(rich, rules); // { roleKeys, orgUnitDirectives, matchedRuleIds }
// Projection RICHE → MINIMAL (contracts), puis réconciliation.
const profile = toContractsProfile(rich, { source: 'scim', active: activeOf(scimBody) });
await scimRepository.upsertUserByEmail(tenantId, profile);
// … appliquer result.roleKeys + result.orgUnitDirectives via vos repos (grants + rattachement).
void result;
}
// ── 5. Montage ──────────────────────────────────────────────────────────────
// const store = new PrismaScimStore(prisma);
// app.use('/scim/v2', scimRouter(store, (req) => resolveTenantId(req)));🇬🇧 English
- Home
- Getting started
- Architecture
- Authorization
- Authentication
- Identity federation
- Compliance & PII
- NestJS integration
- Developing an adapter
- Security
Recipes
- NestJS + native + Prisma
- better-auth
- SCIM / Entra
- LDAP / AD
- RBAC / ABAC
- PII / GDPR
- Combo: better-auth + PII
- Combo: SCIM/Entra + authz
- Combo: full stack
🇫🇷 Français
- Accueil
- Prise en main
- Architecture
- Autorisation
- Authentification
- Fédération d'identité
- Conformité & PII
- Intégration NestJS
- Développer un adapter
- Sécurité
Recettes