ldap-rest 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/abstract/twakePlugin.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/config/schema.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/plugins/ldap/enterpriseRules.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/onChange.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/twake/james.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +9 -11
- package/dist/src/abstract/twakePlugin.d.ts +18 -1
- package/dist/src/bin/index.d.ts +10 -0
- package/dist/src/browser/ldap-group-editor/types.d.ts +2 -1
- package/dist/src/browser/ldap-resource-editor/types.d.ts +3 -2
- package/dist/src/browser/ldap-unit-editor/types.d.ts +2 -1
- package/dist/src/browser/ldap-user-editor/types.d.ts +3 -2
- package/dist/src/browser/shared/types/index.d.ts +7 -1
- package/dist/src/browser/shared/utils/schema.d.ts +20 -1
- package/dist/src/config/args.d.ts +2 -0
- package/dist/src/config/schema.d.ts +27 -4
- package/dist/src/hooks.d.ts +3 -1
- package/dist/src/plugins/ldap/organizations.d.ts +0 -8
- package/dist/src/plugins/twake/james.d.ts +12 -74
- package/docs/client-development/api/reference.md +1 -1
- package/docs/client-development/api/rest-api.md +11 -1
- package/docs/client-development/schemas/README.md +18 -13
- package/docs/plugin-development/dependencies.md +7 -3
- package/docs/presentations/en.md +0 -28
- package/docs/presentations/fr.md +0 -28
- package/docs/usage/configuration.md +14 -10
- package/docs/usage/plugins/integrations/james-mail.md +51 -115
- package/docs/usage/plugins/integrations/james-mailboxes.md +58 -78
- package/docs/usage/plugins/ldap/flat-generic.md +23 -5
- package/docs/usage/plugins/ldap/on-change.md +6 -0
- package/docs/usage/plugins/utilities/weblogs.md +1 -1
- package/docs/usage/upgrading.md +30 -0
- package/package.json +1 -1
- package/static/browser/ldap-resource-editor/api/ResourceApiClient.js +2 -1
- package/static/browser/ldap-resource-editor/api/ResourceApiClient.js.map +1 -1
- package/static/browser/ldap-user-editor/api/UserApiClient.js +2 -1
- package/static/browser/ldap-user-editor/api/UserApiClient.js.map +1 -1
- package/static/browser/ldap-user-editor/components/UserEditor.js +11 -8
- package/static/browser/ldap-user-editor/components/UserEditor.js.map +1 -1
- package/static/browser/ldap-user-editor/components/UserList.js +3 -1
- package/static/browser/ldap-user-editor/components/UserList.js.map +1 -1
- package/static/browser/ldap-user-editor.esm.js +52 -10
- package/static/browser/ldap-user-editor.esm.js.map +1 -1
- package/static/browser/ldap-user-editor.js +52 -10
- package/static/browser/ldap-user-editor.js.map +1 -1
- package/static/browser/ldap-user-editor.min.js +1 -1
- package/static/browser/ldap-user-editor.min.js.map +1 -1
- package/static/browser/shared/index.js +1 -1
- package/static/browser/shared/types/index.js.map +1 -1
- package/static/browser/shared/utils/form.js +2 -1
- package/static/browser/shared/utils/form.js.map +1 -1
- package/static/browser/shared/utils/schema.js +33 -1
- package/static/browser/shared/utils/schema.js.map +1 -1
- package/static/browser/shared.esm.js +153 -121
- package/static/browser/shared.esm.js.map +1 -1
- package/static/browser/shared.js +154 -120
- package/static/browser/shared.js.map +1 -1
- package/static/browser/shared.min.js +1 -1
- package/static/browser/shared.min.js.map +1 -1
- package/static/schemas/standard/groups.json +1 -1
- package/static/schemas/twake/groups.json +25 -7
- package/static/schemas/twake/nomenclature/twakeAccountStatus.json +10 -2
- package/static/schemas/twake/nomenclature/twakeDeliveryMode.json +10 -2
- package/static/schemas/twake/nomenclature/twakeDomain.json +9 -3
- package/static/schemas/twake/nomenclature/twakeListType.json +10 -2
- package/static/schemas/twake/nomenclature/twakeMailboxType.json +10 -2
- package/static/schemas/twake/nomenclature/twakeTitle.json +10 -2
- package/static/schemas/twake/organizations.json +4 -1
- package/static/schemas/twake/positions.json +4 -1
- package/static/schemas/twake/users.json +36 -9
|
@@ -1 +1 @@
|
|
|
1
|
-
import t from"../../abstract/plugin.js";import{tryMethodData as i,jsonBody as a,tryMethod as e,wantJson as n}from"../../lib/expressFormatedResponses.js";import{asyncHandler as s,validateDnValue as r,escapeDnValue as o,getRdn as h,isDnInBranch as c,rdnValue as d,normalizeDn as l,getParentDn as g,escapeLdapFilter as p,launchHooksChained as f,isChildOf as u,substringSearchFilter as w}from"../../lib/utils.js";import{BadRequestError as m,NotFoundError as z,ConflictError as y,HttpError as b}from"../../lib/errors.js";import{extractLdapCode as $}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as _,schemaUrl as v}from"../../lib/schemaFile.js";import{assertClientMaySet as O,modifiedAttributeNames as C,missingRequiredAttribute as E,checkDnValues as A,matchesPattern as L}from"../../config/schema.js";class LdapOrganizations extends t{constructor(t){if(super(t),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new m("An organization link cannot be deleted");if(e)throw new m("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema){const t=this.config.organization_schema;_(t,{config:this.config}).then(t=>{this.schema=t,this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,a)=>{await i(a,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,s(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,s(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,s(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,s(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,s(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,s(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const n=a(t,i,"ou");if(!n)return;O(this.schema,Object.keys(n),"ou"),r(n.ou,"ou");const s=n.parentDn||this.config.ldap_top_organization,h=`ou=${o(n.ou)},${s}`,c={objectClass:this.config.ldap_organization_class,ou:n.ou,...Object.fromEntries(Object.entries(n).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await e(i,this.addOrganization.bind(this),h,c,t)}async apiModify(t,i){const n=a(t,i);if(!n)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new m("dn is required");O(this.schema,C(n)),await e(i,this.modifyOrganization.bind(this),s,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new m("dn is required");await e(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,e){if(!n(t,e))return;const s=a(t,e,"targetOrgDn");if(!s)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new m("dn is required");const{targetOrgDn:o}=s;if(!o||"string"!=typeof o)throw new m("Missing or invalid targetOrgDn in request body");await i(e,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new z(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new m(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(h(i))&&c(i,s)?d(i):void 0,o=t.ou,f=(Array.isArray(o)?o[0]:o)||r;let u=e;if((this.isOu(t)||void 0!==r)&&f){const t=s?d(s):"";if(t&&e.startsWith(f+n)&&e.endsWith(n+t))return;if(e===f){const t=s?l(s):"";if(!i||!t||!c(i,s)||l(i)===t||l(g(i))===t)return;throw new m(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+f))throw new m(`Organization path must end with its own name, preceded by separator "${n}"`);u=e.slice(0,e.length-n.length-f.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${p(u)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new m(`Invalid organization path ${e}: no organization holds "${u}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${p(t)})`})).searchEntries.length>0)throw new y(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new m("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new z("Top organization not found");const[,a]=await f(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new z(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=g(s);let o=`(${this.config.ldap_organization_link_attribute}=${p(t)})`;return n&&(o=`(&${o}(objectClass=${p(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=$(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==$(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=r.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));const a=E(this.schema,i,this.server.loadedPlugins);if(a)throw new m(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new m(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new m(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new m(`Field ${t} must be a single value`);return await A(t,a,i,t=>this.entryExists(t)),L(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new z(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new z(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new m(`Target ${i} is not an organizational unit`);const n=`${h(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new z(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new z(`Source organization ${t} not found`);if(n===t)throw new m("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||u(i,t))throw new m("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){if(t instanceof b)throw t;throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${w(i,"ou,description")})`,n,a));const s=this.config.ldap_top_organization,r=g(s),o=p(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${w(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${r}`),e.push(...await this.linkedEntities(r,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",s=`(&(objectClass=organizationalUnit)${w(i,n)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:s},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const n=$(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(4!==n)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:s,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=e)return r;const h=o-e;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.server.loadedPlugins.static&&this.config.organization_schema?v(this.config,this.config.organization_schema):void 0;return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
|
|
1
|
+
import t from"../../abstract/plugin.js";import{tryMethodData as i,jsonBody as a,tryMethod as e,wantJson as n}from"../../lib/expressFormatedResponses.js";import{asyncHandler as r,validateDnValue as s,escapeDnValue as o,getRdn as h,isDnInBranch as c,rdnValue as d,normalizeDn as l,getParentDn as g,escapeLdapFilter as p,launchHooksChained as u,isChildOf as f,substringSearchFilter as w}from"../../lib/utils.js";import{BadRequestError as m,NotFoundError as z,ConflictError as y,HttpError as b}from"../../lib/errors.js";import{extractLdapCode as $}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as _,schemaUrl as v}from"../../lib/schemaFile.js";import{assertClientMaySet as O,modifiedAttributeNames as A,missingRequiredAttribute as C,invalidValueMessage as E,checkDnValues as L,matchesPattern as k}from"../../config/schema.js";class LdapOrganizations extends t{constructor(t){if(super(t),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,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.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new m("An organization link cannot be deleted");if(e)throw new m("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(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,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema){const t=this.config.organization_schema;_(t,{config:this.config}).then(t=>{this.schema=t,this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,a)=>{await i(a,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,a)=>{const e=decodeURIComponent(t.params.dn);await i(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,r(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,r(async(t,a)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new m('query parameter "q" is required');await i(a,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,r(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,r(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const n=a(t,i,"ou");if(!n)return;O(this.schema,Object.keys(n),"ou"),s(n.ou,"ou");const r=n.parentDn||this.config.ldap_top_organization,h=`ou=${o(n.ou)},${r}`,c={objectClass:this.config.ldap_organization_class,ou:n.ou,...Object.fromEntries(Object.entries(n).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await e(i,this.addOrganization.bind(this),h,c,t)}async apiModify(t,i){const n=a(t,i);if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new m("dn is required");O(this.schema,A(n)),await e(i,this.modifyOrganization.bind(this),r,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new m("dn is required");await e(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,e){if(!n(t,e))return;const r=a(t,e,"targetOrgDn");if(!r)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new m("dn is required");const{targetOrgDn:o}=r;if(!o||"string"!=typeof o)throw new m("Missing or invalid targetOrgDn in request body");await i(e,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 z(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new m(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",r=this.config.ldap_top_organization||"",s=i&&r&&/^ou=/i.test(h(i))&&c(i,r)?d(i):void 0,o=t.ou,u=(Array.isArray(o)?o[0]:o)||s;let f=e;if((this.isOu(t)||void 0!==s)&&u){const t=r?d(r):"";if(t&&e.startsWith(u+n)&&e.endsWith(n+t))return;if(e===u){const t=r?l(r):"";if(!i||!t||!c(i,r)||l(i)===t||l(g(i))===t)return;throw new m(`Organization path "${e}" names no parent, but ${i} is not directly under ${r}`)}if(!e.endsWith(n+u))throw new m(`Organization path must end with its own name, preceded by separator "${n}"`);f=e.slice(0,e.length-n.length-u.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${p(f)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new m(`Invalid organization path ${e}: no organization holds "${f}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${p(t)})`})).searchEntries.length>0)throw new y(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new m("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new z("Top organization not found");const[,a]=await u(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){let a;try{a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i)}catch(i){if(32===$(i))throw new z(`Organization ${t} not found`);throw i}if(1!==a.searchEntries.length)throw new z(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const r=this.config.ldap_top_organization,s=g(r);let o=`(${this.config.ldap_organization_link_attribute}=${p(t)})`;return n&&(o=`(&${o}(objectClass=${p(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${s}`),a.push(...await this.linkedEntities(s,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=$(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const r=[];let s=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){s+=t.searchEntries.length;const i=a-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(r){if(4!==$(r))throw r;const s=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=s.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${s} linked entities for ${e}, returning ${r.length}`),s<=a?r:[...r,LdapOrganizations.moreIndicator(`more-${e}`,`... ${s-a} more ${s-a===1?"element":"elements"}`,a,s)]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));const a=C(this.schema,i,this.server.loadedPlugins);if(a)throw new m(`Attribute "${a}" is required`);return!0}invalidValueMessage(t){return E(t,this.schema?.attributes[t])}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new m(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new m(`Attribute "${t}" is not allowed`);return!0}if(null==i||Array.isArray(i)&&0===i.length){if(a.required)throw new m(`Attribute "${t}" is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new m(`Attribute "${t}" must be a single value`);return await L(t,a,i,t=>this.entryExists(t)),k(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new z(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new z(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new m(`Target ${i} is not an organizational unit`);const n=`${h(t)},${i}`;let r;try{r=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new z(`Source organization not found: ${t}`)}if(!r.searchEntries||0===r.searchEntries.length)throw new z(`Source organization ${t} not found`);if(n===t)throw new m("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new m("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){if(t instanceof b)throw t;throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${w(i,"ou,description")})`,n,a));const r=this.config.ldap_top_organization,s=g(r),o=p(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${w(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${s}`),e.push(...await this.linkedEntities(s,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",r=`(&(objectClass=organizationalUnit)${w(i,n)})`,s=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:r},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(i){const n=$(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(4!==n)throw i;const s=(await this.server.ldap.search({paged:!1,scope:"sub",filter:r,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${s.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",s.length)]}if(o<=e)return s;const h=o-e;return[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.server.loadedPlugins.static&&this.config.organization_schema?v(this.config,this.config.organization_schema):void 0;return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t})}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t})))}const l=this.getDisplayNameFromAttributes(i);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a)if(s){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:a,newmail:s});try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const i=this.getAliases(e.searchEntries[0][this.aliasAttr]);i.length>0&&await Promise.all([...i.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${e}`,"DELETE",t,null,{oldmail:a,alias:e})),...i.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e}))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,i,a)=>{const s=i.map(t=>this.normalizeAlias(t)),r=a.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...l.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"}))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a}),onLdapForwardChange:async(t,e,i,a)=>{const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=i.filter(t=>!a.includes(t)),n=a.filter(t=>!i.includes(t));await Promise.all([...r.map(i=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...n.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"}))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!r(e,i))return;const s=e.params.user;try{const e=a(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${s} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${s} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${s}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(t,e,i){const a=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,i));this.identityQueue.set(e,a);try{await a}finally{this.identityQueue.get(e)===a&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};try{const r=`${this.webadminUrl}/users/${i}/identities`,n=await this.requestLimit(()=>t(`${r}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(n.ok){const t=await n.json();l=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==n.status)return void this.logger.error({...s,step:"get_default_identity",http_status:n.status,http_status_text:n.statusText});const o=await this.generateSignature(e),m=l?.id?{id:l.id,email:l.email,name:a}:{email:i,name:a,sortOrder:0};o&&(m.htmlSignature=o);const d=l?.id?"update_identity":"create_identity",h=await this.requestLimit(()=>t(l?.id?`${r}/${l.id}`:r,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));h.ok?this.logger.info({...s,step:d,result:"success",http_status:h.status}):this.logger.error({...s,step:d,http_status:h.status,http_status_text:h.statusText})}catch(t){this.logger.error({...s,error:`${t}`})}}async deleteUserData(e,i){const a={plugin:this.name,event:"deleteUserData",mail:e,fromStep:i};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),i&&s.searchParams.set("fromStep",i);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...a,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...a,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...a,error:`${t}`}),null}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r),l=[];l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i}));for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>!s(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(t=>!l.includes(t)),d=l.filter(t=>!o.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,c]=await Promise.all([Promise.all(h),Promise.all(u)]),p=[];for(const{dn:e,email:i}of g)i&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"}));for(const{dn:e,email:i}of c)i&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"DELETE",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"remove"}));await Promise.all(p)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
|
|
1
|
+
import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as a,escapeLdapFilter as i,isDummyMemberDn as s,normalizeDn as r}from"../../lib/utils.js";import{wantJson as n}from"../../lib/expressFormatedResponses.js";const o=[400,404],members=t=>{const e=t?.member;return void 0===e?[]:(Array.isArray(e)?e:[e]).map(String)},memberKey=t=>{try{return r(t)}catch{return t.toLowerCase()}},l=["groupofnames","groupofuniquenames","groupofurls"],isGroup=t=>(Array.isArray(t)?t:t?[t]:[]).some(t=>l.includes(String(t).toLowerCase()));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,a]=t,i=a[this.mailAttr],s=a[this.quotaAttr],r=a[this.aliasAttr];if(!i||isGroup(a.objectClass))return;const n=Array.isArray(i)?String(i[0]):String(i);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},o)}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},o)))}const l=this.getDisplayNameFromAttributes(a);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,a)=>{const i=e?Array.isArray(e)?String(e[0]):String(e):null,s=a?Array.isArray(a)?String(a[0]):String(a):null;if(i)if(s){if(!await this.isGroupDn(t)){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${i}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:i,newmail:s},o);try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const a=this.getAliases(e.searchEntries[0][this.aliasAttr]);a.length>0&&await Promise.all([...a.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${i}/sources/${e}`,"DELETE",t,null,{oldmail:i,alias:e})),...a.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e},o))])}}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,a,i)=>{if(await this.isGroupDn(t))return;const s=a.map(t=>this.normalizeAlias(t)),r=i.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(a=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"DELETE",t,null,{mail:e,alias:a,action:"delete"})),...l.map(a=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${a}`,"PUT",t,null,{mail:e,alias:a,action:"add"},o))])},onLdapQuotaChange:async(t,e,a,i)=>{if(!await this.isGroupDn(t))return this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,i.toString(),{oldQuota:a,newQuota:i},o)},onLdapForwardChange:async(t,e,a,i)=>{if(await this.isGroupDn(t))return;const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=a.filter(t=>!i.includes(t)),n=i.filter(t=>!a.includes(t));await Promise.all([...r.map(a=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"DELETE",t,null,{mail:e,forward:a,domain:s,action:"delete"})),...n.map(a=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${a}`,"PUT",t,null,{mail:e,forward:a,domain:s,action:"add"},o))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&!await this.isGroupDn(t)&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn","objectClass"],a=await this.ldapGetAttributes(t,e);if(!a)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);if(isGroup(a.objectClass))return;const i=this.attributeToString(a[this.mailAttr]);if(!i)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(a);if(s)return this.updateJamesIdentity(t,i,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},onLdapEntryChange:(t,e,a)=>this.followTeamMailbox(e,a)},this.teamMailboxQueue=new Map,this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute,this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3,this.retryAttempts="number"==typeof this.config.james_retry_attempts?Math.max(1,this.config.james_retry_attempts):5,this.retryDelay="number"==typeof this.config.james_retry_delay?Math.max(0,this.config.james_retry_delay):1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,a((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,a){if(!n(e,a))return;const s=e.params.user;try{const e=i(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 a.status(404).json({error:`User ${s} not found`});const o=n[this.mailAttr];if(!o)return void a.status(400).json({error:`User ${s} has no mail attribute`});const l=Array.isArray(o)?String(o[0]):String(o),u=`${this.webadminUrl}/quota/users/${l}`,m=await this.requestLimit(()=>t(u,{method:"GET",headers:this.createHeaders()}));if(!m.ok)return void(404===m.status?a.status(404).json({error:`Quota not found for user ${s}`}):a.status(502).json({error:`Failed to retrieve quota from James: ${m.statusText}`}));const d=await m.json();a.json(d)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),a.status(500).json({error:"Internal server error while retrieving quota"})}}teamMailboxOf(t){if(!t||"teamMailbox"!==this.getMailboxType(t))return null;const e=this.attributeToString(t.mail);if(!e)return null;const a=String(t.dn);return{mail:e,refused:(this.config.james_mailing_list_branch||[]).some(t=>a===t||a.endsWith(","+t))}}async followTeamMailbox(t,e){const a=this.teamMailboxOf(t),i=this.teamMailboxOf(e);if(!a&&!i)return;const s=String((e??t).dn);i?.refused&&!a?.refused&&this.logger.error({plugin:this.name,event:"teamMailbox",dn:s,error:`Team mailbox cannot be in mailing list branches: ${(this.config.james_mailing_list_branch||[]).join(", ")}`});const r=a&&!a.refused?a.mail:null,n=i&&!i.refused?i.mail:null;if(!r&&!n)return;const o=memberKey(s),follow=async()=>{if(r&&n&&r.toLowerCase()===n.toLowerCase()){const a=new Map(members(t).map(t=>[memberKey(t),t])),i=[];for(const t of members(e))a.delete(memberKey(t))||i.push(t);return void await Promise.all([this.teamMailboxMembers(s,n,"PUT",i),this.teamMailboxMembers(s,n,"DELETE",[...a.values()])])}r&&await this.teamMailboxMembers(s,r,"DELETE",members(t)),n&&await this.createTeamMailbox(s,e,n)},l=(this.teamMailboxQueue.get(o)??Promise.resolve()).then(follow,follow);this.teamMailboxQueue.set(o,l);try{await l}finally{this.teamMailboxQueue.get(o)===l&&this.teamMailboxQueue.delete(o)}}async teamMailboxMembers(t,e,a,i){const s=this.extractMailDomain(e);if(!s||!i.length)return;const r=await this.getMemberEmails(i);await Promise.all(r.map(i=>this.callWebAdminApi("teamMailbox:"+("PUT"===a?"addMember":"removeMember"),`${this.webadminUrl}/domains/${s}/team-mailboxes/${e}/members/${i}`,a,t,null,{domain:s,teamMailbox:e,memberMail:i})))}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const a=this.attributeToString(t.cn);if(a)return a;const i=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(i||s){const t=[];return i&&t.push(i),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async isGroupDn(t){const e=await this.ldapGetAttributes(t,["objectClass"]);return isGroup(e?.objectClass)}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],a=await this.ldapGetAttributes(t,e);return a?this.getDisplayNameFromAttributes(a):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const a=await this.ldapGetAttributes(t);if(!a)return null;let i=e;return i=i.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&a&&"object"==typeof a&&Object.prototype.hasOwnProperty.call(a,e)&&this.attributeToString(a[e])||""),i}async updateJamesIdentity(t,e,a){const i=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,a));this.identityQueue.set(e,i);try{await i}finally{this.identityQueue.get(e)===i&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,a,i){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:a,displayName:i};for(let r=1;;r++){try{const n=`${this.webadminUrl}/users/${a}/identities`,l=await this.requestLimit(()=>t(`${n}?default=true`,{method:"GET",headers:this.createHeaders()}));let u;if(l.ok){const t=await l.json();u=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==l.status){const t="get_default_identity";if(this.shouldRetry(l.status,o,r)){await this.warnAndWait(s,t,r,l);continue}return void this.logger.error({...s,step:t,...r>1?{attempts:r}:{},http_status:l.status,http_status_text:l.statusText})}const m=await this.generateSignature(e),d=u?.id?{id:u.id,email:u.email,name:i}:{email:a,name:i,sortOrder:0};m&&(d.htmlSignature=m);const h=u?.id?"update_identity":"create_identity",c=await this.requestLimit(()=>t(u?.id?`${n}/${u.id}`:n,{method:u?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(d)}));if(c.ok)this.logger.info({...s,step:h,result:"success",http_status:c.status});else{if(this.shouldRetry(c.status,o,r)){await this.warnAndWait(s,h,r,c);continue}this.logger.error({...s,step:h,...r>1?{attempts:r}:{},http_status:c.status,http_status_text:c.statusText})}}catch(t){this.logger.error({...s,error:`${t}`})}return}}async warnAndWait(t,e,a,i){this.logger.warn({...t,step:e,result:"retry",attempt:a,http_status:i.status,http_status_text:i.statusText}),await this.waitBeforeRetry(a)}async deleteUserData(e,a){const i={plugin:this.name,event:"deleteUserData",mail:e,fromStep:a};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),a&&s.searchParams.set("fromStep",a);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...i,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...i,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...i,error:`${t}`}),null}}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const a=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!a)return null;const i=a[1];return"group"===i||"mailingList"===i||"teamMailbox"===i?i:null}async createTeamMailbox(t,e,a){const i=this.extractMailDomain(a);i?(await this.callWebAdminApi("teamMailbox:create",`${this.webadminUrl}/domains/${i}/team-mailboxes/${a}`,"PUT",t,null,{domain:i,teamMailbox:a}),await this.teamMailboxMembers(t,a,"PUT",members(e))):this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:a,error:"Cannot extract domain from mail address"})}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 a=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${a}`)}return null}async _handleDelegationChange(t,e){const a=await this.ldapGetAttributes(t,[this.mailAttr]);if(!a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const i=a[this.mailAttr];if(!i||"string"!=typeof i)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),u=this._normalizeToArray(n),m=u.filter(t=>!l.includes(t)),d=l.filter(t=>!u.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),c=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,p]=await Promise.all([Promise.all(h),Promise.all(c)]),b=[];for(const{dn:e,email:a}of g)a&&b.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"PUT",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"add"},o));for(const{dn:e,email:a}of p)a&&b.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${i}/authorizedUsers/${a}`,"DELETE",t,null,{userMail:i,delegateEmail:a,delegateDN:e,action:"remove"}));await Promise.all(b)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
|
|
@@ -239,6 +239,15 @@ export default abstract class LdapFlat extends DmPlugin {
|
|
|
239
239
|
* @returns lowercased attribute names
|
|
240
240
|
*/
|
|
241
241
|
protected hiddenAttributes(): Set<string>;
|
|
242
|
+
/**
|
|
243
|
+
* Attributes to ask for when reading one entry. `*` brings the user
|
|
244
|
+
* attributes only: an operational one the schema declares, such as
|
|
245
|
+
* `memberOf`, comes back only when named. A name the directory does not
|
|
246
|
+
* define is ignored by it.
|
|
247
|
+
*
|
|
248
|
+
* @returns attribute list
|
|
249
|
+
*/
|
|
250
|
+
protected entryAttributes(): string[];
|
|
242
251
|
/**
|
|
243
252
|
* Remove the `neverReturn` attributes from an entry about to be serialised.
|
|
244
253
|
* LDAP attribute names are case-insensitive and may carry options
|
|
@@ -294,17 +303,6 @@ export default abstract class LdapFlat extends DmPlugin {
|
|
|
294
303
|
* @returns true when the DN is taken
|
|
295
304
|
*/
|
|
296
305
|
protected rdnExists(value: string): Promise<boolean>;
|
|
297
|
-
/**
|
|
298
|
-
* Build the rejection message for a value that failed its schema `test`.
|
|
299
|
-
*
|
|
300
|
-
* When the schema carries a `hint`, the message says what a valid value
|
|
301
|
-
* looks like instead of only that this one was not. The hint lives next to
|
|
302
|
-
* the pattern so the two cannot drift apart, and so a client can show it
|
|
303
|
-
* under the field *before* the user gets it wrong.
|
|
304
|
-
*
|
|
305
|
-
* @param field attribute name
|
|
306
|
-
* @returns message for a `BadRequestError`
|
|
307
|
-
*/
|
|
308
306
|
protected invalidValueMessage(field: string): string;
|
|
309
307
|
/**
|
|
310
308
|
* API routes
|
|
@@ -12,6 +12,8 @@ export declare abstract class TwakePlugin extends DmPlugin {
|
|
|
12
12
|
protected webadminUrl: string;
|
|
13
13
|
protected webadminToken: string;
|
|
14
14
|
protected requestLimit: ReturnType<typeof pLimit>;
|
|
15
|
+
protected retryAttempts: number;
|
|
16
|
+
protected retryDelay: number;
|
|
15
17
|
protected mailAttr: string;
|
|
16
18
|
protected displayNameAttr: string;
|
|
17
19
|
/**
|
|
@@ -39,8 +41,23 @@ export declare abstract class TwakePlugin extends DmPlugin {
|
|
|
39
41
|
* @param dn LDAP DN for logging
|
|
40
42
|
* @param body Request body (string or null)
|
|
41
43
|
* @param fields Additional fields for logging
|
|
44
|
+
* @param retryOn HTTP statuses worth another attempt, up to
|
|
45
|
+
* `retryAttempts` in all; none by default
|
|
42
46
|
*/
|
|
43
|
-
protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object): Promise<void>;
|
|
47
|
+
protected callWebAdminApi(hookname: string, url: string, method: string, dn: string, body: string | null, fields: object, retryOn?: readonly number[]): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Whether a failed call is worth another attempt
|
|
50
|
+
* @param statusCode HTTP status code of the failure
|
|
51
|
+
* @param retryOn HTTP statuses the call retries on
|
|
52
|
+
* @param attempt Number of the attempt that failed, from 1
|
|
53
|
+
*/
|
|
54
|
+
protected shouldRetry(statusCode: number, retryOn: readonly number[], attempt: number): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Wait before the attempt following `attempt`: `retryDelay` times the
|
|
57
|
+
* number of attempts made, so that the waits grow linearly
|
|
58
|
+
* @param attempt Number of the attempt that failed, from 1
|
|
59
|
+
*/
|
|
60
|
+
protected waitBeforeRetry(attempt: number): Promise<void>;
|
|
44
61
|
/**
|
|
45
62
|
* Override this method to customize error handling for specific status codes
|
|
46
63
|
* @param statusCode HTTP status code
|
package/dist/src/bin/index.d.ts
CHANGED
|
@@ -61,6 +61,15 @@ export declare class DM {
|
|
|
61
61
|
*/
|
|
62
62
|
private _composed;
|
|
63
63
|
private _errorMiddleware?;
|
|
64
|
+
/**
|
|
65
|
+
* The loads of `--plugin` entries without overrides, keyed by resolved
|
|
66
|
+
* module and name. Regular plugins load in parallel and load their own
|
|
67
|
+
* dependencies, so one module is often asked for twice: in the
|
|
68
|
+
* configuration and by a plugin depending on it, or listed twice. The
|
|
69
|
+
* second request waits for the first rather than building an instance
|
|
70
|
+
* whose name is taken. A Map, so that `withConfig` views share it.
|
|
71
|
+
*/
|
|
72
|
+
private readonly pluginLoads;
|
|
64
73
|
constructor();
|
|
65
74
|
/**
|
|
66
75
|
* The Express router stack, whatever the Express version calls it: 5
|
|
@@ -201,6 +210,7 @@ export declare class DM {
|
|
|
201
210
|
*/
|
|
202
211
|
private resolveModule;
|
|
203
212
|
loadPlugin(pluginName: string): Promise<boolean>;
|
|
213
|
+
private importPlugin;
|
|
204
214
|
/**
|
|
205
215
|
* Refuse a plugin whose arrival makes the authorization composition
|
|
206
216
|
* ambiguous, before any of it is registered.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TypeScript types for LDAP Group Editor
|
|
3
3
|
*/
|
|
4
|
+
import type { LocalizedText } from '../shared/types';
|
|
4
5
|
export interface GroupEditorOptions {
|
|
5
6
|
containerId: string;
|
|
6
7
|
apiBaseUrl?: string;
|
|
@@ -45,7 +46,7 @@ export interface SchemaAttribute {
|
|
|
45
46
|
type: string;
|
|
46
47
|
branch?: string[];
|
|
47
48
|
};
|
|
48
|
-
group?:
|
|
49
|
+
group?: LocalizedText;
|
|
49
50
|
}
|
|
50
51
|
export interface LdapGroup {
|
|
51
52
|
dn: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LDAP Resource Editor - Generic Type Definitions
|
|
3
3
|
*/
|
|
4
|
+
import type { LocalizedText } from '../shared/types';
|
|
4
5
|
export type ResourceType = 'users' | 'groups' | 'organizations';
|
|
5
6
|
export type NavigationType = 'flat' | 'tree';
|
|
6
7
|
export interface EditorOptions {
|
|
@@ -19,13 +20,13 @@ export interface SchemaAttribute {
|
|
|
19
20
|
type: string;
|
|
20
21
|
required?: boolean;
|
|
21
22
|
fixed?: boolean;
|
|
22
|
-
role?: string;
|
|
23
|
+
role?: string | string[];
|
|
23
24
|
test?: string;
|
|
24
25
|
branch?: string[];
|
|
25
26
|
items?: SchemaAttribute;
|
|
26
27
|
default?: unknown;
|
|
27
28
|
ui?: 'select' | 'search';
|
|
28
|
-
group?:
|
|
29
|
+
group?: LocalizedText;
|
|
29
30
|
}
|
|
30
31
|
export interface Schema {
|
|
31
32
|
entity: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TypeScript types for LDAP Unit Editor
|
|
3
3
|
*/
|
|
4
|
+
import type { LocalizedText } from '../shared/types';
|
|
4
5
|
export interface UnitEditorOptions {
|
|
5
6
|
containerId: string;
|
|
6
7
|
apiBaseUrl?: string;
|
|
@@ -45,7 +46,7 @@ export interface SchemaAttribute {
|
|
|
45
46
|
type: string;
|
|
46
47
|
branch?: string[];
|
|
47
48
|
};
|
|
48
|
-
group?:
|
|
49
|
+
group?: LocalizedText;
|
|
49
50
|
}
|
|
50
51
|
export interface LdapUnit {
|
|
51
52
|
dn: string;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LDAP User Editor - Type definitions
|
|
3
3
|
*/
|
|
4
|
+
import type { LocalizedText } from '../shared/types';
|
|
4
5
|
export interface EditorOptions {
|
|
5
6
|
containerId: string;
|
|
6
7
|
apiBaseUrl?: string;
|
|
@@ -15,13 +16,13 @@ export interface SchemaAttribute {
|
|
|
15
16
|
type: string;
|
|
16
17
|
required?: boolean;
|
|
17
18
|
fixed?: boolean;
|
|
18
|
-
role?: string;
|
|
19
|
+
role?: string | string[];
|
|
19
20
|
test?: string;
|
|
20
21
|
branch?: string[];
|
|
21
22
|
items?: SchemaAttribute;
|
|
22
23
|
default?: unknown;
|
|
23
24
|
ui?: 'select' | 'search';
|
|
24
|
-
group?:
|
|
25
|
+
group?: LocalizedText;
|
|
25
26
|
}
|
|
26
27
|
export interface Schema {
|
|
27
28
|
entity: {
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Shared TypeScript types for browser components
|
|
3
3
|
* @module browser/shared/types
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* Text of a schema in one language (a plain string) or several (a map keyed
|
|
7
|
+
* by language tag), see `localizedText`
|
|
8
|
+
*/
|
|
9
|
+
export type LocalizedText = string | Record<string, string>;
|
|
5
10
|
export interface EditorConfig {
|
|
6
11
|
ldapBase?: string;
|
|
7
12
|
features?: {
|
|
@@ -27,13 +32,14 @@ export interface SchemaAttribute {
|
|
|
27
32
|
type: 'string' | 'number' | 'integer' | 'array' | 'pointer';
|
|
28
33
|
required?: boolean;
|
|
29
34
|
fixed?: boolean;
|
|
35
|
+
role?: string | string[];
|
|
30
36
|
default?: unknown;
|
|
31
37
|
branch?: string[];
|
|
32
38
|
items?: {
|
|
33
39
|
type: string;
|
|
34
40
|
branch?: string[];
|
|
35
41
|
};
|
|
36
|
-
group?:
|
|
42
|
+
group?: LocalizedText;
|
|
37
43
|
}
|
|
38
44
|
export interface PointerOption {
|
|
39
45
|
dn: string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Schema utility functions
|
|
3
3
|
* @module browser/shared/utils/schema
|
|
4
4
|
*/
|
|
5
|
-
import type { PointerOption } from '../types';
|
|
5
|
+
import type { LocalizedText, PointerOption, SchemaAttribute } from '../types';
|
|
6
6
|
/**
|
|
7
7
|
* Load pointer options from API
|
|
8
8
|
*/
|
|
@@ -27,3 +27,22 @@ export declare function getOrganizationPath(orgDn: string, apiBaseUrl: string):
|
|
|
27
27
|
* Load schema from URL or object
|
|
28
28
|
*/
|
|
29
29
|
export declare function loadSchema<T = unknown>(schemaSource: string | T): Promise<T>;
|
|
30
|
+
/**
|
|
31
|
+
* Tell whether an attribute carries a role: a schema names one, or several.
|
|
32
|
+
*
|
|
33
|
+
* @param attr attribute definition
|
|
34
|
+
* @param role role to look for
|
|
35
|
+
* @returns true when the attribute declares that role
|
|
36
|
+
*/
|
|
37
|
+
export declare function hasRole(attr: Pick<SchemaAttribute, 'role'> | undefined, role: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Pick the text of a {@link LocalizedText} for a language: the exact tag,
|
|
40
|
+
* then its primary language, then English, then whatever the map holds.
|
|
41
|
+
*
|
|
42
|
+
* The editors are written in English, so English is the default.
|
|
43
|
+
*
|
|
44
|
+
* @param text plain string or map keyed by language tag
|
|
45
|
+
* @param language tag to look for
|
|
46
|
+
* @returns the text, or undefined when there is none
|
|
47
|
+
*/
|
|
48
|
+
export declare function localizedText(text: LocalizedText | undefined, language?: string): string | undefined;
|
|
@@ -166,6 +166,8 @@ export interface Config {
|
|
|
166
166
|
ldap_concurrency?: number;
|
|
167
167
|
james_concurrency?: number;
|
|
168
168
|
james_init_delay?: number;
|
|
169
|
+
james_retry_attempts?: number;
|
|
170
|
+
james_retry_delay?: number;
|
|
169
171
|
james_mailing_list_branch?: string[];
|
|
170
172
|
james_mailbox_type_attribute?: string;
|
|
171
173
|
twake_drive_webadmin_url?: string;
|
|
@@ -49,6 +49,29 @@ export type SchemaRole = string;
|
|
|
49
49
|
* rather than in a catalogue shipped with the code.
|
|
50
50
|
*/
|
|
51
51
|
export type LocalizedText = string | Record<string, string>;
|
|
52
|
+
/**
|
|
53
|
+
* Pick the text of a {@link LocalizedText} for a language: the exact tag,
|
|
54
|
+
* then its primary language, then English, then whatever the map holds.
|
|
55
|
+
*
|
|
56
|
+
* @param text plain string or map keyed by language tag
|
|
57
|
+
* @param language tag to look for, English when omitted
|
|
58
|
+
* @returns the text, or undefined when there is none
|
|
59
|
+
*/
|
|
60
|
+
export declare function localizedText(text: LocalizedText | undefined, language?: string): string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Refusal of a value that failed its schema `test`, for a `BadRequestError`.
|
|
63
|
+
*
|
|
64
|
+
* When the schema carries a `hint`, the message says what a valid value
|
|
65
|
+
* looks like instead of only that this one was not. The hint lives next to
|
|
66
|
+
* the pattern so the two cannot drift apart, and so a client can show it
|
|
67
|
+
* under the field *before* the user gets it wrong. Its English text is
|
|
68
|
+
* quoted, the language of every other API message.
|
|
69
|
+
*
|
|
70
|
+
* @param name attribute name
|
|
71
|
+
* @param attr its schema definition
|
|
72
|
+
* @returns the message
|
|
73
|
+
*/
|
|
74
|
+
export declare function invalidValueMessage(name: string, attr: SchemaAttribute | undefined): string;
|
|
52
75
|
/** Roles the core itself looks for. See {@link SchemaRole}. */
|
|
53
76
|
export declare const CORE_ROLES: readonly ["identifier", "displayName", "primaryEmail", "emailAliases", "emailQuota", "organizationLink", "organizationPath", "members", "owners", "accountStatus", "password", "passwordReset", "accountExpiry", "domainLink", "domainName"];
|
|
54
77
|
/**
|
|
@@ -114,7 +137,7 @@ export interface SchemaAttribute {
|
|
|
114
137
|
items?: {
|
|
115
138
|
type: string;
|
|
116
139
|
test?: string | RegExp;
|
|
117
|
-
hint?:
|
|
140
|
+
hint?: LocalizedText;
|
|
118
141
|
branch?: string[];
|
|
119
142
|
};
|
|
120
143
|
default?: AttributeValue;
|
|
@@ -126,7 +149,7 @@ export interface SchemaAttribute {
|
|
|
126
149
|
* schema so the pattern and its explanation cannot drift apart, and so a
|
|
127
150
|
* client never has to hardcode either.
|
|
128
151
|
*/
|
|
129
|
-
hint?:
|
|
152
|
+
hint?: LocalizedText;
|
|
130
153
|
branch?: string[];
|
|
131
154
|
fixed?: boolean;
|
|
132
155
|
/** Semantic role(s) — see {@link SchemaRole} */
|
|
@@ -209,8 +232,8 @@ export interface SchemaAttribute {
|
|
|
209
232
|
* under `example.org`. Off by default: the domain must match exactly.
|
|
210
233
|
*/
|
|
211
234
|
allowSubdomains?: boolean;
|
|
212
|
-
/**
|
|
213
|
-
group?:
|
|
235
|
+
/** Section of a form the attribute belongs to, e.g. `Mailbox settings` */
|
|
236
|
+
group?: LocalizedText;
|
|
214
237
|
/** Name a client shows for this attribute, in one or several languages */
|
|
215
238
|
label?: LocalizedText;
|
|
216
239
|
}
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -127,7 +127,9 @@ export interface Hooks {
|
|
|
127
127
|
* `onLdapEntryChange` gives the entry as the directory held it before and
|
|
128
128
|
* after an add, a modify, a rename or a delete; the other hooks are derived
|
|
129
129
|
* from it. `before` is null on an add, `after` on a delete, and on a rename
|
|
130
|
-
* their `dn` differ. A write that changed nothing fires none of them.
|
|
130
|
+
* their `dn` differ. A write that changed nothing fires none of them. A
|
|
131
|
+
* delete fires `onLdapEntryChange`, `onLdapChange` and `onLdapMailChange`
|
|
132
|
+
* only, and the hooks given a `mail` fire only while the entry has one.
|
|
131
133
|
*/
|
|
132
134
|
onLdapEntryChange?: (dn: string, before: Entry | null, after: Entry | null, context: ChangeContext) => MaybePromise<void>;
|
|
133
135
|
onLdapChange?: (dn: string, changes: ChangesToNotify) => MaybePromise<void>;
|
|
@@ -231,14 +231,6 @@ export default class LdapOrganizations extends DmPlugin {
|
|
|
231
231
|
addOrganization(dn: string, entry: AttributesList, req?: Request): Promise<boolean>;
|
|
232
232
|
modifyOrganization(dn: string, changes: ModifyRequest, req?: Request): Promise<boolean>;
|
|
233
233
|
validateNewOrganization(dn: string, entry: AttributesList): Promise<boolean>;
|
|
234
|
-
/**
|
|
235
|
-
* Build the rejection message for a value that failed its schema `test`,
|
|
236
|
-
* quoting the `hint` when the schema carries one so the answer says what a
|
|
237
|
-
* valid value looks like.
|
|
238
|
-
*
|
|
239
|
-
* @param field attribute name
|
|
240
|
-
* @returns message for a `BadRequestError`
|
|
241
|
-
*/
|
|
242
234
|
invalidValueMessage(field: string): string;
|
|
243
235
|
validateChanges(dn: string, changes: ModifyRequest): Promise<boolean>;
|
|
244
236
|
_validateOneChange(field: string, value: AttributeValue | null): Promise<boolean>;
|
|
@@ -5,53 +5,6 @@ import type { DM } from '../../bin';
|
|
|
5
5
|
import type { AttributeValue } from '../../lib/ldapActions';
|
|
6
6
|
import { Hooks } from '../../hooks';
|
|
7
7
|
import type { ChangesToNotify } from '../ldap/onChange';
|
|
8
|
-
/**
|
|
9
|
-
* OpenAPI schemas specific to the James integration.
|
|
10
|
-
*
|
|
11
|
-
* @openapi-component
|
|
12
|
-
* JamesQuotaUsage:
|
|
13
|
-
* type: object
|
|
14
|
-
* description: |
|
|
15
|
-
* Quota information for a mailbox user as returned by the James
|
|
16
|
-
* WebAdmin API (`GET /quota/users/{mail}`). Contains separate
|
|
17
|
-
* counters for message count and storage size.
|
|
18
|
-
* properties:
|
|
19
|
-
* count:
|
|
20
|
-
* type: object
|
|
21
|
-
* description: Message-count quota.
|
|
22
|
-
* properties:
|
|
23
|
-
* used:
|
|
24
|
-
* type: integer
|
|
25
|
-
* description: Number of messages currently stored.
|
|
26
|
-
* example: 142
|
|
27
|
-
* max:
|
|
28
|
-
* type: integer
|
|
29
|
-
* nullable: true
|
|
30
|
-
* description: |
|
|
31
|
-
* Maximum number of messages allowed. `null` means unlimited.
|
|
32
|
-
* example: 10000
|
|
33
|
-
* size:
|
|
34
|
-
* type: object
|
|
35
|
-
* description: Storage-size quota (bytes).
|
|
36
|
-
* properties:
|
|
37
|
-
* used:
|
|
38
|
-
* type: integer
|
|
39
|
-
* description: Bytes currently used.
|
|
40
|
-
* example: 524288000
|
|
41
|
-
* max:
|
|
42
|
-
* type: integer
|
|
43
|
-
* nullable: true
|
|
44
|
-
* description: |
|
|
45
|
-
* Maximum bytes allowed. `null` means unlimited.
|
|
46
|
-
* example: 2147483648
|
|
47
|
-
* example:
|
|
48
|
-
* count:
|
|
49
|
-
* used: 142
|
|
50
|
-
* max: 10000
|
|
51
|
-
* size:
|
|
52
|
-
* used: 524288000
|
|
53
|
-
* max: 2147483648
|
|
54
|
-
*/
|
|
55
8
|
export default class James extends TwakePlugin {
|
|
56
9
|
name: string;
|
|
57
10
|
roles: Role[];
|
|
@@ -74,14 +27,23 @@ export default class James extends TwakePlugin {
|
|
|
74
27
|
private getUserQuota;
|
|
75
28
|
hooks: Hooks;
|
|
76
29
|
/**
|
|
77
|
-
*
|
|
30
|
+
* The address of the team mailbox an entry stands for, and whether it is
|
|
31
|
+
* refused for lying in a mailing list branch.
|
|
78
32
|
*/
|
|
79
|
-
private
|
|
33
|
+
private teamMailboxOf;
|
|
34
|
+
/**
|
|
35
|
+
* Bring James from the team mailbox an entry was to the one it is. A
|
|
36
|
+
* mailbox the entry no longer stands for is kept, emptied of its members.
|
|
37
|
+
*/
|
|
38
|
+
private followTeamMailbox;
|
|
39
|
+
private teamMailboxQueue;
|
|
40
|
+
private teamMailboxMembers;
|
|
80
41
|
/**
|
|
81
42
|
* Extract display name from LDAP attributes
|
|
82
43
|
* Fallback logic: displayName → cn → givenName+sn → mail
|
|
83
44
|
*/
|
|
84
45
|
private getDisplayNameFromAttributes;
|
|
46
|
+
private isGroupDn;
|
|
85
47
|
getMailFromDN(dn: string): Promise<string | null>;
|
|
86
48
|
getDisplayNameFromDN(dn: string): Promise<string | null>;
|
|
87
49
|
generateSignature(dn: string): Promise<string | null>;
|
|
@@ -104,6 +66,7 @@ export default class James extends TwakePlugin {
|
|
|
104
66
|
updateJamesIdentity(dn: string, mail: string, displayName: string): Promise<void>;
|
|
105
67
|
private identityQueue;
|
|
106
68
|
private doUpdateJamesIdentity;
|
|
69
|
+
private warnAndWait;
|
|
107
70
|
/**
|
|
108
71
|
* Delete user data via James WebAdmin API
|
|
109
72
|
* Calls POST /users/{mail}?action=deleteData
|
|
@@ -112,40 +75,15 @@ export default class James extends TwakePlugin {
|
|
|
112
75
|
deleteUserData(mail: string, fromStep?: string): Promise<{
|
|
113
76
|
taskId: string;
|
|
114
77
|
} | null>;
|
|
115
|
-
/**
|
|
116
|
-
* Type guard to check if delete operation is an AttributesList
|
|
117
|
-
*/
|
|
118
|
-
private isAttributesList;
|
|
119
78
|
/**
|
|
120
79
|
* Extract mailbox type from group attributes
|
|
121
80
|
* Returns: 'group', 'mailingList', 'teamMailbox', or null if not set
|
|
122
81
|
*/
|
|
123
82
|
private getMailboxType;
|
|
124
|
-
/**
|
|
125
|
-
* Validate that a DN is within one of the allowed branches
|
|
126
|
-
*/
|
|
127
|
-
private isInAllowedBranches;
|
|
128
|
-
/**
|
|
129
|
-
* Create a mailing list in James
|
|
130
|
-
*/
|
|
131
|
-
private createMailingList;
|
|
132
83
|
/**
|
|
133
84
|
* Create a team mailbox in James
|
|
134
85
|
*/
|
|
135
86
|
private createTeamMailbox;
|
|
136
|
-
/**
|
|
137
|
-
* Delete a team mailbox from James
|
|
138
|
-
*/
|
|
139
|
-
private deleteTeamMailbox;
|
|
140
|
-
/**
|
|
141
|
-
* Remove all members from a team mailbox (without deleting it)
|
|
142
|
-
* This preserves the mailbox for potential future use
|
|
143
|
-
*/
|
|
144
|
-
private removeAllTeamMailboxMembers;
|
|
145
|
-
/**
|
|
146
|
-
* Delete a mailing list from James
|
|
147
|
-
*/
|
|
148
|
-
private deleteMailingList;
|
|
149
87
|
/**
|
|
150
88
|
* Get email addresses for a list of member DNs
|
|
151
89
|
* Uses p-limit to parallelize LDAP queries while limiting concurrency
|
|
@@ -875,7 +875,7 @@ Ensure all required fields are included:
|
|
|
875
875
|
|
|
876
876
|
```json
|
|
877
877
|
{
|
|
878
|
-
"error": "Invalid value for attribute \"mail\":
|
|
878
|
+
"error": "Invalid value for attribute \"mail\": Expected an email address, e.g. jane.doe@example.org"
|
|
879
879
|
}
|
|
880
880
|
```
|
|
881
881
|
|