ldap-rest 0.23.0 → 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 +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/ldap/enterpriseRules.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/src/config/schema.d.ts +5 -3
- package/dist/src/lib/utils.d.ts +12 -0
- package/dist/src/plugins/ldap/enterpriseRules.d.ts +23 -6
- package/dist/src/plugins/ldap/groups.d.ts +2 -1
- package/docs/usage/plugins/ldap/enterprise-rules.md +34 -11
- package/docs/usage/plugins/ldap/trash.md +9 -0
- package/docs/usage/upgrading.md +37 -0
- package/package.json +1 -1
- package/static/schemas/example/groups.json +2 -1
- package/static/schemas/example/organizations.json +2 -0
- package/static/schemas/standard/devices.json +4 -2
- package/static/schemas/twake/groups.json +2 -1
- package/static/schemas/twake/organizations.json +2 -0
- package/static/schemas/twake/teamMailboxes.json +2 -1
- package/static/schemas/twake/users.json +1 -0
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
|
|
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};
|
|
@@ -217,10 +217,12 @@ export interface SchemaAttribute {
|
|
|
217
217
|
*/
|
|
218
218
|
states?: Record<string, AttributeValue>;
|
|
219
219
|
/**
|
|
220
|
-
* For a `pointer
|
|
221
|
-
*
|
|
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.
|
|
222
224
|
*/
|
|
223
|
-
referentialIntegrity?: 'restrict' | 'ignore';
|
|
225
|
+
referentialIntegrity?: 'restrict' | 'remove' | 'ignore';
|
|
224
226
|
/**
|
|
225
227
|
* For a `members`-role attribute: refuse to delete the entry while it still
|
|
226
228
|
* has members. The placeholder member some directories need to keep a
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
27
|
-
* entry naming another one by its DN. They look
|
|
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,
|
|
37
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
|
@@ -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
|
|
package/docs/usage/upgrading.md
CHANGED
|
@@ -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
|
@@ -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,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
|
}
|
|
@@ -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",
|