ldap-rest 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/plugin.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/config/schema.js +1 -0
  6. package/dist/lib/expressFormatedResponses.js +1 -1
  7. package/dist/lib/ldapActions.js +1 -1
  8. package/dist/lib/utils.js +1 -1
  9. package/dist/plugins/auth/authzScope.js +1 -0
  10. package/dist/plugins/auth/llng.js +1 -1
  11. package/dist/plugins/ldap/accountLifecycle.js +1 -0
  12. package/dist/plugins/ldap/bulkImport.js +1 -1
  13. package/dist/plugins/ldap/departmentSync.js +1 -1
  14. package/dist/plugins/ldap/enterpriseRules.js +1 -0
  15. package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
  16. package/dist/plugins/ldap/flatGeneric.js +1 -1
  17. package/dist/plugins/ldap/groups.js +1 -1
  18. package/dist/plugins/ldap/organizations.js +1 -1
  19. package/dist/plugins/scim/groups.js +1 -1
  20. package/dist/plugins/twake/calendar.js +1 -0
  21. package/dist/plugins/twake/calendarResources.js +1 -1
  22. package/dist/plugins/twake/james.js +1 -1
  23. package/dist/src/abstract/ldapFlat.d.ts +159 -1
  24. package/dist/src/abstract/plugin.d.ts +13 -0
  25. package/dist/src/config/args.d.ts +2 -0
  26. package/dist/src/config/schema.d.ts +382 -12
  27. package/dist/src/lib/expressFormatedResponses.d.ts +14 -0
  28. package/dist/src/lib/ldapActions.d.ts +33 -6
  29. package/dist/src/lib/utils.d.ts +65 -0
  30. package/dist/src/plugins/auth/authzScope.d.ts +53 -0
  31. package/dist/src/plugins/auth/llng.d.ts +30 -1
  32. package/dist/src/plugins/ldap/accountLifecycle.d.ts +56 -0
  33. package/dist/src/plugins/ldap/departmentSync.d.ts +43 -0
  34. package/dist/src/plugins/ldap/enterpriseRules.d.ts +436 -0
  35. package/dist/src/plugins/ldap/flatGeneric.d.ts +29 -0
  36. package/dist/src/plugins/ldap/groups.d.ts +22 -0
  37. package/dist/src/plugins/ldap/organizations.d.ts +25 -4
  38. package/dist/src/plugins/twake/calendar.d.ts +113 -0
  39. package/dist/src/plugins/twake/calendarResources.d.ts +9 -58
  40. package/docs/README.md +1 -0
  41. package/docs/client-development/api/openapi.md +1 -1
  42. package/docs/client-development/browser/libraries.md +1 -1
  43. package/docs/plugin-development/dependencies.md +10 -7
  44. package/docs/presentations/en.md +1 -1
  45. package/docs/presentations/fr.md +1 -1
  46. package/docs/usage/README.md +1 -0
  47. package/docs/usage/configuration.md +35 -15
  48. package/docs/usage/directory-audit.md +105 -0
  49. package/docs/usage/plugins/README.md +6 -6
  50. package/docs/usage/plugins/auth/README.md +6 -5
  51. package/docs/usage/plugins/auth/authz-scope.md +103 -0
  52. package/docs/usage/plugins/auth/llng.md +17 -0
  53. package/docs/usage/plugins/integrations/README.md +5 -5
  54. package/docs/usage/plugins/integrations/{calendar-resources.md → calendar.md} +70 -13
  55. package/docs/usage/plugins/ldap/README.md +13 -11
  56. package/docs/usage/plugins/ldap/account-lifecycle.md +129 -0
  57. package/docs/usage/plugins/ldap/bulk-import.md +8 -1
  58. package/docs/usage/plugins/ldap/enterprise-rules.md +167 -0
  59. package/docs/usage/plugins/ldap/flat-generic.md +172 -0
  60. package/docs/usage/plugins/ldap/groups.md +10 -4
  61. package/docs/usage/plugins/ldap/organizations.md +59 -33
  62. package/docs/usage/upgrading.md +322 -0
  63. package/package.json +50 -5
  64. package/static/schemas/example/README.md +36 -0
  65. package/static/schemas/example/groups.json +94 -0
  66. package/static/schemas/example/nomenclature/domains.json +49 -0
  67. package/static/schemas/example/organizations.json +106 -0
  68. package/static/schemas/example/positions.json +35 -0
  69. package/static/schemas/example/users.json +246 -0
  70. package/static/schemas/twake/groups.json +81 -15
  71. package/static/schemas/twake/nomenclature/twakeAccountStatus.json +26 -0
  72. package/static/schemas/twake/nomenclature/twakeDeliveryMode.json +22 -0
  73. package/static/schemas/twake/nomenclature/twakeDomain.json +60 -0
  74. package/static/schemas/twake/nomenclature/twakeListType.json +22 -0
  75. package/static/schemas/twake/nomenclature/twakeMailboxType.json +22 -0
  76. package/static/schemas/twake/nomenclature/twakeTitle.json +42 -0
  77. package/static/schemas/twake/organizations.json +92 -12
  78. package/static/schemas/twake/positions.json +22 -4
  79. package/static/schemas/twake/users.json +269 -32
@@ -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 r}from"../../lib/expressFormatedResponses.js";import{transformSchemas as s,asyncHandler as o,validateDnValue as h,escapeDnValue as c,escapeLdapFilter as d,launchHooksChained as g,getParentDn as l,getRdn as p,isChildOf as f}from"../../lib/utils.js";import{BadRequestError as u,NotFoundError as w,ConflictError as $}from"../../lib/errors.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),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,r={},s={};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.includes(this.linkAttr):i.delete[this.linkAttr],a=Array.isArray(i.delete)?i.delete.includes(this.pathAttr):i.delete[this.pathAttr];if(t||a){if(!await checkIsOu()&&t)throw new u("An organization link cannot be deleted");if(a)throw new u("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(r={...i.replace}),i.replace[this.pathAttr]&&(s={...i.replace})),i.add&&(i.add[this.linkAttr]&&(r={...r,...i.add}),i.add[this.pathAttr]&&(s={...s,...i.add})),Object.keys(r).length>0){await checkIsOu()||await this.checkDeptLink(r)}if(Object.keys(s).length>0){await checkIsOu()&&await this.checkDeptPath(s)}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.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,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(s(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,this.getOrganisationTop.bind(this),t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationByDn.bind(this),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,this.getOrganisationSubnodes.bind(this),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 u('query parameter "q" is required');await a(i,this.searchOrganisationSubnodes.bind(this),e,n)})),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;h(a.ou,"ou");const r=a.parentDn||this.config.ldap_top_organization,s=`ou=${c(a.ou)},${r}`,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),s,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new u("dn is required");await n(i,this.modifyOrganization.bind(this),r,a)}async apiDelete(t,i){if(!r(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new u("dn is required");await n(i,this.deleteOrganization.bind(this),a)}async apiMove(t,i){if(!r(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new u("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new u("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),s,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 w(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new u(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t){if(t[this.pathAttr]){const i=t[this.pathAttr],a=Array.isArray(i)?i[0]:i,e=this.config.ldap_organization_path_separator||" / ";let n=a;if(this.isOu(t)){const i=t.ou,r=Array.isArray(i)?i[0]:i;if(!a.startsWith(r+e))throw new u(`Organization path must start with its own name followed by separator "${e}"`);n=a.slice(r.length+e.length)}const[r,s]=n.split(e,2),o=await this.server.ldap.search({paged:!1,filter:`(ou=${d(r)})`,scope:"sub"},this.config.ldap_top_organization);if(0===o.searchEntries.length)throw new u(`Invalid organization path ${a}`);if(s){let t=!1;for(const i of o.searchEntries){const a=i[this.pathAttr],e=Array.isArray(a)?a[0]:a;if(e&&e===s){t=!0;break}}if(!t)throw new u(`Invalid organization path ${a}: no matching entry for ${r} with path ${s}`)}else{let t=!1;for(const i of o.searchEntries){const a=i.dn,n=this.config.ldap_top_organization;if(a.toLowerCase()===n.toLowerCase()||a.toLowerCase().endsWith(`,${n.toLowerCase()}`)){const a=i[this.pathAttr],n=Array.isArray(a)?a[0]:a;if(!n||n===r||!n.includes(e)){t=!0;break}}}if(!t)throw new u(`Invalid organization path ${a}: no matching top-level entry for ${r}`)}}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${t})`})).searchEntries.length>0)throw new $(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=t.objectClass.map(t=>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 u("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 w("Top organization not found");const[,a]=await g(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 w(`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;if(!n||"organizationalUnit"===n)try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:"(objectClass=organizationalUnit)"},t,i);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs for ${t}`),a.push(...e.searchEntries)}catch(i){this.server.logger.debug(`No sub-OUs found for ${t}: ${i}`)}const r=this.config.ldap_top_organization,s=l(r);let o=`(${this.config.ldap_organization_link_attribute}=${d(t)})`;n&&(o=`(&${o}(objectClass=${d(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${s}`);const h=await this.server.ldap.search({paged:!0,filter:o},s,i);let c=0,g=0;for await(const t of h){g+=t.searchEntries.length;const i=e-c;if(i>0){const e=t.searchEntries.slice(0,i);a.push(...e),c+=e.length}}return this.server.logger.debug(`Found ${g} linked entities for ${t}, returning ${c}`),g>e&&a.push({dn:`more-${t}`,cn:[`... ${g-e} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:g.toString(),_displayedCount:e.toString()}),a}async addOrganization(t,i,a){return this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i){return this.validateChanges(t,i),await this.server.ldap.modify(t,i)}validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);for(const[t,a]of Object.entries(this.schema.attributes))if(a.required&&null==i[t])throw new u(`Missing required field ${t}`);return!0}validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!this._validateOneChange(t,a))throw new u(`Invalid value for field ${t}`);return!0}_validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new Error(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new Error(`Field ${t} is required`);return!0}if(a.type)switch(a.type){case"string":if(Array.isArray(i))throw new Error(`Field ${t} must be a single value`);break;case"array":if(!Array.isArray(i))throw new Error(`Field ${t} must be an array`)}if(a.test){const e=Array.isArray(i)?i[0]:i;if(!("string"==typeof a.test?new RegExp(a.test):a.test).test(String(e)))throw new Error(`Field ${t} does not match required pattern`)}return!0}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new w(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new w(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new u(`Target ${i} is not an organizational unit`);const n=`${p(t)},${i}`;let r;try{r=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new w(`Source organization not found: ${t}`)}if(!r.searchEntries||0===r.searchEntries.length)throw new w(`Source organization ${t} not found`);if(n===t)throw new u("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new u("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){return await this.server.ldap.delete(t)}async searchOrganisationSubnodes(t,i){const a=[],e=d(i);try{const n=await this.server.ldap.search({paged:!1,scope:"one",filter:`(&(objectClass=organizationalUnit)(|(ou=*${e}*)(description=*${e}*)))`},t);this.server.logger.debug(`Found ${n.searchEntries.length} sub-OUs matching "${i}" for ${t}`),a.push(...n.searchEntries)}catch(a){this.server.logger.debug(`No sub-OUs found matching "${i}" for ${t}: ${a}`)}const n=this.config.ldap_top_organization,r=l(n),s=d(t),o=`(&(${this.config.ldap_organization_link_attribute}=${s})(|(uid=*${e}*)(cn=*${e}*)(mail=*${e}*)(sn=*${e}*)(givenName=*${e}*)))`;this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`);const h=await this.server.ldap.search({paged:!0,filter:o},r);let c=0;for await(const t of h)c+=t.searchEntries.length,a.push(...t.searchEntries);return this.server.logger.debug(`Found ${c} linked entities matching "${i}" for ${t}`),a}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`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{LdapOrganizations as default};
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 g,rdnValue as l,normalizeDn as p,getParentDn as f,escapeLdapFilter as u,launchHooksChained as w,isChildOf as m}from"../../lib/utils.js";import{BadRequestError as z,NotFoundError as $,ConflictError as b}from"../../lib/errors.js";import{assertClientMaySet as v,modifiedAttributeNames as y,missingRequiredAttribute as _,checkDnValues as O,matchesPattern as C}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 z("An organization link cannot be deleted");if(e)throw new z("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.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,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 z('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n)))})),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 z("dn is required");v(this.schema,y(a)),await n(i,this.modifyOrganization.bind(this),s,a)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new z("dn is required");await n(i,this.deleteOrganization.bind(this),a)}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 z("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new z("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 z(`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))&&g(i,s)?l(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?l(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?p(s):"";if(!i||!t||!g(i,s)||p(i)===t||p(f(i))===t)return;throw new z(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new z(`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 z(`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}=${t})`})).searchEntries.length>0)throw new b(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=t.objectClass.map(t=>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 z("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;if(!n||"organizationalUnit"===n)try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:"(objectClass=organizationalUnit)"},t,i);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs for ${t}`),a.push(...e.searchEntries)}catch(i){this.server.logger.debug(`No sub-OUs found for ${t}: ${i}`)}const s=this.config.ldap_top_organization,r=f(s);let o=`(${this.config.ldap_organization_link_attribute}=${u(t)})`;n&&(o=`(&${o}(objectClass=${u(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`);const h=await this.server.ldap.search({paged:!0,filter:o},r,i);let c=0,d=0;for await(const t of h){d+=t.searchEntries.length;const i=e-c;if(i>0){const e=t.searchEntries.slice(0,i);a.push(...e),c+=e.length}}return this.server.logger.debug(`Found ${d} linked entities for ${t}, returning ${c}`),d>e&&a.push({dn:`more-${t}`,cn:[`... ${d-e} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:d.toString(),_displayedCount:e.toString()}),a}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i)}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 z(this.invalidValueMessage(t));const a=_(this.schema,i,this.server.loadedPlugins);if(a)throw new z(`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 z(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new z(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 z(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new z(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new z(`Field ${t} must be a single value`);return await O(t,a,i,t=>this.entryExists(t)),C(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 z(`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 z("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||m(i,t))throw new z("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){return await this.server.ldap.delete(t)}async searchOrganisationSubnodes(t,i){const a=[],e=u(i);try{const n=await this.server.ldap.search({paged:!1,scope:"one",filter:`(&(objectClass=organizationalUnit)(|(ou=*${e}*)(description=*${e}*)))`},t);this.server.logger.debug(`Found ${n.searchEntries.length} sub-OUs matching "${i}" for ${t}`),a.push(...n.searchEntries)}catch(a){this.server.logger.debug(`No sub-OUs found matching "${i}" for ${t}: ${a}`)}const n=this.config.ldap_top_organization,s=f(n),r=u(t),o=`(&(${this.config.ldap_organization_link_attribute}=${r})(|(uid=*${e}*)(cn=*${e}*)(mail=*${e}*)(sn=*${e}*)(givenName=*${e}*)))`;this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${s}`);const h=await this.server.ldap.search({paged:!0,filter:o},s);let c=0;for await(const t of h)c+=t.searchEntries.length,a.push(...t.searchEntries);return this.server.logger.debug(`Found ${c} linked entities matching "${i}" for ${t}`),a}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`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{LdapOrganizations as default};
@@ -1 +1 @@
1
- import{escapeDnValue as t,launchHooksChained as e,validateDnValue as s,launchHooks as r,isChildOf as i,escapeLdapFilter as o}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as a}from"./types.js";import{withExternalId as n,mergeMapping as c,loadMappingFile as h,DEFAULT_GROUP_MAPPING as u,requiredLdapAttributes as p,ldapToScimGroup as m,scimGroupToLdap as l}from"./mapping.js";import{scimFilterToLdap as d}from"./filter.js";import{pagedSearch as f}from"./list.js";import{patchToModifyRequest as g}from"./patch.js";import{extractLdapCode as b,scimNotFound as w,ScimError as y,scimInvalidValue as R,scimUniqueness as _}from"./errors.js";class ScimGroups{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.users=t.users,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_group_rdn_attribute||"cn",this.objectClass=this.config.scim_group_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.maxScanned=this.config.scim_max_scanned||1e4,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const e=this.config.scim_group_mapping||"";this.mapping=n(c(u,e?h(e):void 0),this.config.scim_group_external_id_attribute||"")}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"Group",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix,lockAttribute:"",lockValue:""}}dnForId(e,s){const r=this.baseResolver.groupBase(s);return`${this.rdnAttribute}=${t(e)},${r}`}buildMemberResolver(t){const e=this.baseResolver.userBase(t),s=this.baseResolver.groupBase(t),r=this.scimPrefix,i=this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),o=this.config.scim_user_rdn_attribute||"uid",a=this.rdnAttribute,n=this.config.group_dummy_user||"cn=fakeuser";return t=>{if(!t)return;if(t.toLowerCase()===n.toLowerCase())return;const c=/^([^=]+)=((?:\\.|[^,])+)/.exec(t);if(!c)return;const h=c[2].replace(/\\(.)/g,"$1"),u=t.toLowerCase();let p;e&&u.endsWith(e.toLowerCase())?p="User":s&&u.endsWith(s.toLowerCase())?p="Group":c[1]===o?p="User":c[1]===a&&(p="Group");const m={value:h};if(p&&(m.type=p,i)){const t="User"===p?"Users":"Groups";m.$ref=`${i.replace(/\/$/,"")}${r}/${t}/${encodeURIComponent(h)}`}return m}}async get(t,e){const s=this.dnForId(e,t);let r;try{r=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:[...p(this.mapping),"member"]},s)}catch(t){if(32===b(t))throw w(`Group ${e} not found`);throw t}if(!r.searchEntries||0===r.searchEntries.length)throw w(`Group ${e} not found`);return m(r.searchEntries[0],this.mapping,this.ctx(t),this.buildMemberResolver(t))}async list(t,e){const s=this.baseResolver.groupBase(t),r=Math.max(1,e.startIndex||1),i=Math.min(this.maxResults,Math.max(0,e.count??this.maxResults));let o,n=`(objectClass=${this.objectClass.find(t=>"top"!==t)||"groupOfNames"})`;if(e.filter){const t=d(e.filter,this.mapping);t.idEquals?o=t.idEquals:n=`(&${n}${t.ldapFilter})`}if(o)try{const e=await this.get(t,o);return{schemas:[a],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[e]}}catch(t){if(t instanceof y&&404===t.statusCode)return{schemas:[a],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const{entries:c,totalResults:h}=await f({directory:this.ldap.forRequest(t),base:s,filter:n,attributes:[...p(this.mapping),"member"],startIndex:r,count:i,maxScanned:this.maxScanned}),u=this.buildMemberResolver(t),l=c.map(e=>m(e,this.mapping,this.ctx(t),u));return{schemas:[a],totalResults:h,startIndex:r,itemsPerPage:l.length,Resources:l}}async create(i,o){if(!o.displayName)throw R("displayName is required");const a=(await e(this.hooks.scimgroupcreate,[o,i]))[0],{rdn:n,attributes:c}=l(a,this.mapping,this.objectClass);if(!n)throw R("displayName is required");s(n,this.rdnAttribute),c[this.rdnAttribute]=n;const h=[];if(a.members&&Array.isArray(a.members))for(const t of a.members){if(!t||!t.value)continue;const e=await this.users.resolveRef(i,t.value);e&&h.push(e)}if(0===h.length){const t=this.config.group_dummy_user||"cn=fakeuser";h.push(t)}c.member=h;const u=this.baseResolver.groupBase(i),p=`${this.rdnAttribute}=${t(n)},${u}`;try{await this.ldap.forRequest(i).add(p,c)}catch(t){if(68===b(t))throw _(`Group ${n} already exists`);throw t}const m=await this.get(i,n);return r(this.hooks.scimgroupcreatedone,m),m}async replace(t,s,i){await this.get(t,s);const o=(await e(this.hooks.scimgroupupdate,[s,i,t]))[1],{attributes:a}=l(o,this.mapping,this.objectClass),n=[];if(o.members&&Array.isArray(o.members))for(const e of o.members){if(!e||!e.value)continue;const s=await this.users.resolveRef(t,e.value);s&&n.push(s)}if(0===n.length){const t=this.config.group_dummy_user||"cn=fakeuser";n.push(t)}const c=this.dnForId(s,t),h={replace:{}};for(const[t,e]of Object.entries(a))"objectClass"!==t&&t!==this.rdnAttribute&&(h.replace[t]=e);h.replace.member=n,await this.ldap.forRequest(t).modify(c,h);const u=await this.get(t,s);return r(this.hooks.scimgroupupdatedone,s,u),u}async patch(t,e,s){const i=this.dnForId(e,t),o=await this.currentEntry(t,e),a=await g(s,{mapping:this.mapping,memberAttribute:"member",resolveMemberRef:async e=>this.users.resolveRef(t,e),current:o});if(a.delete&&!Array.isArray(a.delete)&&"member"in a.delete){const t=this.config.group_dummy_user||"cn=fakeuser";delete a.delete.member,0===Object.keys(a.delete).length&&delete a.delete,a.replace||(a.replace={}),a.replace.member=[t]}await this.ldap.forRequest(t).modify(i,a);const n=await this.get(t,e);return r(this.hooks.scimgroupupdatedone,e,n),n}async currentEntry(t,e){const s=this.dnForId(e,t);let r;try{r=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:[...p(this.mapping),"member"]},s)}catch(t){if(32===b(t))throw w(`Group ${e} not found`);throw t}if(!r.searchEntries?.length)throw w(`Group ${e} not found`);return r.searchEntries[0]}async delete(t,s){await this.get(t,s);const i=(await e(this.hooks.scimgroupdelete,[s,t]))[0],o=this.dnForId(i,t);await this.ldap.forRequest(t).delete(o),r(this.hooks.scimgroupdeletedone,i)}async resolveRef(e,s){if(!s)return;const r=this.baseResolver.groupBase(e);if(s.includes("=")&&s.includes(","))return i(s,r)?s:void 0;const a=`${this.rdnAttribute}=${t(s)},${r}`;try{const t=await this.ldap.forRequest(e).search({paged:!1,scope:"base",attributes:["dn"]},a);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==b(t))throw t}try{const t=await this.ldap.forRequest(e).search({filter:`(${this.rdnAttribute}=${o(s)})`,scope:"sub",paged:!1,attributes:["dn"]},r);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==b(t))throw t}}get groupMapping(){return this.mapping}}export{ScimGroups};
1
+ import{escapeDnValue as t,isDummyMemberDn as e,launchHooksChained as s,validateDnValue as r,launchHooks as i,isChildOf as o,escapeLdapFilter as a}from"../../lib/utils.js";import{SCHEMA_LIST_RESPONSE as n}from"./types.js";import{withExternalId as c,mergeMapping as h,loadMappingFile as u,DEFAULT_GROUP_MAPPING as p,requiredLdapAttributes as m,ldapToScimGroup as l,scimGroupToLdap as d}from"./mapping.js";import{scimFilterToLdap as f}from"./filter.js";import{pagedSearch as g}from"./list.js";import{patchToModifyRequest as b}from"./patch.js";import{extractLdapCode as w,scimNotFound as y,ScimError as R,scimInvalidValue as _,scimUniqueness as $}from"./errors.js";class ScimGroups{constructor(t){this.ldap=t.ldap,this.config=t.config,this.logger=t.logger,this.baseResolver=t.baseResolver,this.users=t.users,this.hooks=t.hooks,this.rdnAttribute=this.config.scim_group_rdn_attribute||"cn",this.objectClass=this.config.scim_group_object_class,this.idAttribute=this.config.scim_id_attribute||"rdn",this.maxResults=this.config.scim_max_results||200,this.maxScanned=this.config.scim_max_scanned||1e4,this.scimPrefix=this.config.scim_prefix||"/scim/v2";const e=this.config.scim_group_mapping||"";this.mapping=c(h(p,e?u(e):void 0),this.config.scim_group_external_id_attribute||"")}ctx(t){return{idAttribute:this.idAttribute,rdnAttribute:this.rdnAttribute,resourceType:"Group",baseUrl:this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),scimPrefix:this.scimPrefix,lockAttribute:"",lockValue:""}}dnForId(e,s){const r=this.baseResolver.groupBase(s);return`${this.rdnAttribute}=${t(e)},${r}`}buildMemberResolver(t){const s=this.baseResolver.userBase(t),r=this.baseResolver.groupBase(t),i=this.scimPrefix,o=this.config.scim_base_url||(t?.protocol&&t.get?`${t.protocol}://${String(t.get("host")||"")}`:""),a=this.config.scim_user_rdn_attribute||"uid",n=this.rdnAttribute,c=this.config.group_dummy_user||"cn=fakeuser";return t=>{if(!t)return;if(e(t,c))return;const h=/^([^=]+)=((?:\\.|[^,])+)/.exec(t);if(!h)return;const u=h[2].replace(/\\(.)/g,"$1"),p=t.toLowerCase();let m;s&&p.endsWith(s.toLowerCase())?m="User":r&&p.endsWith(r.toLowerCase())?m="Group":h[1]===a?m="User":h[1]===n&&(m="Group");const l={value:u};if(m&&(l.type=m,o)){const t="User"===m?"Users":"Groups";l.$ref=`${o.replace(/\/$/,"")}${i}/${t}/${encodeURIComponent(u)}`}return l}}async get(t,e){const s=this.dnForId(e,t);let r;try{r=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:[...m(this.mapping),"member"]},s)}catch(t){if(32===w(t))throw y(`Group ${e} not found`);throw t}if(!r.searchEntries||0===r.searchEntries.length)throw y(`Group ${e} not found`);return l(r.searchEntries[0],this.mapping,this.ctx(t),this.buildMemberResolver(t))}async list(t,e){const s=this.baseResolver.groupBase(t),r=Math.max(1,e.startIndex||1),i=Math.min(this.maxResults,Math.max(0,e.count??this.maxResults));let o,a=`(objectClass=${this.objectClass.find(t=>"top"!==t)||"groupOfNames"})`;if(e.filter){const t=f(e.filter,this.mapping);t.idEquals?o=t.idEquals:a=`(&${a}${t.ldapFilter})`}if(o)try{const e=await this.get(t,o);return{schemas:[n],totalResults:1,startIndex:1,itemsPerPage:1,Resources:[e]}}catch(t){if(t instanceof R&&404===t.statusCode)return{schemas:[n],totalResults:0,startIndex:1,itemsPerPage:0,Resources:[]};throw t}const{entries:c,totalResults:h}=await g({directory:this.ldap.forRequest(t),base:s,filter:a,attributes:[...m(this.mapping),"member"],startIndex:r,count:i,maxScanned:this.maxScanned}),u=this.buildMemberResolver(t),p=c.map(e=>l(e,this.mapping,this.ctx(t),u));return{schemas:[n],totalResults:h,startIndex:r,itemsPerPage:p.length,Resources:p}}async create(e,o){if(!o.displayName)throw _("displayName is required");const a=(await s(this.hooks.scimgroupcreate,[o,e]))[0],{rdn:n,attributes:c}=d(a,this.mapping,this.objectClass);if(!n)throw _("displayName is required");r(n,this.rdnAttribute),c[this.rdnAttribute]=n;const h=[];if(a.members&&Array.isArray(a.members))for(const t of a.members){if(!t||!t.value)continue;const s=await this.users.resolveRef(e,t.value);s&&h.push(s)}if(0===h.length){const t=this.config.group_dummy_user||"cn=fakeuser";h.push(t)}c.member=h;const u=this.baseResolver.groupBase(e),p=`${this.rdnAttribute}=${t(n)},${u}`;try{await this.ldap.forRequest(e).add(p,c)}catch(t){if(68===w(t))throw $(`Group ${n} already exists`);throw t}const m=await this.get(e,n);return i(this.hooks.scimgroupcreatedone,m),m}async replace(t,e,r){await this.get(t,e);const o=(await s(this.hooks.scimgroupupdate,[e,r,t]))[1],{attributes:a}=d(o,this.mapping,this.objectClass),n=[];if(o.members&&Array.isArray(o.members))for(const e of o.members){if(!e||!e.value)continue;const s=await this.users.resolveRef(t,e.value);s&&n.push(s)}if(0===n.length){const t=this.config.group_dummy_user||"cn=fakeuser";n.push(t)}const c=this.dnForId(e,t),h={replace:{}};for(const[t,e]of Object.entries(a))"objectClass"!==t&&t!==this.rdnAttribute&&(h.replace[t]=e);h.replace.member=n,await this.ldap.forRequest(t).modify(c,h);const u=await this.get(t,e);return i(this.hooks.scimgroupupdatedone,e,u),u}async patch(t,e,s){const r=this.dnForId(e,t),o=await this.currentEntry(t,e),a=await b(s,{mapping:this.mapping,memberAttribute:"member",resolveMemberRef:async e=>this.users.resolveRef(t,e),current:o});if(a.delete&&!Array.isArray(a.delete)&&"member"in a.delete){const t=this.config.group_dummy_user||"cn=fakeuser";delete a.delete.member,0===Object.keys(a.delete).length&&delete a.delete,a.replace||(a.replace={}),a.replace.member=[t]}await this.ldap.forRequest(t).modify(r,a);const n=await this.get(t,e);return i(this.hooks.scimgroupupdatedone,e,n),n}async currentEntry(t,e){const s=this.dnForId(e,t);let r;try{r=await this.ldap.forRequest(t).search({paged:!1,scope:"base",attributes:[...m(this.mapping),"member"]},s)}catch(t){if(32===w(t))throw y(`Group ${e} not found`);throw t}if(!r.searchEntries?.length)throw y(`Group ${e} not found`);return r.searchEntries[0]}async delete(t,e){await this.get(t,e);const r=(await s(this.hooks.scimgroupdelete,[e,t]))[0],o=this.dnForId(r,t);await this.ldap.forRequest(t).delete(o),i(this.hooks.scimgroupdeletedone,r)}async resolveRef(e,s){if(!s)return;const r=this.baseResolver.groupBase(e);if(s.includes("=")&&s.includes(","))return o(s,r)?s:void 0;const i=`${this.rdnAttribute}=${t(s)},${r}`;try{const t=await this.ldap.forRequest(e).search({paged:!1,scope:"base",attributes:["dn"]},i);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==w(t))throw t}try{const t=await this.ldap.forRequest(e).search({filter:`(${this.rdnAttribute}=${a(s)})`,scope:"sub",paged:!1,attributes:["dn"]},r);if(t.searchEntries&&t.searchEntries.length>0)return t.searchEntries[0].dn}catch(t){if(32!==w(t))throw t}}get groupMapping(){return this.mapping}}export{ScimGroups};
@@ -0,0 +1 @@
1
+ import e from"node-fetch";import{TwakePlugin as t}from"../../abstract/twakePlugin.js";import{isDnInBranch as r,rdnValue as s}from"../../lib/utils.js";function isRegisteredUser(e){return"object"==typeof e&&null!==e&&"string"==typeof e.id}class Calendar extends t{constructor(e){super(e,"calendar_webadmin_url","calendar_webadmin_token","calendar_concurrency"),this.name="calendar",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={ldapcalendarResourceadddone:async e=>{const[t,r]=e;if(!this.isResource(t,r))return;const s=this.buildResourceData(t,r);s?await this.callWebAdminApi("ldapcalendarResourceadddone",`${this.webadminUrl}/resources`,"POST",t,JSON.stringify(s),{resourceName:s.name}):this.logger.debug(`Skipping resource creation for ${t}: missing required fields`)},ldapcalendarResourcemodifydone:async e=>{const[t,r]=e;if(!this.isInResourceBranch(t))return;const s=this.getResourceId(t);if(!s)return;const i={};r.replace?.cn&&(i.name=Array.isArray(r.replace.cn)?String(r.replace.cn[0]):String(r.replace.cn)),r.replace?.description&&(i.description=Array.isArray(r.replace.description)?String(r.replace.description[0]):String(r.replace.description)),0!==Object.keys(i).length?await this.callWebAdminApi("ldapcalendarResourcemodifydone",this.resourceUrl(s),"PATCH",t,JSON.stringify(i),{resourceId:s,...i}):this.logger.debug(`No relevant changes for resource ${t}`)},ldapcalendarResourcedeletedone:async e=>{if(!this.isInResourceBranch(e))return;const t=this.getResourceId(e);t&&await this.callWebAdminApi("ldapcalendarResourcedeletedone",this.resourceUrl(t),"DELETE",e,null,{resourceId:t})},onLdapChange:async(e,t)=>{const r=t[this.mailAttr];if(r){const t=this.attributeToString(r[0]),s=this.attributeToString(r[1]);return void(t&&s&&t!==s&&await this.syncRegisteredUser("onLdapMailChange",e,t))}(t[this.firstnameAttr]||t[this.lastnameAttr])&&await this.syncRegisteredUser("onLdapDisplayNameChange",e)}},this.resourceBase=this.config.calendar_resource_base||"",this.resourceObjectClass=this.config.calendar_resource_objectclass||"",this.resourceCreator=this.config.calendar_resource_creator||"admin@example.com",this.resourceDomain=this.config.calendar_resource_domain||"";const t=this.config.ldap_base;this.resourceBase&&t&&!r(this.resourceBase,t)&&this.logger.warn(`Calendar plugin: calendar_resource_base (${this.resourceBase}) is not a DN under ${t}; no entry will be taken for a resource`),this.firstnameAttr=this.config.calendar_firstname_attribute||"givenName",this.lastnameAttr=this.config.calendar_lastname_attribute||"sn"}isResource(e,t){if(!this.isInResourceBranch(e))return!1;const r=t.objectClass;if(this.resourceObjectClass){return(Array.isArray(r)?r:[r]).some(e=>String(e).toLowerCase()===this.resourceObjectClass.toLowerCase())}return!0}isInResourceBranch(e){return!this.resourceBase||r(e,this.resourceBase)}resourceUrl(e){return`${this.webadminUrl}/resources/${encodeURIComponent(e)}`}buildResourceData(e,t){const r=t.cn,s=this.attributeToString(r);if(!s)return null;const i=this.attributeToString(t.description)||void 0,a=this.resourceCreator,n=this.resourceDomain||this.extractDomainFromDn(e),o=this.getResourceId(e);return o?{name:s,description:i,creator:a,domain:n,id:o}:null}getResourceId(e){return s(e)||null}async findRegisteredUser(t,r){const s={...r,step:"find_registered_user",searchEmail:t},i=new URL(`${this.webadminUrl}/registeredUsers`);i.searchParams.set("email",t);const a=await this.requestLimit(()=>e(i.toString(),{method:"GET",headers:this.createHeaders()}));if(404!==a.status){if(!a.ok)return this.logger.error({...s,http_status:a.status,http_status_text:a.statusText}),null;let e;try{e=await a.json()}catch(e){return this.logger.error({...s,http_status:a.status,error:`unreadable answer: ${e}`}),null}if(!Array.isArray(e))return isRegisteredUser(e)?e:(this.logger.error({...s,http_status:a.status,error:"answer is not a registered user"}),null);const r=e.find(e=>isRegisteredUser(e)&&e.email?.toLowerCase()===t.toLowerCase());if(r)return r}return this.logger.warn({...s,message:"user not registered in Calendar"}),null}async syncRegisteredUser(t,r,s){const i={plugin:this.name,event:t,result:"error",dn:r},a=await this.ldapGetAttributes(r,[this.mailAttr,this.firstnameAttr,this.lastnameAttr]);if(!a)return void this.logger.warn({...i,message:`Cannot sync registered user: entry not found for ${r}`});const n=this.attributeToString(a[this.mailAttr]);if(!n)return void this.logger.warn({...i,message:`Cannot sync registered user: no mail found for ${r}`});const o=this.attributeToString(a[this.firstnameAttr]),c=this.attributeToString(a[this.lastnameAttr]),u=s||n;try{const t=await this.findRegisteredUser(u,i);if(!t)return;const r=new URL(`${this.webadminUrl}/registeredUsers`);r.searchParams.set("id",t.id);const s={email:n};o&&(s.firstname=o),c&&(s.lastname=c);const a=await this.requestLimit(()=>e(r.toString(),{method:"PATCH",headers:this.createHeaders("application/json"),body:JSON.stringify(s)}));a.ok?this.logger.info({...i,result:"success",id:t.id,http_status:a.status,...s}):this.logger.error({...i,step:"patch_registered_user",id:t.id,http_status:a.status,http_status_text:a.statusText})}catch(e){this.logger.error({...i,error:`${e}`})}}async deleteUserData(t){const r={plugin:this.name,event:"deleteUserData",mail:t};try{const s=new URL(`${this.webadminUrl}/users/${t}`);s.searchParams.set("action","deleteData");const i=await this.requestLimit(()=>e(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!i.ok)return this.logger.error({...r,http_status:i.status,http_status_text:i.statusText}),null;const a=await i.json();return this.logger.info({...r,http_status:i.status,taskId:a.taskId}),a}catch(e){return this.logger.error({...r,error:`${e}`}),null}}}export{Calendar as default};
@@ -1 +1 @@
1
- import e from"node-fetch";import{TwakePlugin as t}from"../../abstract/twakePlugin.js";class CalendarResources extends t{constructor(e){super(e,"calendar_webadmin_url","calendar_webadmin_token","calendar_concurrency"),this.name="calendarResources",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange"},this.hooks={ldapcalendarResourceadddone:async e=>{const[t,s]=e;if(!this.isResource(t,s))return;const r=this.buildResourceData(t,s);r?await this.callWebAdminApi("ldapcalendarResourceadddone",`${this.webadminUrl}/resources`,"POST",t,JSON.stringify(r),{resourceName:r.name}):this.logger.debug(`Skipping resource creation for ${t}: missing required fields`)},ldapcalendarResourcemodifydone:async e=>{const[t,s]=e,r=this.getResourceId(t);if(!r)return;const a={};s.replace?.cn&&(a.name=Array.isArray(s.replace.cn)?String(s.replace.cn[0]):String(s.replace.cn)),s.replace?.description&&(a.description=Array.isArray(s.replace.description)?String(s.replace.description[0]):String(s.replace.description)),0!==Object.keys(a).length?await this.callWebAdminApi("ldapcalendarResourcemodifydone",`${this.webadminUrl}/resources/${r}`,"PATCH",t,JSON.stringify(a),{resourceId:r,...a}):this.logger.debug(`No relevant changes for resource ${t}`)},ldapcalendarResourcedeletedone:async e=>{const t=this.getResourceId(e);t&&await this.callWebAdminApi("ldapcalendarResourcedeletedone",`${this.webadminUrl}/resources/${t}`,"DELETE",e,null,{resourceId:t})},onLdapChange:async(e,t)=>{const s=t[this.mailAttr];if(s){const t=this.attributeToString(s[0]),r=this.attributeToString(s[1]);return void(t&&r&&t!==r&&await this.syncRegisteredUser("onLdapMailChange",e,t))}(t[this.firstnameAttr]||t[this.lastnameAttr])&&await this.syncRegisteredUser("onLdapDisplayNameChange",e)}},this.resourceBase=this.config.calendar_resource_base||"",this.resourceObjectClass=this.config.calendar_resource_objectclass||"",this.resourceCreator=this.config.calendar_resource_creator||"admin@example.com",this.resourceDomain=this.config.calendar_resource_domain||"",this.firstnameAttr=this.config.calendar_firstname_attribute||"givenName",this.lastnameAttr=this.config.calendar_lastname_attribute||"sn"}isResource(e,t){if(this.resourceBase&&!e.toLowerCase().includes(this.resourceBase.toLowerCase()))return!1;const s=t.objectClass;if(this.resourceObjectClass){return(Array.isArray(s)?s:[s]).some(e=>String(e).toLowerCase()===this.resourceObjectClass.toLowerCase())}return!0}buildResourceData(e,t){const s=t.cn,r=this.attributeToString(s);if(!r)return null;const a=this.attributeToString(t.description)||void 0,i=this.resourceCreator,n=this.resourceDomain||this.extractDomainFromDn(e),o=this.getResourceId(e)||r.toLowerCase().replace(/\s+/g,"-");return{name:r,description:a,creator:i,domain:n,id:o}}getResourceId(e){const t=e.match(/(?:cn|uid)=([^,]+)/i);return t?t[1]:null}async syncRegisteredUser(t,s,r){const a={plugin:this.name,event:t,result:"error",dn:s},i=await this.ldapGetAttributes(s,[this.mailAttr,this.firstnameAttr,this.lastnameAttr]);if(!i)return void this.logger.warn({...a,message:`Cannot sync registered user: entry not found for ${s}`});const n=this.attributeToString(i[this.mailAttr]);if(!n)return void this.logger.warn({...a,message:`Cannot sync registered user: no mail found for ${s}`});const o=this.attributeToString(i[this.firstnameAttr]),c=this.attributeToString(i[this.lastnameAttr]),d=r||n;try{const t=await this.requestLimit(()=>e(`${this.webadminUrl}/registeredUsers`,{method:"GET",headers:this.createHeaders()}));if(!t.ok)return void this.logger.error({...a,step:"list_registered_users",http_status:t.status,http_status_text:t.statusText});const s=(await t.json()).find(e=>e.email?.toLowerCase()===d.toLowerCase());if(!s)return void this.logger.warn({...a,step:"find_registered_user",searchEmail:d,message:"user not registered in Calendar"});const r=new URL(`${this.webadminUrl}/registeredUsers`);r.searchParams.set("id",s.id);const i={email:n};o&&(i.firstname=o),c&&(i.lastname=c);const u=await this.requestLimit(()=>e(r.toString(),{method:"PATCH",headers:this.createHeaders("application/json"),body:JSON.stringify(i)}));u.ok?this.logger.info({...a,result:"success",id:s.id,http_status:u.status,...i}):this.logger.error({...a,step:"patch_registered_user",id:s.id,http_status:u.status,http_status_text:u.statusText})}catch(e){this.logger.error({...a,error:`${e}`})}}async deleteUserData(t){const s={plugin:this.name,event:"deleteUserData",mail:t};try{const r=new URL(`${this.webadminUrl}/users/${t}`);r.searchParams.set("action","deleteData");const a=await this.requestLimit(()=>e(r.toString(),{method:"POST",headers:this.createHeaders()}));if(!a.ok)return this.logger.error({...s,http_status:a.status,http_status_text:a.statusText}),null;const i=await a.json();return this.logger.info({...s,http_status:a.status,taskId:i.taskId}),i}catch(e){return this.logger.error({...s,error:`${e}`}),null}}}export{CalendarResources as default};
1
+ import e from"./calendar.js";console.warn("[ldap-rest] Plugin `core/twake/calendarResources` is deprecated; replace it with `core/twake/calendar` — do not load both, each would sync Calendar.");class CalendarResources extends e{constructor(){super(...arguments),this.name="calendarResources"}}export{CalendarResources 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}from"../../lib/utils.js";import{wantJson as s}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.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(!s(e,i))return;const r=e.params.user;try{const e=a(r),s=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=s.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${r} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${r} 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 ${r}`}):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:r,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(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,{method:"GET",headers:this.createHeaders()}));if(!n.ok)return void this.logger.error({...s,step:"get_identities",http_status:n.status,http_status_text:n.statusText});const l=await n.json(),o=l.find(t=>t.email===i)||l[0];if(!o)return void this.logger.warn({...s,step:"find_identity",message:"No identity found for user"});const m=await this.generateSignature(e),d=`${this.webadminUrl}/users/${i}/identities/${o.id}`,h={id:o.id,email:o.email,name:a};m&&(h.htmlSignature=m);const u=await this.requestLimit(()=>t(d,{method:"PUT",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));u.ok?this.logger.info({...s,result:"success",http_status:u.status}):this.logger.error({...s,step:"update_identity",http_status:u.status,http_status_text:u.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=>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";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.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(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,{method:"GET",headers:this.createHeaders()}));if(!n.ok)return void this.logger.error({...s,step:"get_identities",http_status:n.status,http_status_text:n.statusText});const l=await n.json(),o=l.find(t=>t.email===i)||l[0];if(!o)return void this.logger.warn({...s,step:"find_identity",message:"No identity found for user"});const m=await this.generateSignature(e),d=`${this.webadminUrl}/users/${i}/identities/${o.id}`,h={id:o.id,email:o.email,name:a};m&&(h.htmlSignature=m);const u=await this.requestLimit(()=>t(d,{method:"PUT",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));u.ok?this.logger.info({...s,result:"success",http_status:u.status}):this.logger.error({...s,step:"update_identity",http_status:u.status,http_status_text:u.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};
@@ -144,6 +144,50 @@ export interface LdapFlatConfig {
144
144
  * example: ou=engineering,ou=departments,dc=example,dc=com
145
145
  * example:
146
146
  * targetOrgDn: ou=engineering,ou=departments,dc=example,dc=com
147
+ * FlatRenameRequest:
148
+ * type: object
149
+ * description: |
150
+ * Body for changing the identifier of an entry. The field is named
151
+ * `newId` for every resource, whatever that resource calls its
152
+ * `mainAttribute`.
153
+ * required: [newId]
154
+ * properties:
155
+ * newId:
156
+ * type: string
157
+ * description: |
158
+ * New value of the resource's `mainAttribute`. A value, not a DN.
159
+ * example: bob
160
+ * example:
161
+ * newId: bob
162
+ * FlatRenameResult:
163
+ * type: object
164
+ * description: |
165
+ * Outcome of a rename: the entry's new DN, and how many references to
166
+ * the old one were rewritten.
167
+ * properties:
168
+ * success:
169
+ * type: boolean
170
+ * description: False when a reference could not be rewritten.
171
+ * dn:
172
+ * type: string
173
+ * description: DN the entry now has.
174
+ * referencesUpdated:
175
+ * type: integer
176
+ * description: References rewritten to the new DN.
177
+ * referencesFailed:
178
+ * type: array
179
+ * description: |
180
+ * References left naming the old DN, counted per attribute. The
181
+ * referring entries are named in the server log, not here.
182
+ * items:
183
+ * type: object
184
+ * properties:
185
+ * attribute: { type: string }
186
+ * count: { type: integer }
187
+ * example:
188
+ * success: true
189
+ * dn: uid=bob,ou=users,dc=example,dc=com
190
+ * referencesUpdated: 3
147
191
  */
148
192
  export default abstract class LdapFlat extends DmPlugin {
149
193
  base: string;
@@ -156,6 +200,85 @@ export default abstract class LdapFlat extends DmPlugin {
156
200
  pluralName: string;
157
201
  hookPrefix: string;
158
202
  constructor(server: DM, config: LdapFlatConfig);
203
+ /**
204
+ * Attributes a client is not allowed to supply: those the server computes
205
+ * (`generated`) and those it only ever derives from elsewhere (`readOnly`,
206
+ * such as `memberOf`, which is driven from the group side).
207
+ *
208
+ * The core and its plugins still write them — the restriction is on the
209
+ * request body, not on the entry.
210
+ *
211
+ * @returns lowercased attribute names
212
+ */
213
+ protected clientForbiddenAttributes(): Set<string>;
214
+ /**
215
+ * Attributes never sent back over the API. A manager may reset a password
216
+ * without being able to read it back: the asymmetry is a projection, not an
217
+ * authorization rule, so it lives with the schema.
218
+ *
219
+ * @returns lowercased attribute names
220
+ */
221
+ protected hiddenAttributes(): Set<string>;
222
+ /**
223
+ * Remove the `neverReturn` attributes from an entry about to be serialised.
224
+ * LDAP attribute names are case-insensitive and may carry options
225
+ * (`userPassword;binary`), so both are normalised before comparison.
226
+ *
227
+ * @param entry entry as read from the directory
228
+ * @returns a copy without the hidden attributes
229
+ */
230
+ protected project<T extends Record<string, unknown>>(entry: T): T;
231
+ /**
232
+ * Same as {@link project}, for a map of entries keyed by identifier.
233
+ *
234
+ * @param list entries as read from the directory
235
+ * @returns a copy without the hidden attributes
236
+ */
237
+ protected projectList(list: LdapList): LdapList;
238
+ /**
239
+ * Refuse a request body that carries an attribute the client may not set.
240
+ *
241
+ * Failing loudly matters more than ignoring the value: a client that sends
242
+ * `twakeDepartmentPath` believes it is setting the path, and silently
243
+ * overwriting it with the computed one would leave it thinking otherwise.
244
+ *
245
+ * @param names attribute names present in the request
246
+ * @throws BadRequestError naming the first offending attribute
247
+ */
248
+ protected rejectForbiddenInput(names: string[]): void;
249
+ /**
250
+ * Derive the RDN value of a new entry when the schema says the server
251
+ * generates it — an account identifier taken from the local part of its mail
252
+ * address, typically.
253
+ *
254
+ * The derivation itself (source attribute, extraction pattern, collision
255
+ * strategy) is schema configuration; this method only applies it.
256
+ *
257
+ * @param body creation payload
258
+ * @returns the generated value, or undefined when nothing is generated
259
+ * @throws BadRequestError when the source attribute is missing, or when the
260
+ * value collides and the schema asks for an error
261
+ */
262
+ protected generateMainAttribute(body: Record<string, AttributeValue>): Promise<string | undefined>;
263
+ /**
264
+ * Tell whether an entry already uses this RDN value in the branch.
265
+ *
266
+ * @param value candidate RDN value
267
+ * @returns true when the DN is taken
268
+ */
269
+ protected rdnExists(value: string): Promise<boolean>;
270
+ /**
271
+ * Build the rejection message for a value that failed its schema `test`.
272
+ *
273
+ * When the schema carries a `hint`, the message says what a valid value
274
+ * looks like instead of only that this one was not. The hint lives next to
275
+ * the pattern so the two cannot drift apart, and so a client can show it
276
+ * under the field *before* the user gets it wrong.
277
+ *
278
+ * @param field attribute name
279
+ * @returns message for a `BadRequestError`
280
+ */
281
+ protected invalidValueMessage(field: string): string;
159
282
  /**
160
283
  * API routes
161
284
  */
@@ -165,6 +288,26 @@ export default abstract class LdapFlat extends DmPlugin {
165
288
  apiDelete(req: Request, res: Response): Promise<void>;
166
289
  apiModify(req: Request, res: Response): Promise<void>;
167
290
  apiMove(req: Request, res: Response): Promise<void>;
291
+ /**
292
+ * Change an entry's identifier, and make the directory consistent again
293
+ * before answering.
294
+ *
295
+ * Shaped like `apiMove`, and deliberately not on `tryMethod`: that helper
296
+ * flattens every outcome to `{success: true}`, which cannot say how many
297
+ * references were rewritten nor that one was not.
298
+ *
299
+ * @param req request, carrying `newId`
300
+ * @param res response
301
+ */
302
+ apiRename(req: Request, res: Response): Promise<void>;
303
+ /**
304
+ * Whether an entry exists, asked without narrowing by the caller's own
305
+ * permissions — as `apiGet` asks it.
306
+ *
307
+ * @param dn entry to look for
308
+ * @returns true when the directory holds it
309
+ */
310
+ private entryExists;
168
311
  /**
169
312
  * Resolve an id (either an RDN value or a full DN) into a DN inside this
170
313
  * instance's base branch.
@@ -187,7 +330,22 @@ export default abstract class LdapFlat extends DmPlugin {
187
330
  protected resolveDn(id: string): string;
188
331
  addEntry(id: string, additional?: AttributesList, req?: Request): Promise<boolean>;
189
332
  modifyEntry(id: string, changes: ModifyRequest): Promise<boolean>;
190
- renameEntry(id: string, newId: string): Promise<boolean>;
333
+ /**
334
+ * Change the RDN of an entry.
335
+ *
336
+ * The request is not optional in practice: `ldapActions` hands it to the
337
+ * `ldaprenamerequest` hook, and every authorization plugin skips its check
338
+ * when there is none (`AuthzBase.shouldSkipAuthorization`). Renaming
339
+ * without one therefore renames without authorization — silently. It stays
340
+ * optional in the signature for the callers that genuinely belong to no
341
+ * request, as the rest of this class does.
342
+ *
343
+ * @param id current identifier, or the entry's DN
344
+ * @param newId new identifier, or the entry's new DN
345
+ * @param req request this comes from, so the branch check runs
346
+ * @returns true when the directory accepted the rename
347
+ */
348
+ renameEntry(id: string, newId: string, req?: Request): Promise<boolean>;
191
349
  deleteEntry(id: string): Promise<boolean>;
192
350
  /**
193
351
  * Move entry to a different organization
@@ -42,6 +42,19 @@ export default abstract class DmPlugin {
42
42
  * @returns uniq operation number
43
43
  */
44
44
  opNumber(): number;
45
+ /**
46
+ * Leave out of data about to be sent every attribute a loaded schema marks
47
+ * `neverReturn`.
48
+ *
49
+ * A flat entity hides its own attributes, but an organization's subnodes are
50
+ * users, groups, positions…, each described by another plugin's schema: an
51
+ * answer mixing entities has to honour all of them, or `/users/<uid>` hides
52
+ * a password hash that `/organizations/<org>/subnodes` hands out.
53
+ *
54
+ * @param data an entry, a list of entries, or entries keyed by identifier
55
+ * @returns the same shape, without the hidden attributes
56
+ */
57
+ protected hideNeverReturn<T>(data: T): T;
45
58
  /** Names already warned about by requirePlugin, so each warns at most once. */
46
59
  private missingPluginsWarned?;
47
60
  /**
@@ -58,6 +58,8 @@ export interface Config {
58
58
  ldap_organization_max_subnodes?: number;
59
59
  organization_schema?: string;
60
60
  ldap_flat_schema?: string[];
61
+ enterprise_domain_name_attribute?: string;
62
+ enterprise_domain_link_attribute?: string;
61
63
  ldap_raw_base?: string[];
62
64
  ldap_raw_hidden_attribute?: string[];
63
65
  ldap_raw_show_secrets?: boolean;