ldap-rest 0.16.2 → 0.17.1

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.
Files changed (39) hide show
  1. package/bin/sync-james.mjs +4 -4
  2. package/dist/abstract/ldapFlat.js +1 -1
  3. package/dist/abstract/twakePlugin.js +1 -1
  4. package/dist/bin/index.js +1 -1
  5. package/dist/config/args.js +1 -1
  6. package/dist/lib/ldapActions.js +1 -1
  7. package/dist/lib/ldapCodes.js +1 -1
  8. package/dist/lib/schemaFile.js +1 -0
  9. package/dist/plugins/ldap/bulkImport.js +1 -1
  10. package/dist/plugins/ldap/flatGeneric.js +1 -1
  11. package/dist/plugins/ldap/groups.js +1 -1
  12. package/dist/plugins/ldap/onChange.js +1 -1
  13. package/dist/plugins/ldap/organizations.js +1 -1
  14. package/dist/plugins/scim/discovery.js +1 -1
  15. package/dist/plugins/scim/errors.js +1 -1
  16. package/dist/plugins/static.js +1 -1
  17. package/dist/plugins/twake/james.js +1 -1
  18. package/dist/src/abstract/ldapFlat.d.ts +9 -0
  19. package/dist/src/abstract/twakePlugin.d.ts +18 -1
  20. package/dist/src/bin/index.d.ts +10 -0
  21. package/dist/src/config/args.d.ts +2 -0
  22. package/dist/src/hooks.d.ts +3 -1
  23. package/dist/src/lib/ldapCodes.d.ts +7 -0
  24. package/dist/src/lib/schemaFile.d.ts +64 -0
  25. package/dist/src/plugins/ldap/flatGeneric.d.ts +7 -0
  26. package/dist/src/plugins/ldap/groups.d.ts +9 -0
  27. package/dist/src/plugins/static.d.ts +15 -1
  28. package/dist/src/plugins/twake/james.d.ts +1 -0
  29. package/docs/client-development/api/rest-api.md +26 -0
  30. package/docs/client-development/schemas/README.md +86 -0
  31. package/docs/plugin-development/dependencies.md +6 -0
  32. package/docs/presentations/en.md +2 -2
  33. package/docs/presentations/fr.md +2 -2
  34. package/docs/usage/configuration.md +9 -1
  35. package/docs/usage/plugins/integrations/james-mail.md +44 -14
  36. package/docs/usage/plugins/ldap/on-change.md +7 -1
  37. package/docs/usage/upgrading.md +14 -0
  38. package/package.json +1 -1
  39. package/static/schemas/twake/users.json +0 -10
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,h=o?t[o]:void 0,l=normalize(d),u=normalize(h);l.length===u.length&&l.every((e,t)=>e===u[t])||(a[o||s]=[l.length?d:null,u.length?h:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);for(const[a,s]of Object.entries(i))if(this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===s||"onLdapForwardChange"===s||"onLdapAliasChange"===s)this.notifyAttributeChangeWithMail(this.config[a],s,e,n,r);else if("onLdapMailChange"===s)this.notifyAttributeChange(this.config[a],s,e,n);else if("onLdapDriveQuotaChange"===s){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const s=this.reconstructDisplayName(a),o=this.reconstructDisplayName(r);s!==o&&t(this.server.hooks.onLdapDisplayNameChange,e,s,o)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",h=a[d];let l;if(h)l=Array.isArray(h[1])?String(h[1][0]):String(h[1]);else{if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];l=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,l,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,l,e,a)}}reconstructDisplayName(e){const getValue=t=>{if(!e)return null;const n=Object.keys(e).find(e=>e.toLowerCase()===t.toLowerCase()),i=void 0===n?void 0:e[n];return i?Array.isArray(i)?i.length>0?String(i[0]):null:String(i):null},t=getValue(this.config.display_name_attribute||"displayName");if(t)return t;const n=getValue("cn");if(n)return n;const i=getValue("givenName"),a=getValue("sn");if(i||a){const e=[];return i&&e.push(i),a&&e.push(a),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
1
+ import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,l=o?t[o]:void 0,h=normalize(d),u=normalize(l);h.length===u.length&&h.every((e,t)=>e===u[t])||(a[o||s]=[h.length?d:null,u.length?l:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);const s=null===r;for(const[a,o]of Object.entries(i))if((!s||"onLdapMailChange"===o)&&this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===o||"onLdapForwardChange"===o||"onLdapAliasChange"===o)this.notifyAttributeChangeWithMail(this.config[a],o,e,n,r);else if("onLdapMailChange"===o)this.notifyAttributeChange(this.config[a],o,e,n);else if("onLdapDriveQuotaChange"===o){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const o=this.reconstructDisplayName(a),d=this.reconstructDisplayName(r);s||o===d||t(this.server.hooks.onLdapDisplayNameChange,e,o,d)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",l=a[d];let h;if(l?.[1])h=Array.isArray(l[1])?String(l[1][0]):String(l[1]);else{if(l)return void this.logger.debug(`${i} lost its mail, skipping ${n} notification`);if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];h=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,h,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,h,e,a)}}reconstructDisplayName(e){const getValue=t=>{if(!e)return null;const n=Object.keys(e).find(e=>e.toLowerCase()===t.toLowerCase()),i=void 0===n?void 0:e[n];return i?Array.isArray(i)?i.length>0?String(i[0]):null:String(i):null},t=getValue(this.config.display_name_attribute||"displayName");if(t)return t;const n=getValue("cn");if(n)return n;const i=getValue("givenName"),a=getValue("sn");if(i||a){const e=[];return i&&e.push(i),a&&e.push(a),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
@@ -1 +1 @@
1
- import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{transformSchemas as r,asyncHandler as o,validateDnValue as h,escapeDnValue as c,getRdn as d,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as f,escapeLdapFilter as u,launchHooksChained as w,isChildOf as m,substringSearchFilter as z}from"../../lib/utils.js";import{BadRequestError as y,NotFoundError as $,ConflictError as b}from"../../lib/errors.js";import{extractLdapCode as _}from"../../lib/ldapCodes.js";import{assertClientMaySet as v,modifiedAttributeNames as O,missingRequiredAttribute as C,checkDnValues as E,matchesPattern as A}from"../../config/schema.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new y("An organization link cannot be deleted");if(e)throw new y("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,i.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(r(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new y('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new y('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;v(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const s=a.parentDn||this.config.ldap_top_organization,r=`ou=${c(a.ou)},${s}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new y("dn is required");v(this.schema,O(a)),await n(i,this.modifyOrganization.bind(this),s,a,t)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new y("dn is required");await n(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!s(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new y("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new y("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new $(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new y(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(d(i))&&l(i,s)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let c=e;if((this.isOu(t)||void 0!==r)&&h){const t=s?g(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?p(s):"";if(!i||!t||!l(i,s)||p(i)===t||p(f(i))===t)return;throw new y(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new y(`Organization path must end with its own name, preceded by separator "${n}"`);c=e.slice(0,e.length-n.length-h.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${u(c)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new y(`Invalid organization path ${e}: no organization holds "${c}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${u(t)})`})).searchEntries.length>0)throw new b(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new y("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new $("Top organization not found");const[,a]=await w(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new $(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=f(s);let o=`(${this.config.ldap_organization_link_attribute}=${u(t)})`;return n&&(o=`(&${o}(objectClass=${u(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=_(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==_(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=r.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new y(this.invalidValueMessage(t));const a=C(this.schema,i,this.server.loadedPlugins);if(a)throw new y(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new y(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new y(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new y(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new y(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new y(`Field ${t} must be a single value`);return await E(t,a,i,t=>this.entryExists(t)),A(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new $(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new $(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new y(`Target ${i} is not an organizational unit`);const n=`${d(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new $(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new $(`Source organization ${t} not found`);if(n===t)throw new y("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||m(i,t))throw new y("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${z(i,"ou,description")})`,n,a));const s=this.config.ldap_top_organization,r=f(s),o=u(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${z(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${r}`),e.push(...await this.linkedEntities(r,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",s=`(&(objectClass=organizationalUnit)${z(i,n)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:s},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const n=_(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(4!==n)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:s,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=e)return r;const h=o-e;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
1
+ import t from"../../abstract/plugin.js";import{tryMethodData as i,jsonBody as a,tryMethod as e,wantJson as n}from"../../lib/expressFormatedResponses.js";import{asyncHandler as s,validateDnValue as r,escapeDnValue as o,getRdn as h,isDnInBranch as c,rdnValue as d,normalizeDn as l,getParentDn as g,escapeLdapFilter as p,launchHooksChained as f,isChildOf as u,substringSearchFilter as w}from"../../lib/utils.js";import{BadRequestError as m,NotFoundError as z,ConflictError as y,HttpError as b}from"../../lib/errors.js";import{extractLdapCode as $}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as _,schemaUrl as v}from"../../lib/schemaFile.js";import{assertClientMaySet as O,modifiedAttributeNames as C,missingRequiredAttribute as E,checkDnValues as A,matchesPattern as L}from"../../config/schema.js";class LdapOrganizations extends t{constructor(t){if(super(t),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new m("An organization link cannot be deleted");if(e)throw new m("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema){const t=this.config.organization_schema;_(t,{config:this.config}).then(t=>{this.schema=t,this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,a)=>{await i(a,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,s(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,s(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,s(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,s(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,s(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,s(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const n=a(t,i,"ou");if(!n)return;O(this.schema,Object.keys(n),"ou"),r(n.ou,"ou");const s=n.parentDn||this.config.ldap_top_organization,h=`ou=${o(n.ou)},${s}`,c={objectClass:this.config.ldap_organization_class,ou:n.ou,...Object.fromEntries(Object.entries(n).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await e(i,this.addOrganization.bind(this),h,c,t)}async apiModify(t,i){const n=a(t,i);if(!n)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new m("dn is required");O(this.schema,C(n)),await e(i,this.modifyOrganization.bind(this),s,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new m("dn is required");await e(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,e){if(!n(t,e))return;const s=a(t,e,"targetOrgDn");if(!s)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new m("dn is required");const{targetOrgDn:o}=s;if(!o||"string"!=typeof o)throw new m("Missing or invalid targetOrgDn in request body");await i(e,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new z(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new m(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(h(i))&&c(i,s)?d(i):void 0,o=t.ou,f=(Array.isArray(o)?o[0]:o)||r;let u=e;if((this.isOu(t)||void 0!==r)&&f){const t=s?d(s):"";if(t&&e.startsWith(f+n)&&e.endsWith(n+t))return;if(e===f){const t=s?l(s):"";if(!i||!t||!c(i,s)||l(i)===t||l(g(i))===t)return;throw new m(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+f))throw new m(`Organization path must end with its own name, preceded by separator "${n}"`);u=e.slice(0,e.length-n.length-f.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${p(u)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new m(`Invalid organization path ${e}: no organization holds "${u}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${p(t)})`})).searchEntries.length>0)throw new y(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new m("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new z("Top organization not found");const[,a]=await f(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){let a;try{a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i)}catch(i){if(32===$(i))throw new z(`Organization ${t} not found`);throw i}if(1!==a.searchEntries.length)throw new z(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=g(s);let o=`(${this.config.ldap_organization_link_attribute}=${p(t)})`;return n&&(o=`(&${o}(objectClass=${p(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=$(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==$(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=r.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));const a=E(this.schema,i,this.server.loadedPlugins);if(a)throw new m(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new m(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new m(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new m(`Field ${t} must be a single value`);return await A(t,a,i,t=>this.entryExists(t)),L(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new z(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new z(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new m(`Target ${i} is not an organizational unit`);const n=`${h(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new z(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new z(`Source organization ${t} not found`);if(n===t)throw new m("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||u(i,t))throw new m("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){if(t instanceof b)throw t;throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${w(i,"ou,description")})`,n,a));const s=this.config.ldap_top_organization,r=g(s),o=p(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${w(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${r}`),e.push(...await this.linkedEntities(r,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",s=`(&(objectClass=organizationalUnit)${w(i,n)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:s},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const n=$(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(4!==n)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:s,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=e)return r;const h=o-e;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.server.loadedPlugins.static&&this.config.organization_schema?v(this.config,this.config.organization_schema):void 0;return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
@@ -1 +1 @@
1
- import e from"fs";import t from"path";import{SCHEMA_SERVICE_PROVIDER_CONFIG as s,SCHEMA_USER as o,SCHEMA_RESOURCE_TYPE as i,SCHEMA_GROUP as r,SCHEMA_SCHEMA as n}from"./types.js";class ScimDiscovery{constructor(e){this.config=e.config,this.schemaDir=e.schemaDir,this.scimPrefix=e.scimPrefix,this.loadedPlugins=e.loadedPlugins}baseUrl(e){const t=this.config.scim_base_url||"";return t?t.replace(/\/$/,""):e?.protocol&&e.get?`${e.protocol}://${String(e.get("host")||"")}`:""}location(e,t){return`${this.baseUrl(e)}${this.scimPrefix}${t}`}authSchemes(){const e=[];return this.loadedPlugins.authToken&&e.push({name:"OAuth Bearer Token",description:'Bearer token configured via --auth-token. Send "Authorization: Bearer <token>".',type:"oauthbearertoken",primary:0===e.length}),this.loadedPlugins.openidconnect&&e.push({name:"OpenID Connect",description:"OIDC-issued access token.",type:"oauth2",specUri:"https://openid.net/specs/openid-connect-core-1_0.html",primary:0===e.length}),this.loadedPlugins.authHmac&&e.push({name:"HMAC",description:"Signed request via HMAC-SHA256.",type:"httpbasic",primary:0===e.length}),0===e.length&&e.push({name:"No authentication",description:"No authentication plugin loaded. NOT RECOMMENDED for production.",type:"httpbasic",primary:!0}),e}serviceProviderConfig(e){return{schemas:[s],patch:{supported:!0},bulk:{supported:!0,maxOperations:this.config.scim_bulk_max_operations||100,maxPayloadSize:this.config.scim_bulk_max_payload_size||1048576},filter:{supported:!0,maxResults:this.config.scim_max_results||200},changePassword:{supported:!1},sort:{supported:!1},etag:{supported:Boolean(this.config.scim_etag)},authenticationSchemes:this.authSchemes(),meta:{resourceType:"ServiceProviderConfig",location:this.location(e,"/ServiceProviderConfig")}}}resourceTypes(e){return[{schemas:[i],id:"User",name:"User",endpoint:"/Users",description:"User Account",schema:o,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/User")}},{schemas:[i],id:"Group",name:"Group",endpoint:"/Groups",description:"Group",schema:r,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/Group")}}]}resourceType(e,t){return this.resourceTypes(t).find(t=>t.id===e)}schemas(e){const t=[this.loadSchema("User.json",o),this.loadSchema("Group.json",r)].filter(e=>null!=e);for(const s of t)s.meta={resourceType:"Schema",location:this.location(e,`/Schemas/${s.id}`)};return t}schema(e,t){return this.schemas(t).find(t=>t.id===e)}loadSchema(s,o){const i=t.join(this.schemaDir,s);try{const t=e.readFileSync(i,"utf8"),s=JSON.parse(t);return{schemas:[n],id:o,name:s.name||o.split(":").pop()||o,description:s.description,attributes:s.attributes||[]}}catch{return}}}export{ScimDiscovery};
1
+ import e from"path";import{loadSchemaFile as t}from"../../lib/schemaFile.js";import{SCHEMA_SERVICE_PROVIDER_CONFIG as s,SCHEMA_USER as i,SCHEMA_RESOURCE_TYPE as o,SCHEMA_GROUP as r,SCHEMA_SCHEMA as c}from"./types.js";class ScimDiscovery{constructor(e){this.config=e.config,this.schemaDir=e.schemaDir,this.scimPrefix=e.scimPrefix,this.loadedPlugins=e.loadedPlugins}baseUrl(e){const t=this.config.scim_base_url||"";return t?t.replace(/\/$/,""):e?.protocol&&e.get?`${e.protocol}://${String(e.get("host")||"")}`:""}location(e,t){return`${this.baseUrl(e)}${this.scimPrefix}${t}`}authSchemes(){const e=[];return this.loadedPlugins.authToken&&e.push({name:"OAuth Bearer Token",description:'Bearer token configured via --auth-token. Send "Authorization: Bearer <token>".',type:"oauthbearertoken",primary:0===e.length}),this.loadedPlugins.openidconnect&&e.push({name:"OpenID Connect",description:"OIDC-issued access token.",type:"oauth2",specUri:"https://openid.net/specs/openid-connect-core-1_0.html",primary:0===e.length}),this.loadedPlugins.authHmac&&e.push({name:"HMAC",description:"Signed request via HMAC-SHA256.",type:"httpbasic",primary:0===e.length}),0===e.length&&e.push({name:"No authentication",description:"No authentication plugin loaded. NOT RECOMMENDED for production.",type:"httpbasic",primary:!0}),e}serviceProviderConfig(e){return{schemas:[s],patch:{supported:!0},bulk:{supported:!0,maxOperations:this.config.scim_bulk_max_operations||100,maxPayloadSize:this.config.scim_bulk_max_payload_size||1048576},filter:{supported:!0,maxResults:this.config.scim_max_results||200},changePassword:{supported:!1},sort:{supported:!1},etag:{supported:Boolean(this.config.scim_etag)},authenticationSchemes:this.authSchemes(),meta:{resourceType:"ServiceProviderConfig",location:this.location(e,"/ServiceProviderConfig")}}}resourceTypes(e){return[{schemas:[o],id:"User",name:"User",endpoint:"/Users",description:"User Account",schema:i,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/User")}},{schemas:[o],id:"Group",name:"Group",endpoint:"/Groups",description:"Group",schema:r,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/Group")}}]}resourceType(e,t){return this.resourceTypes(t).find(t=>t.id===e)}schemas(e){const t=[this.loadSchema("User.json",i),this.loadSchema("Group.json",r)].filter(e=>null!=e);for(const s of t)s.meta={resourceType:"Schema",location:this.location(e,`/Schemas/${s.id}`)};return t}schema(e,t){return this.schemas(t).find(t=>t.id===e)}loadSchema(s,i){const o=e.join(this.schemaDir,s);try{const e=t(o,{config:this.config});return{schemas:[c],id:i,name:e.name||i.split(":").pop()||i,description:e.description,attributes:e.attributes||[]}}catch{return}}}export{ScimDiscovery};
@@ -1 +1 @@
1
- import{HttpError as r,AUTHZ_REFUSED as t}from"../../lib/errors.js";import{extractLdapCode as e}from"../../lib/ldapCodes.js";import{SCHEMA_ERROR as i}from"./types.js";const o="application/scim+json";class ScimError extends r{constructor(r,t,e){super(t,r),this.name="ScimError",this.scimType=e}}const scimInvalidFilter=(r="Invalid filter")=>new ScimError(400,r,"invalidFilter"),scimInvalidPath=(r="Invalid path")=>new ScimError(400,r,"invalidPath"),scimInvalidValue=(r="Invalid value")=>new ScimError(400,r,"invalidValue"),scimInvalidSyntax=(r="Invalid syntax")=>new ScimError(400,r,"invalidSyntax"),scimNoTarget=(r="No target")=>new ScimError(400,r,"noTarget"),scimMutability=(r="Immutable attribute")=>new ScimError(400,r,"mutability"),scimUniqueness=(r="Uniqueness violation")=>new ScimError(409,r,"uniqueness"),scimTooMany=(r="Too many results")=>new ScimError(400,r,"tooMany"),scimNotFound=(r="Resource not found")=>new ScimError(404,r);function writeScimError(r,t,e,n){const s={schemas:[i],status:String(t),...n?{scimType:n}:{},detail:e};r.status(t).type(o).json(s)}function scimErrorFromException(o,n=500){const sanitize=r=>/\[authz-forbidden\]/.test(r)?t:r,body=(r,t,e)=>({schemas:[i],status:String(r),...e?{scimType:e}:{},detail:t});if(o instanceof ScimError)return body(o.statusCode,sanitize(o.message),o.scimType);if(o instanceof r)return body(o.statusCode,sanitize(o.message));const s=o instanceof Error?o.message:String(o);if(/\[authz-forbidden\]/.test(s))return body(403,t);const c=e(o);return 32===c?body(404,"Resource not found"):68===c?body(409,sanitize(s),"uniqueness"):17===c||65===c?body(400,`The directory rejected this write against its schema: ${sanitize(s)}`,"invalidValue"):body(n,sanitize(s)||"Internal error")}function writeScimErrorFromException(r,t,e=500){const i=scimErrorFromException(t,e);r.status(Number(i.status)).type(o).json(i)}const scimAsyncHandler=r=>(t,e,i)=>{Promise.resolve().then(()=>r(t,e,i)).catch(r=>{e.headersSent?i(r):writeScimErrorFromException(e,r)})};export{o as SCIM_CONTENT_TYPE,ScimError,e as extractLdapCode,scimAsyncHandler,scimErrorFromException,scimInvalidFilter,scimInvalidPath,scimInvalidSyntax,scimInvalidValue,scimMutability,scimNoTarget,scimNotFound,scimTooMany,scimUniqueness,writeScimError,writeScimErrorFromException};
1
+ import{HttpError as r,AUTHZ_REFUSED as t}from"../../lib/errors.js";import{isSchemaRefusal as e,extractLdapCode as i}from"../../lib/ldapCodes.js";import{SCHEMA_ERROR as o}from"./types.js";const n="application/scim+json";class ScimError extends r{constructor(r,t,e){super(t,r),this.name="ScimError",this.scimType=e}}const scimInvalidFilter=(r="Invalid filter")=>new ScimError(400,r,"invalidFilter"),scimInvalidPath=(r="Invalid path")=>new ScimError(400,r,"invalidPath"),scimInvalidValue=(r="Invalid value")=>new ScimError(400,r,"invalidValue"),scimInvalidSyntax=(r="Invalid syntax")=>new ScimError(400,r,"invalidSyntax"),scimNoTarget=(r="No target")=>new ScimError(400,r,"noTarget"),scimMutability=(r="Immutable attribute")=>new ScimError(400,r,"mutability"),scimUniqueness=(r="Uniqueness violation")=>new ScimError(409,r,"uniqueness"),scimTooMany=(r="Too many results")=>new ScimError(400,r,"tooMany"),scimNotFound=(r="Resource not found")=>new ScimError(404,r);function writeScimError(r,t,e,i){const s={schemas:[o],status:String(t),...i?{scimType:i}:{},detail:e};r.status(t).type(n).json(s)}function scimErrorFromException(n,s=500){const sanitize=r=>/\[authz-forbidden\]/.test(r)?t:r,body=(r,t,e)=>({schemas:[o],status:String(r),...e?{scimType:e}:{},detail:t});if(n instanceof ScimError)return body(n.statusCode,sanitize(n.message),n.scimType);if(n instanceof r){const r=400===n.statusCode&&e(i(n))?"invalidValue":void 0;return body(n.statusCode,sanitize(n.message),r)}const c=n instanceof Error?n.message:String(n);if(/\[authz-forbidden\]/.test(c))return body(403,t);const a=i(n);return 32===a?body(404,"Resource not found"):68===a?body(409,sanitize(c),"uniqueness"):e(a)?body(400,`The directory rejected this write against its schema: ${sanitize(c)}`,"invalidValue"):body(s,sanitize(c)||"Internal error")}function writeScimErrorFromException(r,t,e=500){const i=scimErrorFromException(t,e);r.status(Number(i.status)).type(n).json(i)}const scimAsyncHandler=r=>(t,e,i)=>{Promise.resolve().then(()=>r(t,e,i)).catch(r=>{e.headersSent?i(r):writeScimErrorFromException(e,r)})};export{n as SCIM_CONTENT_TYPE,ScimError,i as extractLdapCode,scimAsyncHandler,scimErrorFromException,scimInvalidFilter,scimInvalidPath,scimInvalidSyntax,scimInvalidValue,scimMutability,scimNoTarget,scimNotFound,scimTooMany,scimUniqueness,writeScimError,writeScimErrorFromException};
@@ -1 +1 @@
1
- import s from"fs";import{resolve as t,join as e}from"path";import a from"express";import i from"../abstract/plugin.js";import{notFound as n}from"../lib/expressFormatedResponses.js";import{transformSchemas as r}from"../lib/utils.js";class Static extends i{constructor(){super(...arguments),this.name="static",this.roles=["api","configurable"]}api(i){const c=this.config.static_path;if(!c)throw new Error("--static-path is not defined");try{if(!s.statSync(c).isDirectory())throw new Error(`${c} isn't a directory`);s.accessSync(c,s.constants.R_OK)}catch(s){throw new Error(`Bad directory ${c}: ${s}`)}i.get(`/${this.config.static_name}/schemas/:name`,(a,i)=>{if(!/^[\w-]+\.json$/.test(a.params.name))return i.status(400).send("Invalid schema name");const o=t(e(c,"schemas",a.params.name)),m=t(e(c,"schemas"));if(!o.startsWith(m+"/"))return i.status(403).send("Access denied");s.readFile(o,(s,t)=>{if(s)return n(i,"Schema not found");const e=r(t,this.config);i.type("json").send(e)})}),i.get(`/${this.config.static_name}/schemas/:dir/:name`,(a,i)=>{if(!/^[\w-]+$/.test(a.params.dir)||!/^[\w-]+\.json$/.test(a.params.name))return i.status(400).send("Invalid schema name");const o=t(e(c,"schemas",a.params.dir,a.params.name)),m=t(e(c,"schemas"));if(!o.startsWith(m+"/"))return i.status(403).send("Access denied");s.readFile(o,(s,t)=>{if(s)return n(i,"Schema not found");const e=r(t,this.config);i.type("json").send(e)})}),i.use(`/${this.config.static_name}`,a.static(c))}getConfigApiData(){const s=`/${this.config.static_name||"static"}`;return{enabled:!0,staticPath:s,endpoints:{schema:`${s}/schemas/:name`,schemaInSubdir:`${s}/schemas/:dir/:name`,files:`${s}/*`}}}}export{Static as default};
1
+ import t from"fs";import{resolve as s,join as e}from"path";import n from"express";import i from"../abstract/plugin.js";import{notFound as a,serverError as r}from"../lib/expressFormatedResponses.js";import{configuredSchemaFiles as c,schemaUrl as o,loadSchemaFileAsync as h}from"../lib/schemaFile.js";class Static extends i{constructor(){super(...arguments),this.name="static",this.roles=["api","configurable"]}api(s){const e=this.config.static_path;if(!e)throw new Error("--static-path is not defined");try{if(!t.statSync(e).isDirectory())throw new Error(`${e} isn't a directory`);t.accessSync(e,t.constants.R_OK)}catch(t){throw new Error(`Bad directory ${e}: ${t}`)}const i=this.schemaServer(e);s.get(`/${this.config.static_name}/schemas/:name`,(t,s,e)=>i([t.params.name],t,s,e)),s.get(`/${this.config.static_name}/schemas/:dir/:name`,(t,s,e)=>i([t.params.dir,t.params.name],t,s,e)),s.get(`/${this.config.static_name}/schemas/*path`,(t,s,e)=>i(t.params.path,t,s,e)),s.use(`/${this.config.static_name}`,n.static(e))}schemaServer(t){const n=this.configuredSchemas(t),i=s(e(t,"schemas"));return(t,a,r,c)=>{const o=n.get((t=>{try{return decodeURIComponent(t)}catch{return t}})(a.path));if(o)return this.sendSchema(o,r);if(!t[t.length-1].endsWith(".json"))return c();if(!t.every(t=>/^[\w-]+(\.[\w-]+)*$/.test(t)))return void r.status(400).send("Invalid schema name");const h=s(e(i,...t));h.startsWith(i+"/")?this.sendSchema(h,r):r.status(403).send("Access denied")}}configuredSchemas(t){const n=new Map;for(const i of c(this.config)){const a=o(this.config,i);if(!a)continue;const r=e(t,a.substring(a.indexOf("/schemas/")));if(s(r)===s(i))continue;const c=n.get(a);c&&s(c)!==s(i)?this.logger.warn(`${i} is not served: ${c} already has its URL ${a}`):n.set(a,i)}return n}sendSchema(t,s){h(t,{config:this.config}).then(t=>s.type("json").send(JSON.stringify(t)),t=>{if(t.code)return a(s,"Schema not found");r(s,t)})}getConfigApiData(){const t=`/${this.config.static_name||"static"}`;return{enabled:!0,staticPath:t,endpoints:{schema:`${t}/schemas/:name`,schemaInSubdir:`${t}/schemas/:dir/:name`,files:`${t}/*`}}}}export{Static as default};
@@ -1 +1 @@
1
- import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t})}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t})))}const l=this.getDisplayNameFromAttributes(i);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a)if(s){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:a,newmail:s});try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const i=this.getAliases(e.searchEntries[0][this.aliasAttr]);i.length>0&&await Promise.all([...i.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${e}`,"DELETE",t,null,{oldmail:a,alias:e})),...i.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e}))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,i,a)=>{const s=i.map(t=>this.normalizeAlias(t)),r=a.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...l.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"}))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a}),onLdapForwardChange:async(t,e,i,a)=>{const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=i.filter(t=>!a.includes(t)),n=a.filter(t=>!i.includes(t));await Promise.all([...r.map(i=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...n.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"}))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute||"mailQuotaSize",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!r(e,i))return;const s=e.params.user;try{const e=a(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${s} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${s} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${s}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(t,e,i){const a=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,i));this.identityQueue.set(e,a);try{await a}finally{this.identityQueue.get(e)===a&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};try{const r=`${this.webadminUrl}/users/${i}/identities`,n=await this.requestLimit(()=>t(`${r}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(n.ok){const t=await n.json();l=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==n.status)return void this.logger.error({...s,step:"get_default_identity",http_status:n.status,http_status_text:n.statusText});const o=await this.generateSignature(e),m=l?.id?{id:l.id,email:l.email,name:a}:{email:i,name:a,sortOrder:0};o&&(m.htmlSignature=o);const d=l?.id?"update_identity":"create_identity",h=await this.requestLimit(()=>t(l?.id?`${r}/${l.id}`:r,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));h.ok?this.logger.info({...s,step:d,result:"success",http_status:h.status}):this.logger.error({...s,step:d,http_status:h.status,http_status_text:h.statusText})}catch(t){this.logger.error({...s,error:`${t}`})}}async deleteUserData(e,i){const a={plugin:this.name,event:"deleteUserData",mail:e,fromStep:i};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),i&&s.searchParams.set("fromStep",i);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...a,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...a,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...a,error:`${t}`}),null}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r),l=[];l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i}));for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>!s(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(t=>!l.includes(t)),d=l.filter(t=>!o.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,c]=await Promise.all([Promise.all(h),Promise.all(u)]),p=[];for(const{dn:e,email:i}of g)i&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"}));for(const{dn:e,email:i}of c)i&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"DELETE",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"remove"}));await Promise.all(p)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
1
+ import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";const n=[400,404];class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const l=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${l}/size`,"PUT",e,t.toString(),{mail:l,quota:t},n)}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${l}/sources/${t}`,"PUT",e,null,{mail:l,alias:t},n)))}const o=this.getDisplayNameFromAttributes(i);o&&await this.updateJamesIdentity(e,l,o)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a)if(s){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:a,newmail:s},n);try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const i=this.getAliases(e.searchEntries[0][this.aliasAttr]);i.length>0&&await Promise.all([...i.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${e}`,"DELETE",t,null,{oldmail:a,alias:e})),...i.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},n))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,i,a)=>{const s=i.map(t=>this.normalizeAlias(t)),r=a.map(t=>this.normalizeAlias(t)),l=s.filter(t=>!r.includes(t)),o=r.filter(t=>!s.includes(t));await Promise.all([...l.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...o.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"},n))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a},n),onLdapForwardChange:async(t,e,i,a)=>{const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=i.filter(t=>!a.includes(t)),l=a.filter(t=>!i.includes(t));await Promise.all([...r.map(i=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...l.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"},n))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!r(e,i))return;const s=e.params.user;try{const e=a(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${s} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${s} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${s}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(t,e,i){const a=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,i));this.identityQueue.set(e,a);try{await a}finally{this.identityQueue.get(e)===a&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};for(let r=1;;r++){try{const l=`${this.webadminUrl}/users/${i}/identities`,o=await this.requestLimit(()=>t(`${l}?default=true`,{method:"GET",headers:this.createHeaders()}));let m;if(o.ok){const t=await o.json();m=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==o.status){const t="get_default_identity";if(this.shouldRetry(o.status,n,r)){await this.warnAndWait(s,t,r,o);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:o.status,http_status_text:o.statusText})}const d=await this.generateSignature(e),h=m?.id?{id:m.id,email:m.email,name:a}:{email:i,name:a,sortOrder:0};d&&(h.htmlSignature=d);const u=m?.id?"update_identity":"create_identity",g=await this.requestLimit(()=>t(m?.id?`${l}/${m.id}`:l,{method:m?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));if(g.ok)this.logger.info({...s,step:u,result:"success",http_status:g.status});else{if(this.shouldRetry(g.status,n,r)){await this.warnAndWait(s,u,r,g);continue}this.logger.error({...s,step:u,...r>1?{attempts:r}:{},http_status:g.status,http_status_text:g.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async warnAndWait(t,e,i,a){this.logger.warn({...t,step:e,result:"retry",attempt:i,http_status:a.status,http_status_text:a.statusText}),await this.waitBeforeRetry(i)}async deleteUserData(e,i){const a={plugin:this.name,event:"deleteUserData",mail:e,fromStep:i};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),i&&s.searchParams.set("fromStep",i);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...a,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...a,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...a,error:`${t}`}),null}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r);await this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i});const l=[];for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>!s(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,l]=e[s]||[],o=this._normalizeToArray(r),m=this._normalizeToArray(l),d=m.filter(t=>!o.includes(t)),h=o.filter(t=>!m.includes(t)),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),g=h.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[c,p]=await Promise.all([Promise.all(u),Promise.all(g)]),b=[];for(const{dn:e,email:i}of c)i&&b.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"},n));for(const{dn:e,email:i}of p)i&&b.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"DELETE",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"remove"}));await Promise.all(b)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @module abstract/ldapFlat
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Abstract class to manage LDAP entries in a flat branch
6
+ * - add/delete entries
7
+ * - modify entries
8
+ * - validate with schema
9
+ */
1
10
  import type { Express, Request, Response } from 'express';
2
11
  import type { DM } from '../bin';
3
12
  import type ldapActions from '../lib/ldapActions';
@@ -12,6 +12,8 @@ export declare abstract class TwakePlugin extends DmPlugin {
12
12
  protected webadminUrl: string;
13
13
  protected webadminToken: string;
14
14
  protected requestLimit: ReturnType<typeof pLimit>;
15
+ protected retryAttempts: number;
16
+ protected retryDelay: number;
15
17
  protected mailAttr: string;
16
18
  protected displayNameAttr: string;
17
19
  /**
@@ -39,8 +41,23 @@ export declare abstract class TwakePlugin extends DmPlugin {
39
41
  * @param dn LDAP DN for logging
40
42
  * @param body Request body (string or null)
41
43
  * @param fields Additional fields for logging
44
+ * @param retryOn HTTP statuses worth another attempt, up to
45
+ * `retryAttempts` in all; none by default
42
46
  */
43
- protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object): Promise<void>;
47
+ protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object, retryOn?: readonly number[]): Promise<void>;
48
+ /**
49
+ * Whether a failed call is worth another attempt
50
+ * @param statusCode HTTP status code of the failure
51
+ * @param retryOn HTTP statuses the call retries on
52
+ * @param attempt Number of the attempt that failed, from 1
53
+ */
54
+ protected shouldRetry(statusCode: number, retryOn: readonly number[], attempt: number): boolean;
55
+ /**
56
+ * Wait before the attempt following `attempt`: `retryDelay` times the
57
+ * number of attempts made, so that the waits grow linearly
58
+ * @param attempt Number of the attempt that failed, from 1
59
+ */
60
+ protected waitBeforeRetry(attempt: number): Promise<void>;
44
61
  /**
45
62
  * Override this method to customize error handling for specific status codes
46
63
  * @param statusCode HTTP status code
@@ -61,6 +61,15 @@ export declare class DM {
61
61
  */
62
62
  private _composed;
63
63
  private _errorMiddleware?;
64
+ /**
65
+ * The loads of `--plugin` entries without overrides, keyed by resolved
66
+ * module and name. Regular plugins load in parallel and load their own
67
+ * dependencies, so one module is often asked for twice: in the
68
+ * configuration and by a plugin depending on it, or listed twice. The
69
+ * second request waits for the first rather than building an instance
70
+ * whose name is taken. A Map, so that `withConfig` views share it.
71
+ */
72
+ private readonly pluginLoads;
64
73
  constructor();
65
74
  /**
66
75
  * The Express router stack, whatever the Express version calls it: 5
@@ -201,6 +210,7 @@ export declare class DM {
201
210
  */
202
211
  private resolveModule;
203
212
  loadPlugin(pluginName: string): Promise<boolean>;
213
+ private importPlugin;
204
214
  /**
205
215
  * Refuse a plugin whose arrival makes the authorization composition
206
216
  * ambiguous, before any of it is registered.
@@ -166,6 +166,8 @@ export interface Config {
166
166
  ldap_concurrency?: number;
167
167
  james_concurrency?: number;
168
168
  james_init_delay?: number;
169
+ james_retry_attempts?: number;
170
+ james_retry_delay?: number;
169
171
  james_mailing_list_branch?: string[];
170
172
  james_mailbox_type_attribute?: string;
171
173
  twake_drive_webadmin_url?: string;
@@ -127,7 +127,9 @@ export interface Hooks {
127
127
  * `onLdapEntryChange` gives the entry as the directory held it before and
128
128
  * after an add, a modify, a rename or a delete; the other hooks are derived
129
129
  * from it. `before` is null on an add, `after` on a delete, and on a rename
130
- * their `dn` differ. A write that changed nothing fires none of them.
130
+ * their `dn` differ. A write that changed nothing fires none of them. A
131
+ * delete fires `onLdapEntryChange`, `onLdapChange` and `onLdapMailChange`
132
+ * only, and the hooks given a `mail` fire only while the entry has one.
131
133
  */
132
134
  onLdapEntryChange?: (dn: string, before: Entry | null, after: Entry | null, context: ChangeContext) => MaybePromise<void>;
133
135
  onLdapChange?: (dn: string, changes: ChangesToNotify) => MaybePromise<void>;
@@ -26,3 +26,10 @@
26
26
  * @returns the LDAP result code, or undefined when nothing says
27
27
  */
28
28
  export declare function extractLdapCode(err: unknown): number | undefined;
29
+ /**
30
+ * Whether an LDAP result code means the directory refused the request for
31
+ * its content, see {@link SCHEMA_REFUSALS}.
32
+ *
33
+ * @param code LDAP result code, as {@link extractLdapCode} reads it
34
+ */
35
+ export declare const isSchemaRefusal: (code: number | undefined) => boolean;
@@ -0,0 +1,64 @@
1
+ import type { Config } from '../bin';
2
+ /** Directory of the schemas shipped with LDAP-Rest, whatever --schemas-path says */
3
+ export declare const shippedSchemasPath: string;
4
+ /** Prefix of an `extends` naming a shipped schema, as `ldap-rest:twake/users.json` */
5
+ export declare const SHIPPED_SCHEMA_PREFIX = "ldap-rest:";
6
+ export interface SchemaFileOptions {
7
+ /**
8
+ * Configuration whose values replace the `__FOO_BAR__` placeholders, in
9
+ * every file of the chain
10
+ */
11
+ config?: Config;
12
+ }
13
+ /**
14
+ * Apply `patch` on `base`: plain objects are merged key by key, `null`
15
+ * deletes a key, anything else (arrays included) replaces the base value
16
+ *
17
+ * @param base value being extended, left untouched
18
+ * @param patch value of the extending file
19
+ * @returns the merged value
20
+ */
21
+ export declare function mergeSchema(base: unknown, patch: unknown): unknown;
22
+ /**
23
+ * Path of the file an `extends` names
24
+ *
25
+ * @param file file holding the `extends`
26
+ * @param base its value
27
+ * @returns absolute path, or relative to the current directory when `file` is
28
+ */
29
+ export declare function resolveSchemaBase(file: string, base: string): string;
30
+ /**
31
+ * Read a schema file and the files it extends, synchronously
32
+ *
33
+ * @param file path of the schema
34
+ * @param options see {@link SchemaFileOptions}
35
+ * @returns the parsed schema, merged with its bases
36
+ */
37
+ export declare function loadSchemaFile<T = unknown>(file: string, options?: SchemaFileOptions): T;
38
+ /**
39
+ * Read a schema file and the files it extends
40
+ *
41
+ * @param file path of the schema
42
+ * @param options see {@link SchemaFileOptions}
43
+ * @returns the parsed schema, merged with its bases
44
+ */
45
+ export declare function loadSchemaFileAsync<T = unknown>(file: string, options?: SchemaFileOptions): Promise<T>;
46
+ /**
47
+ * URL the static plugin serves a schema file at: its path from the first
48
+ * `schemas` directory it is in, under --static-name
49
+ *
50
+ * @param config server configuration
51
+ * @param file path of the schema
52
+ * @returns the URL, or undefined when the path has no `schemas` directory
53
+ */
54
+ export declare function schemaUrl(config: Config, file: string): string | undefined;
55
+ /**
56
+ * The schema files the configuration names, those the plugins advertise a
57
+ * {@link schemaUrl} for. A value left at its default is not one: the
58
+ * default `--group-schema` lies in the package, and a `--static-path` of
59
+ * the deployment's own keeps serving its file at that URL.
60
+ *
61
+ * @param config server configuration
62
+ * @returns their paths, as configured
63
+ */
64
+ export declare function configuredSchemaFiles(config: Config): string[];
@@ -1,3 +1,10 @@
1
+ /**
2
+ * @module plugins/ldap/flatGeneric
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Generic plugin to manage LDAP flat entities from schema files
6
+ * Automatically creates sub-plugins based on schema metadata
7
+ */
1
8
  import type { Express } from 'express';
2
9
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
10
  import LdapFlat from '../../abstract/ldapFlat';
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @module plugins/ldap/groups
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Plugin to manage groups into LDAP server
6
+ * - add/delete groups
7
+ * - add/delete members of groups
8
+ * - detect user deletion to remove them from groups (hook)
9
+ */
1
10
  import type { Express, Request, Response } from 'express';
2
11
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
12
  import type { DM } from '../../bin';
@@ -13,7 +13,8 @@ import DmPlugin, { type Role } from '../abstract/plugin';
13
13
  * The server replaces `__FOO_BAR__` placeholders on the fly with the
14
14
  * value of the corresponding `--foo-bar` CLI option so that a single
15
15
  * schema file can be shared between server-side validation and
16
- * client-side rendering.
16
+ * client-side rendering. A schema that `extends` another is served
17
+ * merged with it.
17
18
  * additionalProperties: true
18
19
  * example:
19
20
  * entity:
@@ -37,6 +38,19 @@ export default class Static extends DmPlugin {
37
38
  name: string;
38
39
  roles: Role[];
39
40
  api(app: Express): void;
41
+ /**
42
+ * The handler of the schema routes
43
+ *
44
+ * @param rep static directory
45
+ * @returns a handler taking the path segments after `schemas/`, decoded
46
+ */
47
+ private schemaServer;
48
+ /**
49
+ * Schema files of the configuration that their URL would not reach under
50
+ * the static directory, by URL
51
+ */
52
+ private configuredSchemas;
53
+ private sendSchema;
40
54
  /**
41
55
  * Provide configuration for config API
42
56
  */
@@ -104,6 +104,7 @@ export default class James extends TwakePlugin {
104
104
  updateJamesIdentity(dn: string, mail: string, displayName: string): Promise<void>;
105
105
  private identityQueue;
106
106
  private doUpdateJamesIdentity;
107
+ private warnAndWait;
107
108
  /**
108
109
  * Delete user data via James WebAdmin API
109
110
  * Calls POST /users/{mail}?action=deleteData
@@ -1158,6 +1158,19 @@ All errors return a JSON object with an error message:
1158
1158
  }
1159
1159
  ```
1160
1160
 
1161
+ **Refused by the directory schema** — an attribute the directory does not
1162
+ define, a value of the wrong syntax, a missing mandatory attribute, a broken
1163
+ constraint or naming rule. Nothing of the write is applied. These are the LDAP
1164
+ result codes 17, 18, 19, 21, 34, 64, 67 and 69 on any write, 65 on a creation,
1165
+ a rename or a move, and 20 (a value given twice) on a creation; a rename or a
1166
+ move names both DNs:
1167
+
1168
+ ```json
1169
+ {
1170
+ "error": "The directory refused this write to uid=john.doe,ou=users,dc=example,dc=com: UndefinedTypeError: mailQuota: attribute type undefined Code: 0x11"
1171
+ }
1172
+ ```
1173
+
1161
1174
  #### 401 Unauthorized
1162
1175
 
1163
1176
  **Missing Authentication:**
@@ -1212,6 +1225,19 @@ All errors return a JSON object with an error message:
1212
1225
  }
1213
1226
  ```
1214
1227
 
1228
+ A rename or a move onto a DN already taken answers the same way, naming that
1229
+ DN (LDAP result code 68).
1230
+
1231
+ **Entry that cannot hold a change** — a modify adding an attribute the
1232
+ classes of the entry do not allow, or a value it already holds (LDAP result
1233
+ codes 65 and 20 on a modify):
1234
+
1235
+ ```json
1236
+ {
1237
+ "error": "Entry uid=john.doe,ou=users,dc=example,dc=com cannot hold this change: TypeOrValueExistsError: modify/add: cn: value #0 already exists Code: 0x14"
1238
+ }
1239
+ ```
1240
+
1215
1241
  #### 422 Unprocessable Entity
1216
1242
 
1217
1243
  **List longer than the directory's size limit** (flat entities' lists, when