ldap-rest 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as t,dirname as r}from"path";import i from"express";import s from"body-parser";import{parseConfig as o}from"../lib/parseConfig.js";import{keepRawBody as n}from"../lib/rawBody.js";import a from"../config/args.js";import h from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as u}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as d}from"../lib/auth/base.js";import{assertAuthzComposition as g}from"../lib/authz/composition.js";import{AUTHZ_REFUSED as p}from"../lib/errors.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import{recordHookOwner as c}from"../lib/utils.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,hookOwner,isAuthzRefusal,isChildOf,isDnInBranch,isDummyMemberDn,launchHooks,launchHooksChained,normalizeDn,organizationLink,parseDn,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import f from"../plugins/priority.json.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this.authenticators=[],this._authState={dispatcherMounted:!1},this._errorMiddlewareSetup=!1,this.bodyParsers=i.Router(),this._composed=!1,this.pluginLoads=new Map,this.config=o(a),this.app=i(),this.app.use(this.bodyParsers),this.app.use(s.json({verify:n})),this.app.use(s.urlencoded({extended:!0,verify:n})),this.logger=l(this.config),this.ldap=new h(this),u(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const t=this.config.plugin.find(e=>e.includes("configApi"));let r=this.config.plugin.filter(e=>!e.includes("configApi"));const i=this.logger,s=(async()=>{for(const e of f){const t=this.resolveModule(DM.moduleOf(e)),s=r.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==s.length){r=r.filter(e=>!s.includes(e));for(const e of s)i.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();e.push(s),e.push(s.then(async()=>{const e=r.map(e=>(i.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),t&&e.push(Promise.all(e).then(()=>this.loadPlugin(t)))}this.ready=new Promise((t,r)=>{e.length>0?Promise.all(e).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await g(this),this._composed=!0,this.afterLoad(),t()}).catch(e=>r(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._composed=!0,t())})}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}mountAuthDispatcher(e){const t=e.roles||[];t.includes("protect")||t.includes("logging")||this.mountAuthDispatcherNow()}mountAuthDispatcherNow(){this._authState.dispatcherMounted||(this._authState.dispatcherMounted=!0,this.app.use((e,t,r)=>{this.dispatchAuth(e,t,r)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,r){let i=-1,s=[];for(const t of this.authenticators)for(const r of t.pathPrefixes)d(r,e.path)&&(r.length>i?(i=r.length,s=[t]):r.length!==i||s.includes(t)||s.push(t));if(i<0&&(s=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===s.length)return r();const finish=i=>{if(i>=s.length)return r();const o=s[i];if(!o.afterChain)return finish(i+1);o.afterChain(e,t,()=>finish(i+1))},o=[];e.authenticators=o;const run=r=>{if(r>=s.length)return finish(0);const i=s[r];i.authenticate(e,t,()=>{i.vouchedFor(e)&&o.push(i.name),run(r+1)})};run(0)}afterLoad(){for(const e of Object.values(this.loadedPlugins))try{e.afterLoad?.()}catch(t){this.logger.error(`Plugin ${e.name}: afterLoad failed: ${String(t)}`)}}warnUnauthenticatedRoutes(){const e=Object.values(this.loadedPlugins).filter(e=>e.roles?.includes("auth"));if(0===e.length)return[];const t=[];for(const r of e){if(!r.pathPrefixes||0===r.pathPrefixes.length)return[];t.push(...r.pathPrefixes)}const r=this.routerStack(),i=new Set;for(const e of r||[]){const t=e.route?.path;"string"==typeof t?i.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&i.add(e))}const s=[...i].filter(e=>!t.some(t=>e===t||e.startsWith(`${t}/`)));return s.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication: ${s.sort().join(", ")}`),s.sort()}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.routerStack();if(e){const t=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==t&&e.splice(t,1)}}this._errorMiddleware=(e,t,r,i)=>{let s="statusCode"in e?e.statusCode:500,o=e.message;if(500===s&&/\[authz-forbidden\]/.test(e.message)?(s=403,o=p):403===s&&/\[authz-forbidden\]/.test(e.message)&&(o=p),s>=400&&s<500)return this.logger.warn(`Client error ${s} in request ${t.method} ${t.path}: ${o}`),void(r.headersSent||r.status(s).json({error:o}));this.logger.error(`Server error ${s} in request ${t.method} ${t.path}: ${e.message}`,{stack:e.stack,url:t.url,method:t.method}),r.headersSent||r.status(s).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}run(){return process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,t)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:t})}),new Promise((e,t)=>{this.server=this.app.listen(this.config.port,r=>{r?(this.logger.error(`Error starting server: ${r}`),t(r)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}static moduleOf(e){const t=e.indexOf(":");return-1===t?e:e.substring(0,t)}resolveModule(i){return i.startsWith("core/")?i.replace("core/",t(r(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):i.startsWith(".")?e(new URL(i,import.meta.url)):i}loadPlugin(e){let t,r;if(/:/.test(e)){let i=e.substring(e.indexOf(":")+1);if(e=e.substring(0,e.indexOf(":")),/:/.test(i)){t=i.substring(0,i.indexOf(":")),t||(t=void 0),i=i.substring(i.indexOf(":")+1);try{r=JSON.parse(i),"object"!=typeof r?(this.logger.error(`Overrides for plugin ${e} are not valid: ${i}`),r=void 0):this.logger.debug(`Overrides for plugin ${t||e}: ${i}`)}catch(t){this.logger.error(`Failed to parse overrides for plugin ${e}: ${t}, using ${i}`),r=void 0}}else t=i,t||(t=void 0)}else t=void 0;const i=r?void 0:`${this.resolveModule(e)}\0${t??""}`,s=void 0===i?void 0:this.pluginLoads.get(i);if(s)return this.logger.debug(`Plugin ${e} already loaded, skipping`),s;const o=this.importPlugin(e,t,r);if(void 0!==i){this.pluginLoads.set(i,o);const forget=()=>{this.pluginLoads.get(i)===o&&this.pluginLoads.delete(i)};o.then(e=>e||forget(),forget)}return o}importPlugin(e,t,r){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((i,s)=>{import(e).then(async o=>{let n;if(o&&o.default&&(o=o.default),r){const e={...this.config,...r};n=new o(this.withConfig(e))}else n=new o(this);if(!n)return s(new Error(`Unable to load ${e}`));i(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>s(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await g(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,r){if(t.name||(t.name=e),r&&(t.name=r),this.loadedPlugins[t.name])return this.logger.warn(`Plugin ${e} not registered: the name "${t.name}" is already taken. This instance is dropped and its routes will not exist. To load the same plugin twice, give each one a name: --plugin '${e}:${t.name}2:{…}'`),!1;if(this.logger.debug(`Registering plugin ${e} as ${t.name}`),t.dependencies)for(const e in t.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${t.name} depends on ${e}, loading it first`),await this.loadPlugin(t.dependencies[e]));if(this._composed&&await this.admitToComposition(t),t.api&&(this.mountAuthDispatcher(t),this.logger.debug(`Plugin ${t.name} has API, registering it`),await t.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),t.hooks)for(const e in t.hooks){this.logger.debug(`Plugin ${t.name} has hook ${e}, registering it`);const r=t.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!r||"function"!=typeof r)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);this.hooks[e].push(r),c(r,t.name,e)}return this.loadedPlugins[t.name]=t,!0}}export{DM,c as recordHookOwner};
1
+ import{fileURLToPath as e}from"url";import{join as t,dirname as r}from"path";import i from"express";import s from"body-parser";import{parseConfig as o}from"../lib/parseConfig.js";import{keepRawBody as n}from"../lib/rawBody.js";import a from"../config/args.js";import h from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import{prefixCoversPath as u}from"../lib/auth/base.js";import{assertAuthzComposition as g}from"../lib/authz/composition.js";import{AUTHZ_REFUSED as p}from"../lib/errors.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";import{recordHookOwner as c}from"../lib/utils.js";export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,hookOwner,isAuthzRefusal,isChildOf,isDnInBranch,isDummyMemberDn,launchHooks,launchHooksChained,movedToTrash,normalizeDn,organizationLink,parseDn,rdnValue,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue}from"../lib/utils.js";import f from"../plugins/priority.json.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this.authenticators=[],this._authState={dispatcherMounted:!1},this._errorMiddlewareSetup=!1,this.bodyParsers=i.Router(),this._composed=!1,this.pluginLoads=new Map,this.config=o(a),this.app=i(),this.app.use(this.bodyParsers),this.app.use(s.json({verify:n})),this.app.use(s.urlencoded({extended:!0,verify:n})),this.logger=l(this.config),this.ldap=new h(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const t=this.config.plugin.find(e=>e.includes("configApi"));let r=this.config.plugin.filter(e=>!e.includes("configApi"));const i=this.logger,s=(async()=>{for(const e of f){const t=this.resolveModule(DM.moduleOf(e)),s=r.filter(e=>this.resolveModule(DM.moduleOf(e))===t);if(0!==s.length){r=r.filter(e=>!s.includes(e));for(const e of s)i.debug(`Loading ${e} in the priority pass`),await this.loadPlugin(e)}}})();e.push(s),e.push(s.then(async()=>{const e=r.map(e=>(i.debug(`Loading ${e} in the parallel batch`),this.loadPlugin(e)));await Promise.all(e)})),t&&e.push(Promise.all(e).then(()=>this.loadPlugin(t)))}this.ready=new Promise((t,r)=>{e.length>0?Promise.all(e).then(async()=>{this.setupErrorMiddleware(),this.warnUnauthenticatedRoutes(),await g(this),this._composed=!0,this.afterLoad(),t()}).catch(e=>r(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),this._composed=!0,t())})}routerStack(){const e=this.app;return(e.router??e._router)?.stack}withConfig(e){return Object.assign(Object.create(Object.getPrototypeOf(this)),this,{config:e})}mountAuthDispatcher(e){const t=e.roles||[];t.includes("protect")||t.includes("logging")||this.mountAuthDispatcherNow()}mountAuthDispatcherNow(){this._authState.dispatcherMounted||(this._authState.dispatcherMounted=!0,this.app.use((e,t,r)=>{this.dispatchAuth(e,t,r)}))}registerAuthenticator(e){this.mountAuthDispatcherNow(),this.authenticators.includes(e)||this.authenticators.push(e)}registerBodyParser(e,t){this.bodyParsers.use(e,t)}dispatchAuth(e,t,r){let i=-1,s=[];for(const t of this.authenticators)for(const r of t.pathPrefixes)u(r,e.path)&&(r.length>i?(i=r.length,s=[t]):r.length!==i||s.includes(t)||s.push(t));if(i<0&&(s=this.authenticators.filter(e=>0===e.pathPrefixes.length)),0===s.length)return r();const finish=i=>{if(i>=s.length)return r();const o=s[i];if(!o.afterChain)return finish(i+1);o.afterChain(e,t,()=>finish(i+1))},o=[];e.authenticators=o;const run=r=>{if(r>=s.length)return finish(0);const i=s[r];i.authenticate(e,t,()=>{i.vouchedFor(e)&&o.push(i.name),run(r+1)})};run(0)}afterLoad(){for(const e of Object.values(this.loadedPlugins))try{e.afterLoad?.()}catch(t){this.logger.error(`Plugin ${e.name}: afterLoad failed: ${String(t)}`)}}warnUnauthenticatedRoutes(){const e=Object.values(this.loadedPlugins).filter(e=>e.roles?.includes("auth"));if(0===e.length)return[];const t=[];for(const r of e){if(!r.pathPrefixes||0===r.pathPrefixes.length)return[];t.push(...r.pathPrefixes)}const r=this.routerStack(),i=new Set;for(const e of r||[]){const t=e.route?.path;"string"==typeof t?i.add(t):Array.isArray(t)&&t.forEach(e=>"string"==typeof e&&i.add(e))}const s=[...i].filter(e=>!t.some(t=>e===t||e.startsWith(`${t}/`)));return s.length>0&&this.logger.warn(`Authentication is restricted to ${t.join(", ")}, so these routes are served without authentication: ${s.sort().join(", ")}`),s.sort()}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.routerStack();if(e){const t=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==t&&e.splice(t,1)}}this._errorMiddleware=(e,t,r,i)=>{let s="statusCode"in e?e.statusCode:500,o=e.message;if(500===s&&/\[authz-forbidden\]/.test(e.message)?(s=403,o=p):403===s&&/\[authz-forbidden\]/.test(e.message)&&(o=p),s>=400&&s<500)return this.logger.warn(`Client error ${s} in request ${t.method} ${t.path}: ${o}`),void(r.headersSent||r.status(s).json({error:o}));this.logger.error(`Server error ${s} in request ${t.method} ${t.path}: ${e.message}`,{stack:e.stack,url:t.url,method:t.method}),r.headersSent||r.status(s).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}run(){return process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,t)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:t})}),new Promise((e,t)=>{this.server=this.app.listen(this.config.port,r=>{r?(this.logger.error(`Error starting server: ${r}`),t(r)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}static moduleOf(e){const t=e.indexOf(":");return-1===t?e:e.substring(0,t)}resolveModule(i){return i.startsWith("core/")?i.replace("core/",t(r(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js"):i.startsWith(".")?e(new URL(i,import.meta.url)):i}loadPlugin(e){let t,r;if(/:/.test(e)){let i=e.substring(e.indexOf(":")+1);if(e=e.substring(0,e.indexOf(":")),/:/.test(i)){t=i.substring(0,i.indexOf(":")),t||(t=void 0),i=i.substring(i.indexOf(":")+1);try{r=JSON.parse(i),"object"!=typeof r?(this.logger.error(`Overrides for plugin ${e} are not valid: ${i}`),r=void 0):this.logger.debug(`Overrides for plugin ${t||e}: ${i}`)}catch(t){this.logger.error(`Failed to parse overrides for plugin ${e}: ${t}, using ${i}`),r=void 0}}else t=i,t||(t=void 0)}else t=void 0;const i=r?void 0:`${this.resolveModule(e)}\0${t??""}`,s=void 0===i?void 0:this.pluginLoads.get(i);if(s)return this.logger.debug(`Plugin ${e} already loaded, skipping`),s;const o=this.importPlugin(e,t,r);if(void 0!==i){this.pluginLoads.set(i,o);const forget=()=>{this.pluginLoads.get(i)===o&&this.pluginLoads.delete(i)};o.then(e=>e||forget(),forget)}return o}importPlugin(e,t,r){return this.logger.debug(`Loading plugin ${e}`),e.startsWith("core/")&&(e=this.resolveModule(e)),new Promise((i,s)=>{import(e).then(async o=>{let n;if(o&&o.default&&(o=o.default),r){const e={...this.config,...r};n=new o(this.withConfig(e))}else n=new o(this);if(!n)return s(new Error(`Unable to load ${e}`));i(await this.registerPlugin(e,n,t)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(t=>s(new Error(`Failed to load plugin ${e}: ${t}`)))})}async admitToComposition(e){this.loadedPlugins[e.name]=e;try{await g(this,e)}finally{delete this.loadedPlugins[e.name]}}async registerPlugin(e,t,r){if(t.name||(t.name=e),r&&(t.name=r),this.loadedPlugins[t.name])return this.logger.warn(`Plugin ${e} not registered: the name "${t.name}" is already taken. This instance is dropped and its routes will not exist. To load the same plugin twice, give each one a name: --plugin '${e}:${t.name}2:{…}'`),!1;if(this.logger.debug(`Registering plugin ${e} as ${t.name}`),t.dependencies)for(const e in t.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${t.name} depends on ${e}, loading it first`),await this.loadPlugin(t.dependencies[e]));if(this._composed&&await this.admitToComposition(t),t.api&&(this.mountAuthDispatcher(t),this.logger.debug(`Plugin ${t.name} has API, registering it`),await t.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),t.hooks)for(const e in t.hooks){this.logger.debug(`Plugin ${t.name} has hook ${e}, registering it`);const r=t.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!r||"function"!=typeof r)throw new Error(`Plugin ${t.name}: hook ${e} is invalid`);this.hooks[e].push(r),c(r,t.name,e)}return this.loadedPlugins[t.name]=t,!0}}export{DM,c as recordHookOwner};
package/dist/lib/utils.js CHANGED
@@ -1 +1 @@
1
- import{BadRequestError as e}from"./errors.js";import{getLogger as n}from"./expressFormatedResponses.js";const r=new Map;function getCompiledRegex(e,n){const t=n?`${e}:${n}`:e;let i=r.get(t);return i||(i=new RegExp(e,n),r.set(t,i)),i}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const describeThrown=e=>{try{return JSON.stringify(e)??String(e)}catch{return String(e)}},hookLabel=e=>{const n=hookOwner(e);return n?` in ${n.plugin} (${n.hook})`:e.name?` in ${e.name}`:""},launchHooks=async(e,...r)=>{if(e)for(const t of e)if(t)try{await t(...r)}catch(e){const r=hookLabel(t);e instanceof Error?n()?.error(`Hook error${r}`,e):n()?.error(`Hook error${r}: ${describeThrown(e)}`)}},t=new WeakMap,recordHookOwner=(e,n,r)=>{t.set(e,{plugin:n,hook:r})},hookOwner=e=>t.get(e),isAuthzRefusal=e=>/\[authz-forbidden\]/.test(String(e?.message??"")),isRefusal=e=>403===e?.statusCode||isAuthzRefusal(e),launchHooksChained=async(e,r)=>{if(e)for(const t of e)if(t)try{r=await t(r)}catch(e){if(isRefusal(e)){const r=hookOwner(t);n()?.warn(`${r?`${r.plugin} refused ${r.hook}`:`a hook no plugin registered (${t.name||"anonymous"}) refused`}: ${e.message}`)}throw e}return r},transformSchemas=(e,n)=>e.toString().replace(/__(\S+)__/g,(e,r)=>{if(!r||"string"!=typeof r)return e;const t=r.trim().toLowerCase();return n[t]?"object"!=typeof n[t]?n[t]:JSON.stringify(n[t]):e});function escapeLdapFilter(e){return e.replace(/\\/g,"\\5c").replace(/\*/g,"\\2a").replace(/\(/g,"\\28").replace(/\)/g,"\\29").replace(/\0/g,"\\00")}function substringSearchFilter(n,r){const t=/^[a-zA-Z][a-zA-Z0-9-]*$/,i=r.split(",").map(e=>e.trim()).filter(Boolean);if(0===i.length||!i.every(e=>t.test(e)))throw new e("Invalid LDAP attribute name");const o=escapeLdapFilter(n),a=i.map(e=>`(${e}=*${o}*)`);return 1===a.length?a[0]:`(|${a.join("")})`}function escapeDnValue(e){return e.replace(/\\/g,"\\\\").replace(/,/g,"\\,").replace(/\+/g,"\\+").replace(/"/g,'\\"').replace(/</g,"\\<").replace(/>/g,"\\>").replace(/;/g,"\\;").replace(/=/g,"\\=").replace(/\0/g,"\\00").replace(/^\s/,"\\ ").replace(/\s$/,"\\ ").replace(/^#/,"\\#")}function unescapeDnValue(n){if("string"!=typeof n)throw new e("DN value must be a string");let r="",t=0;for(;t<n.length;)if("\\"===n[t]&&t+1<n.length){const e=n[t+1];if(/[0-9a-fA-F]/.test(e)&&t+2<n.length){const e=n.substring(t+1,t+3);if(/^[0-9a-fA-F]{2}$/.test(e)){r+=String.fromCharCode(parseInt(e,16)),t+=3;continue}}r+=e,t+=2}else r+=n[t],t++;return r}function validateDnValue(e,n){if(null==e||"string"!=typeof e)throw new Error(`${n} must be a string`);if(0===e.trim().length)throw new Error(`${n} must be a non-empty string`);if(/[\x00-\x1F\x7F]/.test(e))throw new Error(`${n} contains invalid control characters`);if(/[\u200B-\u200F\uFEFF]/.test(e))throw new Error(`${n} contains invalid invisible characters`)}function parseDn(n){if("string"!=typeof n)throw new e("DN must be a string");const r=[];let t="",i=!1;for(let e=0;e<n.length;e++){const o=n[e];i?(t+=o,i=!1):"\\"===o?(t+=o,i=!0):","===o?(r.push(t.trim()),t=""):t+=o}return t&&r.push(t.trim()),r}function getParentDn(e){const n=parseDn(e);return n.length<=1?e:n.slice(1).join(",")}function getRdn(e){return parseDn(e)[0]||""}function rdnValue(e){const n=/^[^=]+=((?:\\.|[^,])*)/.exec(e);return n?n[1].replace(/\\(.)/g,"$1"):""}function isChildOf(e,n){const r=e.toLowerCase(),t=n.toLowerCase();if(!r.endsWith(t))return!1;if(r.length===t.length)return!1;return r.substring(0,r.length-t.length).endsWith(",")}function normalizeDn(e){return parseDn(e).map(e=>e.split("+").map(e=>{const n=e.indexOf("=");return(-1===n?e.trim():`${e.slice(0,n).trim()}=${e.slice(n+1).trim()}`).toLowerCase()}).sort().join("+")).filter(e=>e.length>0).join(",")}function isDummyMemberDn(e,n){if(!n||"string"!=typeof e)return!1;if(e===n)return!0;try{return normalizeDn(e)===normalizeDn(n)}catch{return!1}}function organizationLink(e){const n=Array.isArray(e)?e[0]:e;if(null==n)return;const r=String(n);return r.length>0?r:void 0}function isDnInBranch(e,n){const r=normalizeDn(n).split(",").filter(Boolean);if(0===r.length)return!1;const t=normalizeDn(e).split(",").filter(Boolean);if(t.length<r.length)return!1;return t.slice(t.length-r.length).every((e,n)=>e===r[n])}const asyncHandler=e=>(n,r,t)=>{Promise.resolve(e(n,r,t)).catch(t)};export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,hookOwner,isAuthzRefusal,isChildOf,isDnInBranch,isDummyMemberDn,launchHooks,launchHooksChained,normalizeDn,organizationLink,parseDn,rdnValue,recordHookOwner,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue};
1
+ import{BadRequestError as e}from"./errors.js";import{getLogger as n}from"./expressFormatedResponses.js";const r=new Map;function getCompiledRegex(e,n){const t=n?`${e}:${n}`:e;let i=r.get(t);return i||(i=new RegExp(e,n),r.set(t,i)),i}function escapeRegex(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}const describeThrown=e=>{try{return JSON.stringify(e)??String(e)}catch{return String(e)}},hookLabel=e=>{const n=hookOwner(e);return n?` in ${n.plugin} (${n.hook})`:e.name?` in ${e.name}`:""},launchHooks=async(e,...r)=>{if(e)for(const t of e)if(t)try{await t(...r)}catch(e){const r=hookLabel(t);e instanceof Error?n()?.error(`Hook error${r}`,e):n()?.error(`Hook error${r}: ${describeThrown(e)}`)}},t=new WeakMap,recordHookOwner=(e,n,r)=>{t.set(e,{plugin:n,hook:r})},hookOwner=e=>t.get(e),isAuthzRefusal=e=>/\[authz-forbidden\]/.test(String(e?.message??"")),isRefusal=e=>403===e?.statusCode||isAuthzRefusal(e),launchHooksChained=async(e,r)=>{if(e)for(const t of e)if(t)try{r=await t(r)}catch(e){if(isRefusal(e)){const r=hookOwner(t);n()?.warn(`${r?`${r.plugin} refused ${r.hook}`:`a hook no plugin registered (${t.name||"anonymous"}) refused`}: ${e.message}`)}throw e}return r},transformSchemas=(e,n)=>e.toString().replace(/__(\S+)__/g,(e,r)=>{if(!r||"string"!=typeof r)return e;const t=r.trim().toLowerCase();return n[t]?"object"!=typeof n[t]?n[t]:JSON.stringify(n[t]):e});function escapeLdapFilter(e){return e.replace(/\\/g,"\\5c").replace(/\*/g,"\\2a").replace(/\(/g,"\\28").replace(/\)/g,"\\29").replace(/\0/g,"\\00")}function substringSearchFilter(n,r){const t=/^[a-zA-Z][a-zA-Z0-9-]*$/,i=r.split(",").map(e=>e.trim()).filter(Boolean);if(0===i.length||!i.every(e=>t.test(e)))throw new e("Invalid LDAP attribute name");const o=escapeLdapFilter(n),a=i.map(e=>`(${e}=*${o}*)`);return 1===a.length?a[0]:`(|${a.join("")})`}function escapeDnValue(e){return e.replace(/\\/g,"\\\\").replace(/,/g,"\\,").replace(/\+/g,"\\+").replace(/"/g,'\\"').replace(/</g,"\\<").replace(/>/g,"\\>").replace(/;/g,"\\;").replace(/=/g,"\\=").replace(/\0/g,"\\00").replace(/^\s/,"\\ ").replace(/\s$/,"\\ ").replace(/^#/,"\\#")}function unescapeDnValue(n){if("string"!=typeof n)throw new e("DN value must be a string");let r="",t=0;for(;t<n.length;)if("\\"===n[t]&&t+1<n.length){const e=n[t+1];if(/[0-9a-fA-F]/.test(e)&&t+2<n.length){const e=n.substring(t+1,t+3);if(/^[0-9a-fA-F]{2}$/.test(e)){r+=String.fromCharCode(parseInt(e,16)),t+=3;continue}}r+=e,t+=2}else r+=n[t],t++;return r}function validateDnValue(e,n){if(null==e||"string"!=typeof e)throw new Error(`${n} must be a string`);if(0===e.trim().length)throw new Error(`${n} must be a non-empty string`);if(/[\x00-\x1F\x7F]/.test(e))throw new Error(`${n} contains invalid control characters`);if(/[\u200B-\u200F\uFEFF]/.test(e))throw new Error(`${n} contains invalid invisible characters`)}function parseDn(n){if("string"!=typeof n)throw new e("DN must be a string");const r=[];let t="",i=!1;for(let e=0;e<n.length;e++){const o=n[e];i?(t+=o,i=!1):"\\"===o?(t+=o,i=!0):","===o?(r.push(t.trim()),t=""):t+=o}return t&&r.push(t.trim()),r}function getParentDn(e){const n=parseDn(e);return n.length<=1?e:n.slice(1).join(",")}function getRdn(e){return parseDn(e)[0]||""}function rdnValue(e){const n=/^[^=]+=((?:\\.|[^,])*)/.exec(e);return n?n[1].replace(/\\(.)/g,"$1"):""}function isChildOf(e,n){const r=e.toLowerCase(),t=n.toLowerCase();if(!r.endsWith(t))return!1;if(r.length===t.length)return!1;return r.substring(0,r.length-t.length).endsWith(",")}function normalizeDn(e){return parseDn(e).map(e=>e.split("+").map(e=>{const n=e.indexOf("=");return(-1===n?e.trim():`${e.slice(0,n).trim()}=${e.slice(n+1).trim()}`).toLowerCase()}).sort().join("+")).filter(e=>e.length>0).join(",")}function isDummyMemberDn(e,n){if(!n||"string"!=typeof e)return!1;if(e===n)return!0;try{return normalizeDn(e)===normalizeDn(n)}catch{return!1}}function organizationLink(e){const n=Array.isArray(e)?e[0]:e;if(null==n)return;const r=String(n);return r.length>0?r:void 0}function isDnInBranch(e,n){const r=normalizeDn(n).split(",").filter(Boolean);if(0===r.length)return!1;const t=normalizeDn(e).split(",").filter(Boolean);if(t.length<r.length)return!1;return t.slice(t.length-r.length).every((e,n)=>e===r[n])}function movedToTrash(e,n,r){return!!e&&isDnInBranch(r,e)&&!isDnInBranch(n,e)}const asyncHandler=e=>(n,r,t)=>{Promise.resolve(e(n,r,t)).catch(t)};export{asyncHandler,escapeDnValue,escapeLdapFilter,escapeRegex,getCompiledRegex,getParentDn,getRdn,hookOwner,isAuthzRefusal,isChildOf,isDnInBranch,isDummyMemberDn,launchHooks,launchHooksChained,movedToTrash,normalizeDn,organizationLink,parseDn,rdnValue,recordHookOwner,substringSearchFilter,transformSchemas,unescapeDnValue,validateDnValue};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as n,invalidValueMessage as r}from"../../config/schema.js";import{normalizeDn as i,isDnInBranch as a,getParentDn as s,rdnValue as o,escapeLdapFilter as c,isDummyMemberDn as u}from"../../lib/utils.js";import{BadRequestError as l,ConflictError as d}from"../../lib/errors.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){const n=t.toLowerCase();for(const[t,r]of Object.entries(e))if(t.split(";")[0].toLowerCase()===n)return valueList(r);return[]}function asDn(e){try{return i(e)}catch{return e}}const f={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return Math.round(e);const t=String(e).trim(),n=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!n)throw new l(`"${t}" is not a valid size`);const r=(n[2]||"B").toUpperCase();return Math.round(parseFloat(n[1])*f[r])}function fillsGeneratedAttribute(e,n,r){return!(void 0===n.default||!n.generated&&!n.normalize)||t(r,"organizationPath")===e}function parseDirectoryDate(e){const t=String(e).trim(),n=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})?(\d{2})?(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$/.exec(t);if(!n){const e=new Date(t);return isNaN(e.getTime())?null:e}const[,r,i,a,s,o,c,u,l]=n,[d,f,h,m,p,g]=[r,i,a,s,o,c].map(e=>void 0===e?0:Number(e));if(f<1||f>12||m>23||p>59||g>60)return null;let b=Date.UTC(d,f-1,h,m,p,g);const y=new Date(b);if(y.getUTCMonth()!==f-1||y.getUTCDate()!==h)return null;if(void 0!==u){const e=void 0!==c?1e3:void 0!==o?6e4:36e5;b+=Math.round(Number(`0.${u}`)*e)}if(l&&"Z"!==l){const e=l.slice(1).replace(":",""),t=Number(e.slice(0,2)),n=e.length>2?Number(e.slice(2,4)):0;if(t>23||n>59)return null;b-=("-"===l[0]?-1:1)*(60*t+n)*6e4}return new Date(b)}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,n=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const r=e.lastIndexOf("@");if(r<0)return!1;const i=e.slice(r+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return i===t||n&&i.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{constructor(e){super(e),this.name="ldapEnterpriseRules",this.roles=["consistency"],this.hooks={ldapaddrequest:async([e,t,n])=>{const r=this.resolveEntity(e);return r&&(await this.runChecks(r,e,t,null),await this.runTransformations(r,e,t,!0)),void 0!==n?[e,t,n]:[e,t]},ldapmodifyrequest:async([e,n,r,i])=>{const a=this.resolveEntity(e);if(!a)return[e,n,r,i];const s={...n.add||{},...n.replace||{}};if(0===Object.keys(s).length)return[e,n,r,i];const o=await this.readEntry(e),c=t(a.schema,"organizationLink"),u={...s};if(c&&c in s&&o)for(const[e,t]of Object.entries(a.schema.attributes))t.mailDomainScope&&(e in u||void 0!==o[e]&&(u[e]=o[e]));await this.runChecks(a,e,u,o),await this.runTransformations(a,e,s,!1);for(const[e,t]of Object.entries(s))n.replace&&e in n.replace?n.replace[e]=t:n.add&&e in n.add?n.add[e]=t:(n.replace=n.replace||{},n.replace[e]=t);return[e,n,r,i]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]}},this.dummyMember=this.config.group_dummy_user,this.topOrganization=this.config.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,n){return fillsGeneratedAttribute(e,t,n)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const n of t?.instances||[])n.schema&&e.push({base:n.base,subtree:!1,schema:n.schema,label:n.singularName});const n=this.server.loadedPlugins.ldapGroups;n?.base&&n.schema&&e.push({base:n.base,subtree:!0,schema:n.schema,label:"group"});const r=this.server.loadedPlugins.ldapOrganizations;return r?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:r.schema,label:"organization"}),e}resolveEntity(e){const t=i(e);let n,r=-1;for(const o of this.bindings()){const c=i(o.base);if(t===c)continue;if(!a(e,o.base))continue;if(!o.subtree&&i(s(e))!==c)continue;const u=c.split(",").length;u>r&&(n=o,r=u)}return n}async runChecks(e,t,r,i){for(const[a,s]of Object.entries(r)){const o=e.schema.attributes[a];if(!o)continue;const c=valueList(s);0!==c.length&&(o.unique&&await this.checkUnique(e,a,o,c,i?t:null),o.mailDomainScope&&await this.checkMailDomains(e,o,c,r,i),n(o,"accountExpiry")&&this.checkNotInThePast(a,o,c))}}async runTransformations(e,n,i,a){for(const[t,n]of Object.entries(e.schema.attributes)){if(a&&void 0===i[t]&&void 0!==n.default&&(n.generated||n.normalize)&&(i[t]=Array.isArray(n.default)?n.default.map(e=>String(e)):String(n.default),"pointer"===n.type||"pointer"===n.items?.type))for(const e of valueList(i[t]))if(!await this.readEntry(e))throw new l(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===n.normalize&&void 0!==i[t]){const e=valueList(i[t]);try{i[t]=Array.isArray(i[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new l(r(t,n))}}}const c=t(e.schema,"organizationPath");if(!c)return;if(!e.schema.attributes[c]?.generated&&void 0!==i[c])return;const u=t(e.schema,"organizationLink");if(u){const e=valueList(i[u])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new l(`Organization ${e} has no ${c}, so "${c}" cannot be computed`);return void(i[c]=t)}if(!a)return;const d=valueList(i[t(e.schema,"identifier")||""])[0]||o(n);if(!d)return;const f=s(n),h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!h||f.toLowerCase().endsWith(`,${h}`)?await this.organizationPath(f):void 0,p=this.config.ldap_organization_path_separator||" / ";i[c]=m?`${m}${p}${d}`:d}async checkUnique(e,t,n,r,i){const a="object"==typeof n.unique?n.unique:{},s=[t,...a.attributes||[]],o=function(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),n=[];for(const r of[e,...t||[]]){const e=r.toLowerCase();if(!n.some(t=>contains(t.toLowerCase(),e))){for(let t=n.length-1;t>=0;t--)contains(e,n[t].toLowerCase())&&n.splice(t,1);n.push(r)}}return n}(e.base,a.branches);for(const n of r){if(void 0!==a.sentinel&&n===a.sentinel)continue;const r=s.map(e=>`(${e}=${c(n)})`).join("");let u=s.length>1?`(|${r})`:r;a.filter&&(u=`(&${a.filter}${u})`);for(const r of o){const a=await this.server.ldap.search({paged:!1,scope:"sub",filter:u,attributes:["dn"]},r);for(const r of a.searchEntries||[]){const a=String(r.dn);if(!i||a.toLowerCase()!==i.toLowerCase())throw this.logger.warn(`${t}="${n}" refused for ${i||"a new entry"}: already held by ${a}`),new d(`${e.label}: "${n}" is already used (${t} must be unique)`)}}}}async checkMailDomains(e,t,n,r,i){const a="directory"===t.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,r,i);if(0!==a.length)for(const e of n)if(!mailInDomains(e,a,t.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${a.join(", ")})`)}async organizationDomains(e,n,r){const i=t(e.schema,"organizationLink");if(!i)return[];const a=valueList(n[i])[0]||valueList(r?.[i])[0]||void 0;if(!a)return[];const o=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(o,"domainLink")||this.config.enterprise_domain_link_attribute;if(!c)return[];const u=[],l=new Set;let d=a;const f=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),h=this.domainNameAttribute();for(;d&&!l.has(d.toLowerCase());){l.add(d.toLowerCase());const e=await this.readEntry(d);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),n=valueList(e?.[h]);0===n.length&&this.logger.warn(`Organization ${d} links to domain ${t}, which has no "${h}": no mail domain restriction comes from it`),u.push(...n)}if(d.toLowerCase()===f)break;const t=s(d);d=t===d?void 0:t}return u}domainNameAttribute(){for(const e of this.bindings()){const n=t(e.schema,"domainName");if(n)return n}return this.config.enterprise_domain_name_attribute||"associatedDomain"}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),n=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),r=[];for(const e of n.searchEntries||[])r.push(...valueList(e[t]));return r}checkNotInThePast(e,t,n){const i=startOfUtcDay();for(const a of n){const n=parseDirectoryDate(a);if(!n)throw new l(r(e,t));if(n.getTime()<i)throw new l(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const n=this.bindings().find(e=>"organization"===e.label)?.schema,r=t(n,"organizationPath")||this.config.ldap_organization_path_attribute;if(!r)return;const i=await this.readEntry(e);return i?valueList(i[r])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[n,r]of Object.entries(t.schema.attributes)){if("nonEmpty"!==r.deleteGuard)continue;const i=await this.readEntry(e),a=valueList(i?.[n]).filter(e=>!u(e,this.dummyMember));if(a.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${a.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const n=(await this.searchReferrers(t,e))[0];if(n)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(n.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}referringAttributes(e){const t=s(e),r=[];for(const e of this.bindings())for(const[i,s]of Object.entries(e.schema.attributes)){const o="pointer"===s.type?s:s.items,c=n(s,"members")||n(s,"owners");if(!o&&!c)continue;const u=o?.branch||[];u.length>0&&!u.some(e=>a(t,e))||r.push({binding:e,name:i,attr:s,pointer:o,multiValued:"array"===s.type})}return r}async searchReferrers(e,t){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${e.name}=${c(t)})`,attributes:["dn",e.name]},e.binding.base)).searchEntries||[]}async findReferences(e){const t=[],n=new Set;return await Promise.all(this.referringAttributes(e).map(r=>this.server.ldap.queryLimit(async()=>{for(const i of await this.searchReferrers(r,e)){const e=String(i.dn),a=`${asDn(e)} ${r.name.toLowerCase()}`;n.has(a)||(n.add(a),t.push({dn:e,attribute:r.name,values:entryValues(i,r.name),multiValued:r.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,n,r){const i=asDn(t),a=asDn(n);if(i===a)return{updated:0,failed:[]};const s=r?this.server.ldap.forRequest(r):{modify:(e,t)=>this.server.ldap.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const u=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(s,e,i,a,n),c++}catch(r){this.logger.error(`Rename ${t} -> ${n}: could not rewrite ${e.attribute} of ${e.dn}: ${String(r)}`),u.set(e.attribute,(u.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...u].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,n,r,i){const a=this.rewrite(t,n,r,i);if(!a)return;try{return void await e.modify(t.dn,a)}catch(e){const n=e.code;if(20!==n&&16!==n)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${n}); reading it back`)}const s={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(s,n,r,i);o&&await e.modify(s.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const n=await this.server.ldap.search({paged:!1,scope:"base",attributes:[t]},e),r=n.searchEntries?.[0];return r?entryValues(r,t):[]}rewrite(e,t,n,r){const i=e.attribute;if(t===n)return;const a=e.values.filter(e=>asDn(e)===t);if(0===a.length)return;const s=e.values.some(e=>asDn(e)===n);if(!e.multiValued){const n=[];for(const i of e.values){const e=asDn(i)===t?r:i;n.some(t=>asDn(t)===asDn(e))||n.push(e)}return{replace:{[i]:n}}}return s?{delete:{[i]:a}}:{delete:{[i]:a},add:{[i]:r}}}async readEntry(e){try{const t=await this.server.ldap.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,parseDirectoryDate,startOfUtcDay};
1
+ import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as r,invalidValueMessage as n}from"../../config/schema.js";import{movedToTrash as i,normalizeDn as a,isDnInBranch as s,getParentDn as o,rdnValue as c,escapeLdapFilter as u,isDummyMemberDn as l}from"../../lib/utils.js";import{BadRequestError as f,ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){const r=t.toLowerCase();for(const[t,n]of Object.entries(e))if(t.split(";")[0].toLowerCase()===r)return valueList(n);return[]}function asDn(e){try{return a(e)}catch{return e}}const m={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return Math.round(e);const t=String(e).trim(),r=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!r)throw new f(`"${t}" is not a valid size`);const n=(r[2]||"B").toUpperCase();return Math.round(parseFloat(r[1])*m[n])}function fillsGeneratedAttribute(e,r,n){return!(void 0===r.default||!r.generated&&!r.normalize)||t(n,"organizationPath")===e}function parseDirectoryDate(e){const t=String(e).trim(),r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})?(\d{2})?(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$/.exec(t);if(!r){const e=new Date(t);return isNaN(e.getTime())?null:e}const[,n,i,a,s,o,c,u,l]=r,[f,d,h,m,p,g]=[n,i,a,s,o,c].map(e=>void 0===e?0:Number(e));if(d<1||d>12||m>23||p>59||g>60)return null;let b=Date.UTC(f,d-1,h,m,p,g);const y=new Date(b);if(y.getUTCMonth()!==d-1||y.getUTCDate()!==h)return null;if(void 0!==u){const e=void 0!==c?1e3:void 0!==o?6e4:36e5;b+=Math.round(Number(`0.${u}`)*e)}if(l&&"Z"!==l){const e=l.slice(1).replace(":",""),t=Number(e.slice(0,2)),r=e.length>2?Number(e.slice(2,4)):0;if(t>23||r>59)return null;b-=("-"===l[0]?-1:1)*(60*t+r)*6e4}return new Date(b)}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,r=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const n=e.lastIndexOf("@");if(n<0)return!1;const i=e.slice(n+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return i===t||r&&i.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{constructor(e){super(e),this.name="ldapEnterpriseRules",this.roles=["consistency"],this.hooks={ldapaddrequest:async([e,t,r])=>{const n=this.resolveEntity(e);return n&&(await this.runChecks(n,e,t,null),await this.runTransformations(n,e,t,!0)),void 0!==r?[e,t,r]:[e,t]},ldapmodifyrequest:async([e,r,n,i])=>{const a=this.resolveEntity(e);if(!a)return[e,r,n,i];const s={...r.add||{},...r.replace||{}};if(0===Object.keys(s).length)return[e,r,n,i];const o=await this.readEntry(e),c=t(a.schema,"organizationLink"),u={...s};if(c&&c in s&&o)for(const[e,t]of Object.entries(a.schema.attributes))t.mailDomainScope&&(e in u||void 0!==o[e]&&(u[e]=o[e]));await this.runChecks(a,e,u,o),await this.runTransformations(a,e,s,!1);for(const[e,t]of Object.entries(s))r.replace&&e in r.replace?r.replace[e]=t:r.add&&e in r.add?r.add[e]=t:(r.replace=r.replace||{},r.replace[e]=t);return[e,r,n,i]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]},ldapdeletedone:async(e,t)=>{for(const r of Array.isArray(e)?e:[e])await this.removeReferences(r,t)},ldaprenamedone:async([e,t],r)=>{i(this.config.trash_base,e,t)&&await this.removeReferences(e,r)}},this.dummyMember=this.config.group_dummy_user,this.topOrganization=this.config.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,r){return fillsGeneratedAttribute(e,t,r)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const r of t?.instances||[])r.schema&&e.push({base:r.base,subtree:!1,schema:r.schema,label:r.singularName});const r=this.server.loadedPlugins.ldapGroups;r?.base&&r.schema&&e.push({base:r.base,subtree:!0,schema:r.schema,label:"group"});const n=this.server.loadedPlugins.ldapOrganizations;return n?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:n.schema,label:"organization"}),e}resolveEntity(e){const t=a(e);let r,n=-1;for(const i of this.bindings()){const c=a(i.base);if(t===c)continue;if(!s(e,i.base))continue;if(!i.subtree&&a(o(e))!==c)continue;const u=c.split(",").length;u>n&&(r=i,n=u)}return r}async runChecks(e,t,n,i){for(const[a,s]of Object.entries(n)){const o=e.schema.attributes[a];if(!o)continue;const c=valueList(s);0!==c.length&&(o.unique&&await this.checkUnique(e,a,o,c,i?t:null),o.mailDomainScope&&await this.checkMailDomains(e,o,c,n,i),r(o,"accountExpiry")&&this.checkNotInThePast(a,o,c))}}async runTransformations(e,r,i,a){for(const[t,r]of Object.entries(e.schema.attributes)){if(a&&void 0===i[t]&&void 0!==r.default&&(r.generated||r.normalize)&&(i[t]=Array.isArray(r.default)?r.default.map(e=>String(e)):String(r.default),"pointer"===r.type||"pointer"===r.items?.type))for(const e of valueList(i[t]))if(!await this.readEntry(e))throw new f(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===r.normalize&&void 0!==i[t]){const e=valueList(i[t]);try{i[t]=Array.isArray(i[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new f(n(t,r))}}}const s=t(e.schema,"organizationPath");if(!s)return;if(!e.schema.attributes[s]?.generated&&void 0!==i[s])return;const u=t(e.schema,"organizationLink");if(u){const e=valueList(i[u])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new f(`Organization ${e} has no ${s}, so "${s}" cannot be computed`);return void(i[s]=t)}if(!a)return;const l=valueList(i[t(e.schema,"identifier")||""])[0]||c(r);if(!l)return;const d=o(r),h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!h||d.toLowerCase().endsWith(`,${h}`)?await this.organizationPath(d):void 0,p=this.config.ldap_organization_path_separator||" / ";i[s]=m?`${m}${p}${l}`:l}async checkUnique(e,t,r,n,i){const a="object"==typeof r.unique?r.unique:{},s=[t,...a.attributes||[]],o=function(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),r=[];for(const n of[e,...t||[]]){const e=n.toLowerCase();if(!r.some(t=>contains(t.toLowerCase(),e))){for(let t=r.length-1;t>=0;t--)contains(e,r[t].toLowerCase())&&r.splice(t,1);r.push(n)}}return r}(e.base,a.branches);for(const r of n){if(void 0!==a.sentinel&&r===a.sentinel)continue;const n=s.map(e=>`(${e}=${u(r)})`).join("");let c=s.length>1?`(|${n})`:n;a.filter&&(c=`(&${a.filter}${c})`);for(const n of o){const a=await this.server.ldap.search({paged:!1,scope:"sub",filter:c,attributes:["dn"]},n);for(const n of a.searchEntries||[]){const a=String(n.dn);if(!i||a.toLowerCase()!==i.toLowerCase())throw this.logger.warn(`${t}="${r}" refused for ${i||"a new entry"}: already held by ${a}`),new d(`${e.label}: "${r}" is already used (${t} must be unique)`)}}}}async checkMailDomains(e,t,r,n,i){const a="directory"===t.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,n,i);if(0!==a.length)for(const e of r)if(!mailInDomains(e,a,t.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${a.join(", ")})`)}async organizationDomains(e,r,n){const i=t(e.schema,"organizationLink");if(!i)return[];const a=valueList(r[i])[0]||valueList(n?.[i])[0]||void 0;if(!a)return[];const s=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(s,"domainLink")||this.config.enterprise_domain_link_attribute;if(!c)return[];const u=[],l=new Set;let f=a;const d=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),h=this.domainNameAttribute();for(;f&&!l.has(f.toLowerCase());){l.add(f.toLowerCase());const e=await this.readEntry(f);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),r=valueList(e?.[h]);0===r.length&&this.logger.warn(`Organization ${f} links to domain ${t}, which has no "${h}": no mail domain restriction comes from it`),u.push(...r)}if(f.toLowerCase()===d)break;const t=o(f);f=t===f?void 0:t}return u}domainNameAttribute(){for(const e of this.bindings()){const r=t(e.schema,"domainName");if(r)return r}return this.config.enterprise_domain_name_attribute||"associatedDomain"}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),n=[];for(const e of r.searchEntries||[])n.push(...valueList(e[t]));return n}checkNotInThePast(e,t,r){const i=startOfUtcDay();for(const a of r){const r=parseDirectoryDate(a);if(!r)throw new f(n(e,t));if(r.getTime()<i)throw new f(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const r=this.bindings().find(e=>"organization"===e.label)?.schema,n=t(r,"organizationPath")||this.config.ldap_organization_path_attribute;if(!n)return;const i=await this.readEntry(e);return i?valueList(i[n])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[r,n]of Object.entries(t.schema.attributes)){if("nonEmpty"!==n.deleteGuard)continue;const i=await this.readEntry(e),a=valueList(i?.[r]).filter(e=>!l(e,this.dummyMember));if(a.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${a.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const r=(await this.searchReferrers(t,e))[0];if(r)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(r.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}async removeReferences(e,t){const r=asDn(e),n=new Set;for(const i of this.referringAttributes(e)){if("remove"!==i.attr.referentialIntegrity)continue;let a;try{a=await this.searchReferrers(i,e)}catch(t){32!==h(t)&&this.logger.error(`Delete of ${e}: could not look for ${i.name} references: ${String(t)}`);continue}for(const s of a){const a=String(s.dn),o=`${asDn(a)} ${i.name.toLowerCase()}`;if(n.has(o))continue;n.add(o);const c=entryValues(s,i.name).filter(e=>asDn(e)===r);if(0!==c.length)try{await this.server.ldap.modify(a,{delete:{[i.name]:c}},void 0,{context:t})}catch(t){if(16===h(t))continue;this.logger.error(`Delete of ${e}: could not remove it from ${i.name} of ${a}: ${String(t)}`)}}}}referringAttributes(e){const t=o(e),n=[];for(const e of this.bindings())for(const[i,a]of Object.entries(e.schema.attributes)){const o="pointer"===a.type?a:a.items,c=r(a,"members")||r(a,"owners");if(!o&&!c)continue;const u=o?.branch||[];u.length>0&&!u.some(e=>s(t,e))||n.push({binding:e,name:i,attr:a,pointer:o,multiValued:"array"===a.type})}return n}async searchReferrers(e,t){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${e.name}=${u(t)})`,attributes:["dn",e.name]},e.binding.base)).searchEntries||[]}async findReferences(e){const t=[],r=new Set;return await Promise.all(this.referringAttributes(e).map(n=>this.server.ldap.queryLimit(async()=>{for(const i of await this.searchReferrers(n,e)){const e=String(i.dn),a=`${asDn(e)} ${n.name.toLowerCase()}`;r.has(a)||(r.add(a),t.push({dn:e,attribute:n.name,values:entryValues(i,n.name),multiValued:n.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,r,n){const i=asDn(t),a=asDn(r);if(i===a)return{updated:0,failed:[]};const s=n?this.server.ldap.forRequest(n):{modify:(e,t)=>this.server.ldap.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const u=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(s,e,i,a,r),c++}catch(n){this.logger.error(`Rename ${t} -> ${r}: could not rewrite ${e.attribute} of ${e.dn}: ${String(n)}`),u.set(e.attribute,(u.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...u].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,r,n,i){const a=this.rewrite(t,r,n,i);if(!a)return;try{return void await e.modify(t.dn,a)}catch(e){const r=e.code;if(20!==r&&16!==r)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${r}); reading it back`)}const s={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(s,r,n,i);o&&await e.modify(s.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const r=await this.server.ldap.search({paged:!1,scope:"base",attributes:[t]},e),n=r.searchEntries?.[0];return n?entryValues(n,t):[]}rewrite(e,t,r,n){const i=e.attribute;if(t===r)return;const a=e.values.filter(e=>asDn(e)===t);if(0===a.length)return;const s=e.values.some(e=>asDn(e)===r);if(!e.multiValued){const r=[];for(const i of e.values){const e=asDn(i)===t?n:i;r.some(t=>asDn(t)===asDn(e))||r.push(e)}return{replace:{[i]:r}}}return s?{delete:{[i]:a}}:{delete:{[i]:a},add:{[i]:n}}}async readEntry(e){try{const t=await this.server.ldap.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,parseDirectoryDate,startOfUtcDay};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{asyncHandler as c,isDummyMemberDn as d,escapeDnValue as p,validateDnValue as m,launchHooksChained as u,launchHooks as l,escapeLdapFilter as f,organizationLink as g,isAuthzRefusal as w,substringSearchFilter as b}from"../../lib/utils.js";import{NotFoundError as y,BadRequestError as $,HttpError as v}from"../../lib/errors.js";import{assertClientMaySet as _,modifiedAttributeNames as A,toDirectoryEntry as G,invalidValueMessage as q,missingRequiredAttribute as C,toDirectoryChanges as E,checkDnValues as j,matchesPattern as k}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,c(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new $("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=b(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new $("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=f(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,c(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,c(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,c(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,c(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,c(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new $("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new $("member must be a string or string array");await s(t,this.addMember.bind(this),r,a.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,c(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new $("cn and member are required");await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,c(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new $("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new $("targetOrgDn must be a string");await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)}})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,c(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new $("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new $("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn,e)}}))}isDummyMember(e){return d(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const r=/,/.test(i)?i:`${this.cn}=${p(i)},${this.base}`,s=await this.ldap.search({paged:!1,scope:"base"},r,e);if(0===s.searchEntries.length)throw new y("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(s.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new y("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;_(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new $("cn is required");await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=this.fixDn(decodeURIComponent(e.params.cn));if(!o)throw new $("cn is required");_(this.schema,A(a));const n=Object.fromEntries(Object.entries(a).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(r,this.modifyGroup.bind(this),o,n,e)}async addGroup(e,t=[],r={},i){let s;new RegExp(`^${this.cn}=`).test(e)?(s=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(m(e,this.cn),s=`${this.cn}=${p(e)},${this.base}`),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r});const a=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||a.includes("twakeGroup")||a.includes("twakeStaticGroup")||a.push("twakeGroup");let o,n={objectClass:a,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&n.member.push(...t),G(this.schema,n),[s,n]=await u(this.registeredHooks.ldapgroupadd,[s,n]);try{o=await this.ldap.add(s,n,i)}catch(e){if(e instanceof v)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return l(this.registeredHooks.ldapgroupadddone,[s,n]),o}async modifyGroup(e,t,r){let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;const s=this.opNumber();if([i,t]=await u(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new $("Use dedicated API to add members");if(t.add[this.cn])throw new $(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new $("Use dedicated API to delete members");if(t.delete[this.cn])throw new $(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new $("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new $(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new $("Use dedicated API to replace members");if(t.replace[this.cn])throw new $(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return l(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||m(e,this.cn),/,/.test(t)||m(t,this.cn);let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`,s=/,/.test(t)?t:`${this.cn}=${p(t)},${this.base}`;[i,s]=await u(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return l(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;r=await u(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return l(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await u(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof v)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;if([e,t]=await u(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new $("Cannot delete dummy member from group");return await this.ldap.modify(i,{delete:{member:t}},r).catch(e=>{if(16===o(e))throw new y(`${t} is not a member of ${i}`);if(e instanceof v)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${f(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>{if(65===o(r)&&this.config.group_dummy_user)return this.ldap.modify(t.dn,{add:{member:this.config.group_dummy_user},delete:{member:e}});throw r}).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;let n;try{n=await this.ldap.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new y(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new y(`Group ${a} not found`);const h=n.searchEntries[0],c=g(h[i]);if(!c)throw new $(`Group ${a} does not have ${i} attribute and cannot be moved`);if(c===t)throw new $("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new y(`Target organization ${t} not found`);if(34===r)throw new $(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new y(`Target organization ${t} not found`);const m=d.searchEntries[0][s];if(!m)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:m}},r),this.logger.info(`Group ${a} moved from ${c} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await u(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=f(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`)}async validateMembers(e,t,r){[e,t]=await u(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(w(t))throw t;if(i)return;const r=o(t);if(32===r)throw new $(`Member ${e} not found`);if(34===r)throw new $(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new $(`Member ${e} not found`)})))}catch(e){if(e instanceof v||w(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return G(void 0,t),!0;[e,t]=await u(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new $(q(e,this.schema.attributes[e]));const r=C(this.schema,t,this.server.loadedPlugins);if(r)throw new $(`Attribute "${r}" is required`);return G(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return E(void 0,t),!0;if([e,t]=await u(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new $(q(e,this.schema.attributes[e]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new $(q(e,this.schema.attributes[e]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return E(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new $(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new $(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new $(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new $(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await j(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!k(r,t))throw new $(q(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
1
+ import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{movedToTrash as c,asyncHandler as d,isDummyMemberDn as p,escapeDnValue as m,validateDnValue as u,launchHooksChained as l,launchHooks as f,escapeLdapFilter as g,organizationLink as w,isAuthzRefusal as b,substringSearchFilter as y}from"../../lib/utils.js";import{NotFoundError as $,BadRequestError as v,HttpError as _}from"../../lib/errors.js";import{assertClientMaySet as A,modifiedAttributeNames as G,toDirectoryEntry as q,invalidValueMessage as C,missingRequiredAttribute as E,toDirectoryChanges as j,checkDnValues as k,matchesPattern as M}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})},ldaprenamedone:async([e,t])=>{c(this.config.trash_base,e,t)&&await this.deleteMemberFromAll(e).catch(e=>{this.logger.error("Failed to process a move to the trash in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new v("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=y(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new v("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=g(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,d(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new v("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new v("member must be a string or string array");await s(t,this.addMember.bind(this),r,a.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,d(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new v("cn and member are required");await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new v("targetOrgDn must be a string");await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)}})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new v("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn,e)}}))}isDummyMember(e){return p(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const r=/,/.test(i)?i:`${this.cn}=${m(i)},${this.base}`,s=await this.ldap.search({paged:!1,scope:"base"},r,e);if(0===s.searchEntries.length)throw new $("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(s.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new $("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;A(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new v("cn is required");await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=this.fixDn(decodeURIComponent(e.params.cn));if(!o)throw new v("cn is required");A(this.schema,G(a));const n=Object.fromEntries(Object.entries(a).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(r,this.modifyGroup.bind(this),o,n,e)}async addGroup(e,t=[],r={},i){let s;new RegExp(`^${this.cn}=`).test(e)?(s=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(u(e,this.cn),s=`${this.cn}=${m(e)},${this.base}`),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r});const a=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||a.includes("twakeGroup")||a.includes("twakeStaticGroup")||a.push("twakeGroup");let o,n={objectClass:a,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&n.member.push(...t),q(this.schema,n),[s,n]=await l(this.registeredHooks.ldapgroupadd,[s,n]);try{o=await this.ldap.add(s,n,i)}catch(e){if(e instanceof _)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return f(this.registeredHooks.ldapgroupadddone,[s,n]),o}async modifyGroup(e,t,r){let i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;const s=this.opNumber();if([i,t]=await l(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new v("Use dedicated API to add members");if(t.add[this.cn])throw new v(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new v("Use dedicated API to delete members");if(t.delete[this.cn])throw new v(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new v("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new v(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new v("Use dedicated API to replace members");if(t.replace[this.cn])throw new v(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return f(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||u(e,this.cn),/,/.test(t)||u(t,this.cn);let i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`,s=/,/.test(t)?t:`${this.cn}=${m(t)},${this.base}`;[i,s]=await l(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return f(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;r=await l(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return f(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await l(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof _)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;if([e,t]=await l(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new v("Cannot delete dummy member from group");return await this.ldap.modify(i,{delete:{member:t}},r).catch(e=>{if(16===o(e))throw new $(`${t} is not a member of ${i}`);if(e instanceof _)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${g(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>{if(65===o(r)&&this.config.group_dummy_user)return this.ldap.modify(t.dn,{add:{member:this.config.group_dummy_user},delete:{member:e}});throw r}).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`;let n;try{n=await this.ldap.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new $(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new $(`Group ${a} not found`);const h=n.searchEntries[0],c=w(h[i]);if(!c)throw new v(`Group ${a} does not have ${i} attribute and cannot be moved`);if(c===t)throw new v("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new $(`Target organization ${t} not found`);if(34===r)throw new v(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new $(`Target organization ${t} not found`);const p=d.searchEntries[0][s];if(!p)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:p}},r),this.logger.info(`Group ${a} moved from ${c} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await l(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=g(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${m(e)},${this.base}`)}async validateMembers(e,t,r){[e,t]=await l(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(b(t))throw t;if(i)return;const r=o(t);if(32===r)throw new v(`Member ${e} not found`);if(34===r)throw new v(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new v(`Member ${e} not found`)})))}catch(e){if(e instanceof _||b(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return q(void 0,t),!0;[e,t]=await l(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));const r=E(this.schema,t,this.server.loadedPlugins);if(r)throw new v(`Attribute "${r}" is required`);return q(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return j(void 0,t),!0;if([e,t]=await l(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new v(C(e,this.schema.attributes[e]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return j(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new v(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new v(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new v(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new v(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await k(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!M(r,t))throw new v(C(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
@@ -32,6 +32,9 @@ export type SchemaAttributeType = 'string' | 'number' | 'integer' | 'boolean' |
32
32
  * | `accountExpiry` | Date after which the account is to be removed |
33
33
  * | `domainLink` | DNs of the mail domains an organization may use |
34
34
  * | `domainName` | Domain name carried by a domain entry |
35
+ *
36
+ * Clients look for more: `givenName` and `surname` name the parts of a
37
+ * person's name, which a form composes the `displayName` from.
35
38
  */
36
39
  export type SchemaRole = string;
37
40
  /**
@@ -150,6 +153,12 @@ export interface SchemaAttribute {
150
153
  * client never has to hardcode either.
151
154
  */
152
155
  hint?: LocalizedText;
156
+ /**
157
+ * What the attribute is, for a client to show on demand — a tooltip next to
158
+ * the label — where its label alone does not say: "Employee ID" names a
159
+ * number, not whose records hold it.
160
+ */
161
+ help?: LocalizedText;
153
162
  branch?: string[];
154
163
  fixed?: boolean;
155
164
  /** Semantic role(s) — see {@link SchemaRole} */
@@ -208,10 +217,12 @@ export interface SchemaAttribute {
208
217
  */
209
218
  states?: Record<string, AttributeValue>;
210
219
  /**
211
- * For a `pointer`: refuse to delete the entry it points at while any entry
212
- * still references it. `ignore` (the default) leaves the reference dangling.
220
+ * For a `pointer`, or a list of them: what deleting the entry it points at
221
+ * does to the reference. `restrict` refuses the deletion while any entry
222
+ * still references it; `remove` takes the deleted DN out of the attribute;
223
+ * `ignore` (the default) leaves the reference dangling.
213
224
  */
214
- referentialIntegrity?: 'restrict' | 'ignore';
225
+ referentialIntegrity?: 'restrict' | 'remove' | 'ignore';
215
226
  /**
216
227
  * For a `members`-role attribute: refuse to delete the entry while it still
217
228
  * has members. The placeholder member some directories need to keep a
@@ -313,6 +313,18 @@ export declare function organizationLink(value: AttributeValue | undefined): str
313
313
  * ```
314
314
  */
315
315
  export declare function isDnInBranch(dn: string, base: string): boolean;
316
+ /**
317
+ * Tell whether a rename moved an entry into the trash branch.
318
+ *
319
+ * `core/ldap/trash` turns a deletion into such a move, so the DN it frees is
320
+ * as free as a deleted one, without any `ldapdeletedone`.
321
+ *
322
+ * @param trashBase the `--trash-base`, if any
323
+ * @param oldDn DN before the rename
324
+ * @param newDn DN after it
325
+ * @returns true when the entry entered the trash branch
326
+ */
327
+ export declare function movedToTrash(trashBase: string | undefined, oldDn: string, newDn: string): boolean;
316
328
  /**
317
329
  * Wrapper for async Express route handlers to catch errors and pass them to error middleware
318
330
  * This ensures that errors in async routes are properly handled and don't crash the server
@@ -5,7 +5,7 @@
5
5
  * Business rules an enterprise directory needs on top of declarative schema
6
6
  * validation: uniqueness across a shared namespace, mail addresses confined to
7
7
  * the domains an organization owns, computed organization paths, normalised
8
- * quotas, and referential-integrity guards on deletion.
8
+ * quotas, and referential integrity on deletion.
9
9
  *
10
10
  * Nothing here knows a concrete attribute name, a domain, or a nomenclature
11
11
  * value. Every rule is driven by the loaded entity schemas — their semantic
@@ -23,8 +23,9 @@
23
23
  *
24
24
  * ## What "a reference" is, and the one case this cannot see
25
25
  *
26
- * Two rules — the delete guard and the rename cascade — have to find every
27
- * entry naming another one by its DN. They look in two places, both declared
26
+ * Three rules — the delete guard, the removal of a deleted DN and the rename
27
+ * cascade — have to find every entry naming another one by its DN. They look
28
+ * in two places, both declared
28
29
  * by the schemas: every `pointer` (single, or under `items`) whose `branch`
29
30
  * admits the target, and every attribute carrying the `members` or `owners`
30
31
  * role. The second is not redundant: a group's member list cannot be a
@@ -33,8 +34,8 @@
33
34
  *
34
35
  * A deployment whose schema stores a DN in an attribute that is neither of
35
36
  * those — a plain `string` carrying no role — is therefore not covered: its
36
- * value is left naming the old DN after a rename, and does not hold a
37
- * deletion back. Declare such an attribute as a `pointer`, or give it the
37
+ * value is left naming the old DN after a rename, does not hold a deletion
38
+ * back, and is not removed when that DN is deleted. Declare such an attribute as a `pointer`, or give it the
38
39
  * role it plays. No marker is invented here to cover the gap: a marker
39
40
  * meaning "this happens to contain a DN" would be a third way of saying what
40
41
  * `pointer` and the roles already say.
@@ -295,10 +296,26 @@ export default class LdapEnterpriseRules extends DmPlugin {
295
296
  * @throws ConflictError naming the first referencing entry
296
297
  */
297
298
  private guardReferences;
299
+ /**
300
+ * Take a deleted DN out of every `referentialIntegrity: remove` pointer
301
+ * still naming it.
302
+ *
303
+ * Written with the server's own rights, not the caller's: the deletion was
304
+ * authorized, and the entries naming it are often outside what the caller
305
+ * may write — the organization a deleted account administered. A link left
306
+ * behind is a right waiting for the next entry given the same DN.
307
+ *
308
+ * A failure is logged, not thrown: the entry is already gone.
309
+ *
310
+ * @param dn entry just deleted
311
+ * @param context change context of the deletion, to attribute these writes
312
+ */
313
+ private removeReferences;
298
314
  /**
299
315
  * Every attribute of every loaded entity that could hold this DN.
300
316
  *
301
- * This is the traversal the delete guard and the rename cascade share: it
317
+ * This is the traversal the delete guard, the removal and the rename
318
+ * cascade share: it
302
319
  * knows no attribute name, only what the schemas declare — a `pointer`
303
320
  * (single or under `items`) whose `branch` admits the DN's parent, or an
304
321
  * attribute carrying the `members` or `owners` role. The module header says
@@ -103,7 +103,8 @@ export default class LdapGroups extends DmPlugin {
103
103
  /**
104
104
  * Remove deleted entries from groups once the delete has landed: a delete
105
105
  * refused, failed or turned into something else by another plugin leaves
106
- * the memberships alone.
106
+ * the memberships alone — except a move into the trash, which frees the DN
107
+ * all the same.
107
108
  */
108
109
  hooks: Hooks;
109
110
  /**
@@ -3,7 +3,7 @@
3
3
  The business rules an enterprise directory needs on top of per-attribute
4
4
  validation: uniqueness across a shared namespace, mail addresses confined to
5
5
  the domains an organization owns, computed organization paths, normalised
6
- quotas, and referential-integrity guards on deletion.
6
+ quotas, and referential integrity on deletion.
7
7
 
8
8
  Every rule is driven by the loaded entity schemas. The plugin contains no
9
9
  attribute name, no domain and no nomenclature value, so the same code serves a
@@ -134,24 +134,47 @@ An `accountExpiry` attribute must not carry a date already past. The comparison
134
134
  is against the start of the current day, so scheduling something for today is
135
135
  allowed.
136
136
 
137
- ### Deletion guards
137
+ ### Deletion and references
138
138
 
139
139
  ```json
140
140
  {
141
141
  "title": { "type": "pointer", "referentialIntegrity": "restrict" },
142
+ "twakeLocalAdminLink": {
143
+ "type": "array",
144
+ "items": { "type": "pointer", "branch": ["ou=users,dc=example,dc=com"] },
145
+ "referentialIntegrity": "remove"
146
+ },
142
147
  "member": { "type": "array", "role": "members", "deleteGuard": "nonEmpty" }
143
148
  }
144
149
  ```
145
150
 
146
- - `referentialIntegrity: "restrict"` refuses to delete an entry while another
147
- entry still points at it — a position somebody still holds, a nomenclature
148
- value still in use. Declared on the **referencing** attribute, and the
149
- pointer's `branch` tells the plugin which deletions to check.
150
- - `deleteGuard: "nonEmpty"` refuses to delete a group that still has members.
151
- The placeholder member some directories keep to satisfy `groupOfNames`
152
- (`--group-dummy-user`) does not count.
153
-
154
- Both default to off: a deployment turns them on where it wants them.
151
+ `referentialIntegrity` is declared on the **referencing** attribute, and the
152
+ pointer's `branch` tells the plugin which deletions concern it:
153
+
154
+ - `restrict` refuses to delete an entry while another entry still points at
155
+ it — a position somebody still holds, a nomenclature value still in use.
156
+ - `remove` takes the deleted entry's DN out of the attribute once the
157
+ deletion has landed: an account leaves the organizations it administered,
158
+ the mailboxes delegated to it, the groups it owned. It also applies to an
159
+ attribute carrying the `owners` role, which is not a pointer. A move into
160
+ the trash (`--trash-base`, see [trash](trash.md)) counts as a deletion,
161
+ [what a restore brings back](trash.md#limitations).
162
+ This runs with the server's rights, whoever deleted the entry, since a
163
+ link left behind would hand its rights to the next entry created with the
164
+ same DN; the writes are attributed to the deletion's author. Use it on
165
+ optional attributes only. Each deletion searches every attribute marked
166
+ `remove`: give them an equality index.
167
+ - `ignore`, the default, leaves the reference dangling.
168
+
169
+ `deleteGuard: "nonEmpty"` refuses to delete a group that still has members.
170
+ The placeholder member some directories keep to satisfy `groupOfNames`
171
+ (`--group-dummy-user`) does not count.
172
+
173
+ Both default to off: a deployment turns them on where it wants them. The
174
+ shipped Twake schemas mark `remove` on the links to accounts: the managers
175
+ and local administrators of an organization, the delegates of a mailbox,
176
+ the owners of a group or a shared mailbox, the owner and manager of a
177
+ device. Members leave their groups through the groups plugin.
155
178
 
156
179
  ## Errors
157
180
 
@@ -222,6 +222,27 @@ cannot drift apart, and neither is hardcoded in the interface. A translated
222
222
  hint is picked like a label; the server's messages are in English, so it
223
223
  quotes the English text.
224
224
 
225
+ ### Explaining an attribute: `help`
226
+
227
+ A label names an attribute; it does not always say what it is. `help` does,
228
+ for a client to show on demand — a tooltip next to the label — rather than
229
+ under every field:
230
+
231
+ ```json
232
+ {
233
+ "employeeNumber": {
234
+ "type": "string",
235
+ "label": { "en": "Employee ID", "fr": "Matricule" },
236
+ "help": {
237
+ "en": "The person's registration number, as the personnel records hold it.",
238
+ "fr": "Le matricule de la personne, tel que le tient la gestion du personnel."
239
+ }
240
+ }
241
+ }
242
+ ```
243
+
244
+ It is picked like a label. The server never shows it.
245
+
225
246
  ### Attributes a client may not send
226
247
 
227
248
  | Marker | Meaning |
@@ -303,6 +324,10 @@ chose in its own. The roles the core looks for:
303
324
  An attribute may carry several roles (`"role": ["identifier", "displayName"]`),
304
325
  and a deployment is free to add its own for its plugins.
305
326
 
327
+ Clients look for more: `givenName` and `surname` name the parts of a person's
328
+ name, which a form composes the `displayName` from. The shipped user schemas
329
+ mark `givenName` and `sn` with them.
330
+
306
331
  ### Which attributes a search covers: `searchable`
307
332
 
308
333
  `GET /{pluralName}?match=…&attribute=…` takes one attribute name or several
@@ -200,6 +200,15 @@ This plugin uses the `ldapdeleterequest` hook to intercept delete operations bef
200
200
  - Entries with the same RDN will overwrite previous trash entries
201
201
  - Tree structures (entries with children) must be deleted leaf-first
202
202
  - Does not track history (only keeps the latest deleted version)
203
+ - An entry moved to the trash leaves the links declared
204
+ `referentialIntegrity: "remove"`
205
+ ([details](enterprise-rules.md#deletion-and-references)) and, with
206
+ `core/ldap/groups`, its groups. A restore does not bring those links back:
207
+ a restored account has to be made a manager, an administrator or a
208
+ delegate again. Attributes the directory's `refint` overlay follows
209
+ (`member`, `owner`, `manager` in its stock configuration) are the
210
+ exception: `refint` may move the value to the trashed DN before it is
211
+ removed, and a restore then brings it back.
203
212
 
204
213
  ## Troubleshooting
205
214
 
@@ -4,6 +4,43 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## To 0.24.0
8
+
9
+ ### Links to deleted accounts
10
+
11
+ **Who is affected:** a deployment using the Twake schemas, or any schema
12
+ whose links to accounts now carry `referentialIntegrity: "remove"`
13
+ ([details](plugins/ldap/enterprise-rules.md#deletion-and-references)).
14
+
15
+ Accounts deleted from now on leave the links naming them. Those deleted
16
+ before still appear as managers, local administrators, delegates or owners,
17
+ and an account created again at the same address gets their rights. List
18
+ the dangling ones and remove them:
19
+
20
+ ```sh
21
+ # Connection to the directory, as for any ldapsearch
22
+ OPTS="-H ldap://localhost -D cn=admin,dc=example,dc=com -w secret"
23
+ BASE="dc=example,dc=com"
24
+ for attr in twakeLocalAdminLink twakeManagerLink twakeDelegatedUsers \
25
+ owner manager; do
26
+ ldapsearch $OPTS -LLL -o ldif-wrap=no -b "$BASE" "($attr=*)" "$attr" |
27
+ sed -n -e 's/^dn: //p' -e "s/^$attr: />/p" |
28
+ while IFS= read -r line; do
29
+ case "$line" in
30
+ ">"*) target="${line#>}"
31
+ ldapsearch $OPTS -LLL -s base -b "$target" dn >/dev/null 2>&1 ||
32
+ echo "$dn: $attr $target" ;;
33
+ *) dn="$line" ;;
34
+ esac
35
+ done
36
+ done
37
+ ```
38
+
39
+ Then, for each line, delete that value
40
+ (`ldapmodify`, `delete: <attribute>` with the DN shown). `owner` and
41
+ `manager` may also be listed for entries no schema marks `remove`, an
42
+ account's `manager` say: check before deleting those.
43
+
7
44
  ## To 0.22.0
8
45
 
9
46
  ### James follows the mail domains
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -37,11 +37,13 @@
37
37
  },
38
38
  "sn": {
39
39
  "type": "string",
40
- "required": true
40
+ "required": true,
41
+ "role": "surname"
41
42
  },
42
43
  "givenName": {
43
44
  "type": "string",
44
- "required": false
45
+ "required": false,
46
+ "role": "givenName"
45
47
  },
46
48
  "displayName": {
47
49
  "type": "string",
@@ -74,7 +74,8 @@
74
74
  "hint": "Expected the DN of an entry of this directory"
75
75
  },
76
76
  "required": false,
77
- "role": "owners"
77
+ "role": "owners",
78
+ "referentialIntegrity": "remove"
78
79
  },
79
80
  "twakeDepartmentLink": {
80
81
  "type": "pointer",
@@ -79,6 +79,7 @@
79
79
  "branch": ["ou=users,__LDAP_BASE__"]
80
80
  },
81
81
  "required": false,
82
+ "referentialIntegrity": "remove",
82
83
  "role": "managers",
83
84
  "label": "Managers"
84
85
  },
@@ -99,6 +100,7 @@
99
100
  "branch": ["ou=users,__LDAP_BASE__"]
100
101
  },
101
102
  "required": false,
103
+ "referentialIntegrity": "remove",
102
104
  "role": "localAdmins",
103
105
  "label": "Local administrators"
104
106
  }
@@ -57,6 +57,7 @@
57
57
  "test": "^[A-ZÀ-ÖØ-Þ](?!.*([\\s’'-]{2}|[.’'-]{2}|\\s\\.|[\\s’'-]$))[A-Za-zÀ-ÖØ-öø-ÿ\\s’'.-]{1,50}$",
58
58
  "hint": "Starts with a capital, up to 51 letters, no repeated space, apostrophe, hyphen or dot",
59
59
  "required": true,
60
+ "role": "surname",
60
61
  "label": "Last name"
61
62
  },
62
63
  "givenName": {
@@ -65,6 +66,7 @@
65
66
  "test": "^[A-ZÀ-ÖØ-Þ](?!.*([\\s’'-]{2}|[.’'-]{2}|\\s\\.|[\\s’'-]$))[A-Za-zÀ-ÖØ-öø-ÿ\\s’'.-]{1,50}$",
66
67
  "hint": "Starts with a capital, up to 51 letters, no repeated space, apostrophe, hyphen or dot",
67
68
  "required": true,
69
+ "role": "givenName",
68
70
  "label": "First name"
69
71
  },
70
72
  "displayName": {
@@ -53,7 +53,8 @@
53
53
  },
54
54
  "sn": {
55
55
  "type": "string",
56
- "required": true
56
+ "required": true,
57
+ "role": "surname"
57
58
  },
58
59
  "homeDirectory": {
59
60
  "type": "string",
@@ -76,7 +77,8 @@
76
77
  },
77
78
  "givenName": {
78
79
  "type": "string",
79
- "required": true
80
+ "required": true,
81
+ "role": "givenName"
80
82
  },
81
83
  "displayName": {
82
84
  "type": "string",
@@ -57,7 +57,8 @@
57
57
  "owner": {
58
58
  "type": "pointer",
59
59
  "branch": ["ou=users,__LDAP_BASE__"],
60
- "required": false
60
+ "required": false,
61
+ "referentialIntegrity": "remove"
61
62
  },
62
63
  "ou": {
63
64
  "type": "string",
@@ -66,7 +67,8 @@
66
67
  "manager": {
67
68
  "type": "pointer",
68
69
  "branch": ["ou=users,__LDAP_BASE__"],
69
- "required": false
70
+ "required": false,
71
+ "referentialIntegrity": "remove"
70
72
  }
71
73
  }
72
74
  }
@@ -31,7 +31,8 @@
31
31
  },
32
32
  "sn": {
33
33
  "type": "string",
34
- "required": true
34
+ "required": true,
35
+ "role": "surname"
35
36
  },
36
37
  "mail": {
37
38
  "type": "string",
@@ -55,7 +56,8 @@
55
56
  },
56
57
  "givenName": {
57
58
  "type": "string",
58
- "required": false
59
+ "required": false,
60
+ "role": "givenName"
59
61
  },
60
62
  "displayName": {
61
63
  "type": "string",
@@ -147,7 +147,8 @@
147
147
  "en": "Owners",
148
148
  "fr": "Propriétaires",
149
149
  "mn": "Эзэмшигчид"
150
- }
150
+ },
151
+ "referentialIntegrity": "remove"
151
152
  },
152
153
  "twakeDepartmentLink": {
153
154
  "type": "pointer",
@@ -109,6 +109,7 @@
109
109
  "branch": ["ou=users,__LDAP_BASE__"]
110
110
  },
111
111
  "required": false,
112
+ "referentialIntegrity": "remove",
112
113
  "role": "managers",
113
114
  "label": {
114
115
  "en": "Managers",
@@ -137,6 +138,7 @@
137
138
  "branch": ["ou=users,__LDAP_BASE__"]
138
139
  },
139
140
  "required": false,
141
+ "referentialIntegrity": "remove",
140
142
  "role": "localAdmins",
141
143
  "label": {
142
144
  "en": "Local administrators",
@@ -127,7 +127,8 @@
127
127
  "en": "Owners",
128
128
  "fr": "Propriétaires",
129
129
  "mn": "Эзэмшигчид"
130
- }
130
+ },
131
+ "referentialIntegrity": "remove"
131
132
  },
132
133
  "twakeDepartmentLink": {
133
134
  "type": "pointer",
@@ -53,12 +53,30 @@
53
53
  "type": "string",
54
54
  "searchable": true,
55
55
  "required": true,
56
- "unique": { "sentinel": "UNIT" },
56
+ "unique": {
57
+ "sentinel": "UNIT"
58
+ },
57
59
  "role": "employeeId",
58
60
  "label": {
59
61
  "en": "Employee ID",
60
62
  "fr": "Matricule",
61
63
  "mn": "Ажилтны дугаар"
64
+ },
65
+ "help": {
66
+ "en": "The person's registration number, as the personnel records hold it. Unique in the directory, except UNIT, which the accounts of no one in particular (shared, technical) take.",
67
+ "fr": "Le matricule de la personne, tel que le tient la gestion du personnel. Unique dans l'annuaire, sauf UNIT, que prennent les comptes qui ne sont à personne en particulier (partagés, techniques).",
68
+ "mn": "Хүний нөөцийн бүртгэлд байгаа ажилтны дугаар. Лавлахад давхцахгүй байх ёстой; хэн нэгэн хүнд хамааралгүй (дундын, техникийн) бүртгэлүүд UNIT утгыг авна."
69
+ }
70
+ },
71
+ "twakeDepartmentLink": {
72
+ "type": "pointer",
73
+ "branch": ["__LDAP_BASE__"],
74
+ "required": true,
75
+ "role": "organizationLink",
76
+ "label": {
77
+ "en": "Department",
78
+ "fr": "Organisation",
79
+ "mn": "Байгууллага"
62
80
  }
63
81
  },
64
82
  "cn": {
@@ -75,6 +93,7 @@
75
93
  "type": "string",
76
94
  "searchable": true,
77
95
  "required": true,
96
+ "role": "surname",
78
97
  "label": {
79
98
  "en": "Last name",
80
99
  "fr": "Nom de famille",
@@ -85,6 +104,7 @@
85
104
  "type": "string",
86
105
  "searchable": true,
87
106
  "required": true,
107
+ "role": "givenName",
88
108
  "label": {
89
109
  "en": "First name",
90
110
  "fr": "Prénom",
@@ -289,17 +309,6 @@
289
309
  "mn": "Нууц үгээ солих шаардлагатай"
290
310
  }
291
311
  },
292
- "twakeDepartmentLink": {
293
- "type": "pointer",
294
- "branch": ["__LDAP_BASE__"],
295
- "required": true,
296
- "role": "organizationLink",
297
- "label": {
298
- "en": "Department",
299
- "fr": "Organisation",
300
- "mn": "Байгууллага"
301
- }
302
- },
303
312
  "twakeDepartmentPath": {
304
313
  "type": "string",
305
314
  "test": "^[^\\r\\n]{1,4096}$",
@@ -375,6 +384,7 @@
375
384
  "branch": ["ou=users,__LDAP_BASE__"]
376
385
  },
377
386
  "required": false,
387
+ "referentialIntegrity": "remove",
378
388
  "group": {
379
389
  "en": "Mailbox Settings",
380
390
  "fr": "Paramètres de messagerie",