/** * @file Ce fichier regroupe les fonctions génériques de recherche et de test utiles, mais trop puissantes pour être exportées directement. * @author hawkspar * @memberof LDAP */ // Toutes les entrées utilisateur sont escapées par sécurité import ldapEscape from 'ldap-escape'; // Imports internes import { ldapConfig, userData, groupData, categories } from './config'; import {Basics} from './basics'; //------------------------------------------------------------------------------------------------------------------------ // Fonctions intermédiaires TBT //------------------------------------------------------------------------------------------------------------------------ export class Tools { /** * @memberof LDAP * @class Tools * @classdesc Cette classe contient des fonctions intermédiaires qui ne sont pas destinées à être utilisées dans les resolvers. * @summary Constructeur vide. */ constructor() {} /** * @memberof LDAP * @summary Fonction qui renvoit toutes les infos relatives à un groupe ou un utilisateur particulier. * @desc Cette fonction utilise {@link LDAP.search} avec des attributs prédéfinis. Elle est naïve et n'opère pas de récursion. * @param T - Format renvoyé (en pratique {@link userData} ou {@link groupData}) * @arg {string} domain - Domaine de la recherche (utilisateur ou groupe) * @arg {string} id - Identifiant de la feuille cherchée (uid ou gid) * @return {Promise(T)} Informations recueillies ; renvoie une liste de dictionnaire avec le profil complet du groupe tel que défini par le paramètre T. * @static * @async */ static async peek<T>(domain: 'user'|'group', id: string, type: new () => T) : Promise<T> { // Utrilisation du tableu d'équivalence dans le fichier de configuration let map = ldapConfig[domain]; let cleanKeys = Object.keys(map); let dirtyKeys = cleanKeys.map(key => map[key]); // Création de la structure de données renvoyée let cleanData: T = new type(); let dirtyData = (await Basics.searchMultiple(domain, dirtyKeys, id))[0]; // Renommage des clés for(let cleanKey of cleanKeys) { let val = dirtyData[map[cleanKey]]; if (val !== undefined) { if (Array.isArray(cleanData[cleanKey]) && !Array.isArray(val)) val = [val]; cleanData[cleanKey] = val; } } // Extraction des ids à partir des dns for (let cat of categories) cleanData[cat] = cleanData[cat].map(dn => dn.split(',')[0].split('=')[1]); return cleanData; } /** * @memberof LDAP * @summary Fonction qui retrouve les ids des paxs ou groupes validant les critères de recherche. Etape vers vrai TOL (Trombino On Line). * Utiliser {@link peekUser} au cas par cas après pour obtenir les vraies infos. * @desc Cette fonction utilise {@link LDAP.search} mais avec un filtre généré à la volée. Accepte des champs exacts ou incomplets pour la plupart des champs * mais pas approximatifs et ne gère pas l'auto-complete. MEF Timeout pour des recherches trop vagues. Va crasher si un champ n'est pas dans ldapConfig. * @param T - Format renvoyé (en pratique {@link userData} ou {@link groupData}) * @arg {"us"|"gr"} domain - Domaine de la recherche (utilisateur ou groupe) * @arg {userData | groupData} data - Dictionnaire contenant les données nécessaires à la recherche. Les valeurs sont celles entrées par l'utilisateur et sont par hypothèse * comme des sous-parties compactes des valeurs renvoyées. Tous les champs ci-dessous peuvent être indifféremment des listes (par exemple pour chercher un membre * de plusieurs groupes) ou des éléments isolés. Si un champ n'est pas pertinent, le mettre à '' ou undefined. * @return {Promise(string[])} ids des profils qui "match" les critères proposés. * @static * @async */ static async search(domain: "user"|"group", data: userData|groupData) : Promise<string[]> { let filter=""; // Iteration pour chaque champ, alourdissement du filtre selon des trucs prédéfinis dans ldapConfig encore for (var key in ldapConfig[domain]) { if ((data[key]!= undefined) && (data[key] != '')) { // Si il y a qque chose à chercher pour ce filtre if (!Array.isArray(data[key])) data[key]=[data[key]]; // Génération systématique d'une liste de valeurs à rechercher // Iteration pour chaque valeur fournie par l'utilisateur data[key].forEach(val => { // Traduction en language LDAP let attribute = ""; attribute = ldapConfig[domain][key]; // Escape user input val = ldapEscape.filter("${fil}", { fil: val }); // Gestion des dns if (categories.concat(["parents"]).includes(attribute)) { if (domain == "group") var id = ldapConfig.group.gid; else var id = ldapConfig.user.uid; val = id + '=' + val + ',' + ldapConfig[domain].dn; } // Creation incrémentale du filtre filter="(&"+filter+ "(|("+ attribute + "=" + val+")"+ // On cherche la valeur exacte "(" + attribute + "=*"+ val+")"+ // La valeur finale avec des trucs avant ; wildcard * (MEF la wildcart ne marche pas pour tous les attributs) "(" + attribute + "=*"+ val+"*)"+ // La valeur du milieu avec des trucs avant et après "(" + attribute + "=" + val+"*)))"; // La valeur du début avec des trucs après }); } } // Appel avec filtre de l'espace if (domain == "group") var att=ldapConfig.group.gid; else var att=ldapConfig.user.uid; return Basics.searchSingle(domain, att, null, filter); } /** * @memberof LDAP * @summary Fonction qui édite un groupe ou utilisateur existant dans le LDAP. N'agit pas sur l'apprtenance à un groupe. * @desc Appelle {@link LDAP.change}. * @arg {"us"|"gr"} domain - Domaine de l'opération' (utilisateur ou groupe). * @arg {userData | groupData} data - Dictionnaire avec les nouvelles valeurs de la feuille. * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, false sinon. * @async * @static */ static async edit(domain: "user"|"group", data: userData|groupData) : Promise<boolean> { if (domain == "user") var id=data['uid']; else var id=data['gid']; var dirtyKeys=ldapConfig[domain]; // Rename in an LDAP-friendly way let dirtyData = {}; Object.keys(data).forEach(function(key: string) { // Some values edit can't change if (!categories.concat(['directory','classes','id','cleanFullName']).includes(key)) dirtyData[dirtyKeys.key]=data[key]; }); return Basics.change(domain, id, "replace", dirtyData); } /** * @memberof LDAP * @summary Fonction qui retrouve les utilisateurs ou groupes respectivement correspondant à un groupe ou un utilisateur de la même catégorie. * @desc Cette fonction utilise {@link LDAP.search} et va directement à la feuille de l'utilisateur ou du groupe interrogé. * Pour autant, elle est moins naïve qu'elle en a l'air. Elle ne gère ni la descente des admins ni la remontée des membres et renvoit une réponse naïve. * @param {string} id - Identifiant du groupe ou de l'individu à interroger (supposé valide) * @param {"user"|"group"} domain - Arbre à interroger * @param {"admins"|"speakers"|"members"|"followers"} category - Catégorie considérée * @return {Promise(string[])} Liste des id de groupes ou d'utilisateurs de la bonne catégorie associé à l'id * @static * @async */ static async get(id : string, domain : "user"|"group", category : string): Promise<string[]> { try { if (domain == "group") var dirtyId = ldapConfig.group.gid; else var dirtyId = ldapConfig.user.uid; dirtyId += "=" + ldapEscape.filter("${txt}", { txt: id }) + "," + ldapConfig.dn[domain]; return await Basics.searchSingle(domain, ldapConfig[domain][category], dirtyId); } catch(err) { throw "Erreur lors d'une recherche générique d'un membre d'une certaine catégorie d'un groupe."; } } /** * @memberof LDAP * @summary Fonction intermédiaire naïve. * @desc Cette fonction rajoute 2 dans 1 pour un des deux arbres si cette entrée n'était pas déjà présente de façon à ne pas créer de doublon. * La symétrie entre 1 et 2 est voulue et permet de gérer indifférement l'ajout d'un individu dans un groupe ou d'un groupe à un individu. * @arg {string} id1 - uid/gid * @arg {"group"|"user"} domain1 - Arbre concerné pour l'id1 * @arg {string} id2 - gid/uid * @arg {"group"|"user"} domain2 - Arbre concerné pour l'id2 * @arg {"admins"|"speakers"|"members"|"followers"} category - Categorie de l'utilisateur concerné * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, `false` sinon * @async * @static */ static async addIfAbsent(id1: string, domain1: "group"|"user", id2: string, domain2: "group"|"user", category: string): Promise<boolean> { try { // Vérifie que l'utilisateur est pas déjà membre pour groupes if (!(await Tools.get(id1, domain1, category)).includes(id2)) { // Ajoute l'utilisateur dans la catégorie concernée if (domain2 == "group") var id = ldapConfig[domain2].gid; else var id = ldapConfig[domain2].uid; var catName = ldapConfig[domain1][category]; if (!await Basics.change(domain1, id1, "add", {catName: id+"="+id2+","+ldapConfig.dn[domain2]})) throw "Erreur lors de la modification dans l'arbre "+domain2+" pour ajouter une entrée dans la catégorie voulue."; } } catch(err) { throw "Erreur pour obtenir une liste d'entrées d'une catégorie d'un "+domain1+"."; } return true; } /** * @memberof LDAP * @summary Fonction intermédiaire de gestion de permissions. * @desc Cette fonction gère les inclusions de droits. Elle ne rajoute pas un admin pour les admins, mais elle le rajoute en tant que speaker. * Cette fonction agit uniquement sur l'arbre User, de façon à ne pas alourdir l'arbre Group inutilement. * @arg {string} uid - uid de l'utilisateur à ajouter * @arg {string} gid - gid du groupe concerné * @arg {"admins"|"speakers"|"members"|"followers"} category - Categorie de l'utilisateur concerné * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, `false` sinon * @async * @static */ static async addIncluded(uid: string, gid: string, category: string): Promise<boolean> { switch (category) { case "admins": // Admin => speaker if (!await Tools.addIfAbsent(uid, "user", gid, "group", "speaker")) throw "Erreur à l'ajout d'un nouvel admin parmi les portes-paroles d'un groupe"; case "speakers": // Speaker & admin => member if (!await Tools.addIfAbsent(uid, "user", gid, "group", "member")) throw "Erreur à l'ajout d'un nouvel admin ou porte-parole parmi les membres d'un groupe"; case "members": case "followers": break; } return true; } /** * @memberof LDAP * @summary Fonction intermédiaire de récursion qui gère la descente des admins et la remontée des membres. * @desc Cette fonction gère les droits par récursion par une classique Depth First Search. * Cette fonction agit uniquement sur l'arbre User. * @arg {string} uid - uid de l'utilisateur à ajouter * @arg {string} gid - gid du groupe concerné * @arg {boolean} direction - direction de la recursion * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, false sinon * @async * @static */ static async addDFS(uid: string, gid: string, direction: boolean): Promise<boolean> { // Classic DFS var to_visit = [gid]; // Cas récursif ascendant (admins) var rol = "admins"; async function next_visit(cur_gid) { to_visit.concat(await Basics.searchSingle("group", ldapConfig.group.gid, null, "parents="+cur_gid)); } // Cas récursif descendant (membres) if (!direction) { var rol = "members"; async function next_visit(cur_gid) { // Lecture du champ parents let dns = await Basics.searchSingle("group", ldapConfig.group.parents, cur_gid); // Reformattage DNs en gids de groupes dns.map(dn => dn.split(',')[0].split('=')[1]); to_visit.concat(dns); } } while (to_visit.length > 0) { let cur_gid = to_visit.pop(); Tools.addIfAbsent(uid, "user", cur_gid, "group", rol); await next_visit(cur_gid); } return true; } /** * @memberof LDAP * @summary Fonction essentielle qui permet d'ajouter un utilisateur à une catégorie d'un groupe. * @desc Cette fonction fait essentiellement appel à d'autres fonctions de {@link Tools} ; {@link Tools.addIncluded} et {@link Tools.addIfAbsent}. * Elle utilise {@link LDAP.addDFS} pour gèrer la récursion. * @arg {string} uid - Identifiant du futur membre * @arg {string} gid - Identifiant du groupe * @arg {"admins"|"speakers"|"members"|"followers"} category - Categorie de l'utilisateur concerné au type non contraint mais en pratique limité * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, false sinon * @async * @static */ static async add(uid: string, gid: string, category: string): Promise<boolean> { // Gestion naïve Tools.addIfAbsent(uid, "user", gid, "group", category); Tools.addIfAbsent(gid, "group", uid, "user", category); // Gestion des droits par inclusion Tools.addIncluded(uid, gid, category); // Gestion des droits récursive // Tout le monde sauf les followers sont members et donc remontent if (category != "followers") Tools.addDFS(uid, gid, false); // Les admins descendent if (category == "admins") Tools.addDFS(uid, gid, true); return true; } /** * @memberof LDAP * @summary Fonction intermédiaire naïve qui supprime une entrée d'une feuille si elle y existe. * @desc Cette fonction enlève 2 d'1, feuille d'un des deux arbres si cette entrée y existe. * La symétrie entre 1 et 2 est voulue et permet de gérer indifférement l'ajout d'un individu dans un groupe ou d'un groupe à un individu. * @arg {string} id1 - uid/gid * @arg {"group"|"user"} domain1 - Arbre concerné pour l'id1 * @arg {string} id2 - gid/uid * @arg {"group"|"user"} domain2 - Arbre concerné pour l'id2 * @arg {"admins"|"speakers"|"members"|"followers"} category - Categorie de l'utilisateur concerné * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, `false` sinon * @async * @static */ static async remIfPresent(id1: string, domain1: "group"|"user", id2: string, domain2: "group"|"user", category: string): Promise<boolean> { try { let l = await Tools.get(id1, domain1, category); // Vérifie que l'identifiant est bien présent if (l.includes(id2)) { // Supprime tous les identifiants var catName = ldapConfig[domain1][category]; if (!await Basics.change(domain1, id1, "del", catName)) throw "Erreur lors de la suppression de tous les "+category+" de l'identifiant "+id1+"."; // Les rajoute un par un, sauf pour le supprimé l.forEach(id => { if (id!=id2) { if (domain2 == "group") var id_n = ldapConfig[domain2].gid; else var id_n = ldapConfig[domain2].uid; Basics.change(domain1, id1, "add", {catName: id_n+'='+id+','+ldapConfig[domain2].dn}).then(res => { if (!res) throw "Erreur lors du ré-ajout d'un autre "+domain1+" de la catégorie "+category+"."; }); } }); } } catch(err) { throw "Erreur pour obtenir une liste d'entrées d'une catégorie d'un "+domain1+"."; } return true; } /** * @memberof LDAP * @summary Fonction intermédiaire de suppression des droits obtenus par inclusion. * @desc Cette fonction gère les inclusions de droits, et donc les rétrogadations. Elle ne supprime pas un admin pour les admins, mais le rajoute en tant que speaker. * Cette fonction appelle {@link remove} directement pour vraiment éliminer le personnage des rôles inclus et {@link add} pour vraiment le rajouter. * @arg {string} uid - uid de l'utilisateur à ajouter * @arg {string} gid - gid du groupe concerné * @arg {"admins"|"speakers"|"members"|"followers"} category - Categorie de l'utilisateur concerné * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, `false` sinon * @async * @static */ static async remIncluded(uid: string, gid: string, category: string): Promise<boolean> { switch (category) { case "members": Tools.remove(uid, gid, "admins"); Tools.remove(uid, gid, "speakers"); break; case "speakers": Tools.remove(uid, gid, "admins"); // Speaker -> member Tools.add(uid, gid, "member"); break; case "admins": // Admin -> speaker Tools.add(uid, gid, "speaker"); case "followers": break; } return true; } /** * @memberof LDAP * @summary Fonction intermédiaire de récursion à la suppression. * @desc Cette fonction gère les droits par récursion par une classique Depth First Search. * Cette fonction agit uniquement sur l'arbre User, de façon à différencier rôles stricts de rôles hérités. * Elle est sans effet sur les groupes où l'utilisateur dispose de rôles stricts. * @arg {string} uid - uid de l'utilisateur à supprimer * @arg {string} gid - gid du groupe à l'origine de la récursion * @arg {boolean} direction - direction de la recursion (true pour ascendant) * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, `false` sinon * @async * @static */ static async remDFS(uid: string, gid: string, direction: boolean): Promise<boolean> { // Classic DFS var to_visit = [gid]; // Cas récursif ascendant (admins) var checks = ["admins"]; // Recherche des groupes avec parents qui correspondent (efficace car index) async function next_visit(cur_gid) { to_visit.concat(await Basics.searchSingle("group", ldapConfig.group.gid, null, "parents="+cur_gid)); } // Cas récursif descendant (membres) if (!direction) { var checks = ["admins","speakers","members"]; async function next_visit(cur_gid) { // Lecture du champ parents let dns = await Basics.searchSingle("group", ldapConfig.group.parents, cur_gid); // Reformattage des dns en gids dns.map(dn => dn.split(',')[0].split('=')[1]); to_visit.concat(dns); } } var rol = checks[checks.length-1]; while (to_visit.length > 0) { let cur_gid = to_visit.pop(); // Si le statut de uid dans cur_gid est un statut strict on arrête de boucler for (let cat of checks) if ((await Tools.get(cur_gid, "group", cat)).includes(uid)) continue; // Sinon on le tue et on boucle Tools.remIfPresent(uid, "user", cur_gid, "group", rol); await next_visit(cur_gid); } return true; } /** * @memberof LDAP * @summary Fonction qui permet de supprimer un membre d'une catégorie existant d'un groupe. * @desc Cette fonction fait essentiellement appel à d'autres fonctions de {@link Tools} passées en argument et {@link LDAP.change}. * Elle essaie d'assurer les propriétés d'inclusion et de récursion du LDAP (voir {@link remDFS} pour la récursion). Elle est sans effet pour un admin hérité. * Le comportement est étrange pour la suppression d'un membre hérité ; le membre est supprimé du groupe et des groupes parents, mais pas du groupe à l'origine de l'héritage. * @arg {string} uid - Identifiant de l'ex-membre * @arg {string} gid - Identifiant du groupe * @arg {"admins"|"speakers"|"members"|"followers"} category - Categorie de l'utilisateur concerné au type non contraint mais en pratique limité * @return {Promise(boolean)} `true` si la modification s'est bien déroulée, `false` sinon * @async * @static */ static async remove(uid: string, gid: string, category : string): Promise<boolean> { // Invulnérabilité pour les admins hérités (par définition, un admin hérité est dans User pas dans Group) if ((await Tools.get(gid, "group", "admins")).includes(uid) || !(await Tools.get(uid, "user", "admins")).includes(gid)) { Tools.remIfPresent(uid, "user", gid, "group", category); Tools.remIfPresent(gid, "group", uid, "user", category); // Gestion des droits par inclusion (et éventuelle rétrogadation) Tools.remIncluded(uid, gid, category); // Gestion des droits récursive if (category == "admins") Tools.remDFS(uid, gid, true); // Uniquement pour des membres ; sinon modification localisée au groupe if (category == "members") Tools.remDFS(uid, gid, false); } return true; } /** * @memberof LDAP * @callback changeValueCallback * @param {string} id - Id à modifier * @param {number} n - Nombre d'itérations * @return {string} Nouveau id */ /** * @memberof LDAP * @summary Cette fonction teste une valeur d'un attribut (typiquement un identifiant) et le fait évoluer jusqu'à ce qu'il soit unique. * @desc Librement adapté de Stack Overflow. Appelle {@link LDAP.search} pour vérifier * qu'il n'y a pas d'autres occurences de cette valeur pour cette attribut * dans le dn fourni. * @param {string} value - Valeur de l'attribut (le plus souvent un identifiant) à tester à cette itération * @param {string} attribute - Attribut à tester * @param {"gr"|"us"} domain - Domaine dans lequel l'attribut doit être unique * @param {changeValueCallback} changeValue - Fonction qui prend uniquement en argument l'id courant et * le nombre d'itérations et qui renvoit la prochaine valeur de l'attribut * @param {number} n [0] - Nombre d'itérations (à initialiser à 0) * @return {Promise(string)} Valeur unique dans le domaine spécifié de l'attribut spécifié * @static * @async */ static async ensureUnique(value: string, attribute: string, domain: 'group'|'user', changeValue: (string, number) => string, n: number=0) : Promise<string> { // Recherche d'autres occurences de l'id try { if (domain == "group") var att=ldapConfig.group.gid; else var att=ldapConfig.user.uid; let matches = await Basics.searchSingle(domain, att, null, "("+attribute+"="+ldapEscape.filter("${txt}", { txt: value })+")") // On renvoit la valeur si elle est bien unique if (matches.length=0) return value; // Sinon, on tente de nouveau notre chance avec la valeur suivante else return Tools.ensureUnique(changeValue(value, n+1), attribute, domain, changeValue, n+1); } catch(err) { throw "Erreur lors de la recherche d'une valeur pour assurer son unicité."; } } /** * @memberof LDAP * @summary Cette fonction génère un uid standard, puis le fait évoluer jusqu'à ce qu'il soit unique. * @desc Limité à un appel à {@link Tools.ensureUnique} avec les bons paramètres, et quelques opérations sur l'uid pour qu'il soit valide (escape, normalisation). * @param {"group"|"user"} domain - Arbre à parcourir * @param {string} givenName - Prénom * @param {string} lastName - Nom * @param {string} promotion - Année de promotion * @return {Promise(string)} Valeur unique dans le domaine spécifié de l'attribut spécifié * @static * @async */ static async generateUid(domain : "group"|"user", givenName: string, lastName: string, promotion: string) : Promise<string> { try { if (domain == "group") var att=ldapConfig.group.gid; else var att=ldapConfig.user.uid; // normalize et lowerCase standardisent le format return Tools.ensureUnique((givenName+'.'+lastName).toLowerCase().normalize('UFD'), att, "user", (id: string, n: number) => { if (n=1) id+='.'+promotion; // Si prénom.nom existe déjà, on rajoute la promo else if (n=2) id+='.'+(n-1).toString(); // Puis si prénom.nom.promo existe déjà on passe à nom.prenom.promo .1 else if (n>2) id+=(n-1).toString(); // Ensuite on continue .123, .1234, etc... return id; }); } catch(err) { throw "Erreur lors de l'assurance de l'unicité d'un human readable unique identifier (hruid)."; } } /** * @memberof LDAP * @summary Cette fonction génère un id lisible, puis le fait évoluer jusqu'à ce qu'il soit unique. * @desc Limité à un appel à {@link Tools.ensureUnique} avec les bons paramètres, et quelques opérations sur l'uid pour qu'il soit valide (escape, normalisation). * @param {"group"|"user"} domain - Arbre à parcourir * @param {string} name - Nom * @return {Promise(string)} Valeur unique dans le domaine spécifié de l'attribut spécifié * @static * @async */ static async generateReadableId(domain : "group"|"user", name: string) : Promise<string> { try { if (domain == "group") var att=ldapConfig.group.gid; else var att=ldapConfig.user.uid; // normalize et lowerCase standardisent le format return Tools.ensureUnique(name.toLowerCase().normalize('UFD'), att, domain, (id: string, n: number) => { if (n=1) id+='.'+n.toString(); // Si nom existe déjà, on essaie nom.1 else if (n>1) id+=n.toString(); // Ensuite on continue .12, .123, etc... return id; }); } catch(err) { throw "Erreur lors de l'assurance de l'unicité d'un human readable unique identifier (hruid)."; } } /** * @memberof LDAP * @summary Cette fonction teste une valeur dummy (0) pour un identifiant numérique puis le fait évoluer aléatoirement (entre 1 et 100 000) jusqu'à ce qu'il soit unique. * @param {string} attribute - Intitulé exact de l'id concerné * @param {"gr"|"us"} domain - Domaine dans lequel l'attribut doit être unique * @return {Promise(string)} Valeur unique dans le domaine spécifié de l'attribut spécifié * @static * @async */ static async generateId(attribute: string, domain: "group"|"user") : Promise<string> { try { return Tools.ensureUnique("0", attribute, domain, (id,n) => Math.floor((Math.random() * 100000) + 1).toString()); } catch(err) { throw "Erreur lors de l'assurance de l'unicité d'un unique identifier numérique."; } } }