ldap-rest 0.16.2 → 0.17.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.
@@ -1 +1 @@
1
- import e from"fs";import t from"path";import{SCHEMA_SERVICE_PROVIDER_CONFIG as s,SCHEMA_USER as o,SCHEMA_RESOURCE_TYPE as i,SCHEMA_GROUP as r,SCHEMA_SCHEMA as n}from"./types.js";class ScimDiscovery{constructor(e){this.config=e.config,this.schemaDir=e.schemaDir,this.scimPrefix=e.scimPrefix,this.loadedPlugins=e.loadedPlugins}baseUrl(e){const t=this.config.scim_base_url||"";return t?t.replace(/\/$/,""):e?.protocol&&e.get?`${e.protocol}://${String(e.get("host")||"")}`:""}location(e,t){return`${this.baseUrl(e)}${this.scimPrefix}${t}`}authSchemes(){const e=[];return this.loadedPlugins.authToken&&e.push({name:"OAuth Bearer Token",description:'Bearer token configured via --auth-token. Send "Authorization: Bearer <token>".',type:"oauthbearertoken",primary:0===e.length}),this.loadedPlugins.openidconnect&&e.push({name:"OpenID Connect",description:"OIDC-issued access token.",type:"oauth2",specUri:"https://openid.net/specs/openid-connect-core-1_0.html",primary:0===e.length}),this.loadedPlugins.authHmac&&e.push({name:"HMAC",description:"Signed request via HMAC-SHA256.",type:"httpbasic",primary:0===e.length}),0===e.length&&e.push({name:"No authentication",description:"No authentication plugin loaded. NOT RECOMMENDED for production.",type:"httpbasic",primary:!0}),e}serviceProviderConfig(e){return{schemas:[s],patch:{supported:!0},bulk:{supported:!0,maxOperations:this.config.scim_bulk_max_operations||100,maxPayloadSize:this.config.scim_bulk_max_payload_size||1048576},filter:{supported:!0,maxResults:this.config.scim_max_results||200},changePassword:{supported:!1},sort:{supported:!1},etag:{supported:Boolean(this.config.scim_etag)},authenticationSchemes:this.authSchemes(),meta:{resourceType:"ServiceProviderConfig",location:this.location(e,"/ServiceProviderConfig")}}}resourceTypes(e){return[{schemas:[i],id:"User",name:"User",endpoint:"/Users",description:"User Account",schema:o,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/User")}},{schemas:[i],id:"Group",name:"Group",endpoint:"/Groups",description:"Group",schema:r,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/Group")}}]}resourceType(e,t){return this.resourceTypes(t).find(t=>t.id===e)}schemas(e){const t=[this.loadSchema("User.json",o),this.loadSchema("Group.json",r)].filter(e=>null!=e);for(const s of t)s.meta={resourceType:"Schema",location:this.location(e,`/Schemas/${s.id}`)};return t}schema(e,t){return this.schemas(t).find(t=>t.id===e)}loadSchema(s,o){const i=t.join(this.schemaDir,s);try{const t=e.readFileSync(i,"utf8"),s=JSON.parse(t);return{schemas:[n],id:o,name:s.name||o.split(":").pop()||o,description:s.description,attributes:s.attributes||[]}}catch{return}}}export{ScimDiscovery};
1
+ import e from"path";import{loadSchemaFile as t}from"../../lib/schemaFile.js";import{SCHEMA_SERVICE_PROVIDER_CONFIG as s,SCHEMA_USER as i,SCHEMA_RESOURCE_TYPE as o,SCHEMA_GROUP as r,SCHEMA_SCHEMA as c}from"./types.js";class ScimDiscovery{constructor(e){this.config=e.config,this.schemaDir=e.schemaDir,this.scimPrefix=e.scimPrefix,this.loadedPlugins=e.loadedPlugins}baseUrl(e){const t=this.config.scim_base_url||"";return t?t.replace(/\/$/,""):e?.protocol&&e.get?`${e.protocol}://${String(e.get("host")||"")}`:""}location(e,t){return`${this.baseUrl(e)}${this.scimPrefix}${t}`}authSchemes(){const e=[];return this.loadedPlugins.authToken&&e.push({name:"OAuth Bearer Token",description:'Bearer token configured via --auth-token. Send "Authorization: Bearer <token>".',type:"oauthbearertoken",primary:0===e.length}),this.loadedPlugins.openidconnect&&e.push({name:"OpenID Connect",description:"OIDC-issued access token.",type:"oauth2",specUri:"https://openid.net/specs/openid-connect-core-1_0.html",primary:0===e.length}),this.loadedPlugins.authHmac&&e.push({name:"HMAC",description:"Signed request via HMAC-SHA256.",type:"httpbasic",primary:0===e.length}),0===e.length&&e.push({name:"No authentication",description:"No authentication plugin loaded. NOT RECOMMENDED for production.",type:"httpbasic",primary:!0}),e}serviceProviderConfig(e){return{schemas:[s],patch:{supported:!0},bulk:{supported:!0,maxOperations:this.config.scim_bulk_max_operations||100,maxPayloadSize:this.config.scim_bulk_max_payload_size||1048576},filter:{supported:!0,maxResults:this.config.scim_max_results||200},changePassword:{supported:!1},sort:{supported:!1},etag:{supported:Boolean(this.config.scim_etag)},authenticationSchemes:this.authSchemes(),meta:{resourceType:"ServiceProviderConfig",location:this.location(e,"/ServiceProviderConfig")}}}resourceTypes(e){return[{schemas:[o],id:"User",name:"User",endpoint:"/Users",description:"User Account",schema:i,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/User")}},{schemas:[o],id:"Group",name:"Group",endpoint:"/Groups",description:"Group",schema:r,meta:{resourceType:"ResourceType",location:this.location(e,"/ResourceTypes/Group")}}]}resourceType(e,t){return this.resourceTypes(t).find(t=>t.id===e)}schemas(e){const t=[this.loadSchema("User.json",i),this.loadSchema("Group.json",r)].filter(e=>null!=e);for(const s of t)s.meta={resourceType:"Schema",location:this.location(e,`/Schemas/${s.id}`)};return t}schema(e,t){return this.schemas(t).find(t=>t.id===e)}loadSchema(s,i){const o=e.join(this.schemaDir,s);try{const e=t(o,{config:this.config});return{schemas:[c],id:i,name:e.name||i.split(":").pop()||i,description:e.description,attributes:e.attributes||[]}}catch{return}}}export{ScimDiscovery};
@@ -1 +1 @@
1
- import{HttpError as r,AUTHZ_REFUSED as t}from"../../lib/errors.js";import{extractLdapCode as e}from"../../lib/ldapCodes.js";import{SCHEMA_ERROR as i}from"./types.js";const o="application/scim+json";class ScimError extends r{constructor(r,t,e){super(t,r),this.name="ScimError",this.scimType=e}}const scimInvalidFilter=(r="Invalid filter")=>new ScimError(400,r,"invalidFilter"),scimInvalidPath=(r="Invalid path")=>new ScimError(400,r,"invalidPath"),scimInvalidValue=(r="Invalid value")=>new ScimError(400,r,"invalidValue"),scimInvalidSyntax=(r="Invalid syntax")=>new ScimError(400,r,"invalidSyntax"),scimNoTarget=(r="No target")=>new ScimError(400,r,"noTarget"),scimMutability=(r="Immutable attribute")=>new ScimError(400,r,"mutability"),scimUniqueness=(r="Uniqueness violation")=>new ScimError(409,r,"uniqueness"),scimTooMany=(r="Too many results")=>new ScimError(400,r,"tooMany"),scimNotFound=(r="Resource not found")=>new ScimError(404,r);function writeScimError(r,t,e,n){const s={schemas:[i],status:String(t),...n?{scimType:n}:{},detail:e};r.status(t).type(o).json(s)}function scimErrorFromException(o,n=500){const sanitize=r=>/\[authz-forbidden\]/.test(r)?t:r,body=(r,t,e)=>({schemas:[i],status:String(r),...e?{scimType:e}:{},detail:t});if(o instanceof ScimError)return body(o.statusCode,sanitize(o.message),o.scimType);if(o instanceof r)return body(o.statusCode,sanitize(o.message));const s=o instanceof Error?o.message:String(o);if(/\[authz-forbidden\]/.test(s))return body(403,t);const c=e(o);return 32===c?body(404,"Resource not found"):68===c?body(409,sanitize(s),"uniqueness"):17===c||65===c?body(400,`The directory rejected this write against its schema: ${sanitize(s)}`,"invalidValue"):body(n,sanitize(s)||"Internal error")}function writeScimErrorFromException(r,t,e=500){const i=scimErrorFromException(t,e);r.status(Number(i.status)).type(o).json(i)}const scimAsyncHandler=r=>(t,e,i)=>{Promise.resolve().then(()=>r(t,e,i)).catch(r=>{e.headersSent?i(r):writeScimErrorFromException(e,r)})};export{o as SCIM_CONTENT_TYPE,ScimError,e as extractLdapCode,scimAsyncHandler,scimErrorFromException,scimInvalidFilter,scimInvalidPath,scimInvalidSyntax,scimInvalidValue,scimMutability,scimNoTarget,scimNotFound,scimTooMany,scimUniqueness,writeScimError,writeScimErrorFromException};
1
+ import{HttpError as r,AUTHZ_REFUSED as t}from"../../lib/errors.js";import{isSchemaRefusal as e,extractLdapCode as i}from"../../lib/ldapCodes.js";import{SCHEMA_ERROR as o}from"./types.js";const n="application/scim+json";class ScimError extends r{constructor(r,t,e){super(t,r),this.name="ScimError",this.scimType=e}}const scimInvalidFilter=(r="Invalid filter")=>new ScimError(400,r,"invalidFilter"),scimInvalidPath=(r="Invalid path")=>new ScimError(400,r,"invalidPath"),scimInvalidValue=(r="Invalid value")=>new ScimError(400,r,"invalidValue"),scimInvalidSyntax=(r="Invalid syntax")=>new ScimError(400,r,"invalidSyntax"),scimNoTarget=(r="No target")=>new ScimError(400,r,"noTarget"),scimMutability=(r="Immutable attribute")=>new ScimError(400,r,"mutability"),scimUniqueness=(r="Uniqueness violation")=>new ScimError(409,r,"uniqueness"),scimTooMany=(r="Too many results")=>new ScimError(400,r,"tooMany"),scimNotFound=(r="Resource not found")=>new ScimError(404,r);function writeScimError(r,t,e,i){const s={schemas:[o],status:String(t),...i?{scimType:i}:{},detail:e};r.status(t).type(n).json(s)}function scimErrorFromException(n,s=500){const sanitize=r=>/\[authz-forbidden\]/.test(r)?t:r,body=(r,t,e)=>({schemas:[o],status:String(r),...e?{scimType:e}:{},detail:t});if(n instanceof ScimError)return body(n.statusCode,sanitize(n.message),n.scimType);if(n instanceof r){const r=400===n.statusCode&&e(i(n))?"invalidValue":void 0;return body(n.statusCode,sanitize(n.message),r)}const c=n instanceof Error?n.message:String(n);if(/\[authz-forbidden\]/.test(c))return body(403,t);const a=i(n);return 32===a?body(404,"Resource not found"):68===a?body(409,sanitize(c),"uniqueness"):e(a)?body(400,`The directory rejected this write against its schema: ${sanitize(c)}`,"invalidValue"):body(s,sanitize(c)||"Internal error")}function writeScimErrorFromException(r,t,e=500){const i=scimErrorFromException(t,e);r.status(Number(i.status)).type(n).json(i)}const scimAsyncHandler=r=>(t,e,i)=>{Promise.resolve().then(()=>r(t,e,i)).catch(r=>{e.headersSent?i(r):writeScimErrorFromException(e,r)})};export{n as SCIM_CONTENT_TYPE,ScimError,i as extractLdapCode,scimAsyncHandler,scimErrorFromException,scimInvalidFilter,scimInvalidPath,scimInvalidSyntax,scimInvalidValue,scimMutability,scimNoTarget,scimNotFound,scimTooMany,scimUniqueness,writeScimError,writeScimErrorFromException};
@@ -1 +1 @@
1
- import s from"fs";import{resolve as t,join as e}from"path";import a from"express";import i from"../abstract/plugin.js";import{notFound as n}from"../lib/expressFormatedResponses.js";import{transformSchemas as r}from"../lib/utils.js";class Static extends i{constructor(){super(...arguments),this.name="static",this.roles=["api","configurable"]}api(i){const c=this.config.static_path;if(!c)throw new Error("--static-path is not defined");try{if(!s.statSync(c).isDirectory())throw new Error(`${c} isn't a directory`);s.accessSync(c,s.constants.R_OK)}catch(s){throw new Error(`Bad directory ${c}: ${s}`)}i.get(`/${this.config.static_name}/schemas/:name`,(a,i)=>{if(!/^[\w-]+\.json$/.test(a.params.name))return i.status(400).send("Invalid schema name");const o=t(e(c,"schemas",a.params.name)),m=t(e(c,"schemas"));if(!o.startsWith(m+"/"))return i.status(403).send("Access denied");s.readFile(o,(s,t)=>{if(s)return n(i,"Schema not found");const e=r(t,this.config);i.type("json").send(e)})}),i.get(`/${this.config.static_name}/schemas/:dir/:name`,(a,i)=>{if(!/^[\w-]+$/.test(a.params.dir)||!/^[\w-]+\.json$/.test(a.params.name))return i.status(400).send("Invalid schema name");const o=t(e(c,"schemas",a.params.dir,a.params.name)),m=t(e(c,"schemas"));if(!o.startsWith(m+"/"))return i.status(403).send("Access denied");s.readFile(o,(s,t)=>{if(s)return n(i,"Schema not found");const e=r(t,this.config);i.type("json").send(e)})}),i.use(`/${this.config.static_name}`,a.static(c))}getConfigApiData(){const s=`/${this.config.static_name||"static"}`;return{enabled:!0,staticPath:s,endpoints:{schema:`${s}/schemas/:name`,schemaInSubdir:`${s}/schemas/:dir/:name`,files:`${s}/*`}}}}export{Static as default};
1
+ import t from"fs";import{resolve as s,join as e}from"path";import n from"express";import i from"../abstract/plugin.js";import{notFound as a,serverError as r}from"../lib/expressFormatedResponses.js";import{configuredSchemaFiles as c,schemaUrl as o,loadSchemaFileAsync as h}from"../lib/schemaFile.js";class Static extends i{constructor(){super(...arguments),this.name="static",this.roles=["api","configurable"]}api(s){const e=this.config.static_path;if(!e)throw new Error("--static-path is not defined");try{if(!t.statSync(e).isDirectory())throw new Error(`${e} isn't a directory`);t.accessSync(e,t.constants.R_OK)}catch(t){throw new Error(`Bad directory ${e}: ${t}`)}const i=this.schemaServer(e);s.get(`/${this.config.static_name}/schemas/:name`,(t,s,e)=>i([t.params.name],t,s,e)),s.get(`/${this.config.static_name}/schemas/:dir/:name`,(t,s,e)=>i([t.params.dir,t.params.name],t,s,e)),s.get(`/${this.config.static_name}/schemas/*path`,(t,s,e)=>i(t.params.path,t,s,e)),s.use(`/${this.config.static_name}`,n.static(e))}schemaServer(t){const n=this.configuredSchemas(t),i=s(e(t,"schemas"));return(t,a,r,c)=>{const o=n.get((t=>{try{return decodeURIComponent(t)}catch{return t}})(a.path));if(o)return this.sendSchema(o,r);if(!t[t.length-1].endsWith(".json"))return c();if(!t.every(t=>/^[\w-]+(\.[\w-]+)*$/.test(t)))return void r.status(400).send("Invalid schema name");const h=s(e(i,...t));h.startsWith(i+"/")?this.sendSchema(h,r):r.status(403).send("Access denied")}}configuredSchemas(t){const n=new Map;for(const i of c(this.config)){const a=o(this.config,i);if(!a)continue;const r=e(t,a.substring(a.indexOf("/schemas/")));if(s(r)===s(i))continue;const c=n.get(a);c&&s(c)!==s(i)?this.logger.warn(`${i} is not served: ${c} already has its URL ${a}`):n.set(a,i)}return n}sendSchema(t,s){h(t,{config:this.config}).then(t=>s.type("json").send(JSON.stringify(t)),t=>{if(t.code)return a(s,"Schema not found");r(s,t)})}getConfigApiData(){const t=`/${this.config.static_name||"static"}`;return{enabled:!0,staticPath:t,endpoints:{schema:`${t}/schemas/:name`,schemaInSubdir:`${t}/schemas/:dir/:name`,files:`${t}/*`}}}}export{Static as default};
@@ -1 +1 @@
1
- import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t})}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t})))}const l=this.getDisplayNameFromAttributes(i);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a)if(s){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename&force`,"POST",t,null,{oldmail:a,newmail:s});try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},t);if(e.searchEntries&&e.searchEntries.length>0){const i=this.getAliases(e.searchEntries[0][this.aliasAttr]);i.length>0&&await Promise.all([...i.map(e=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${e}`,"DELETE",t,null,{oldmail:a,alias:e})),...i.map(e=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${e}`,"PUT",t,null,{newmail:s,alias:e}))])}}catch(t){this.logger.debug("Could not fetch aliases for mail change:",t)}}else this.logger.debug(`Skipping mail rename for ${t}: newmail is empty (mail attribute was deleted, not changed)`);else this.logger.debug(`Skipping mail rename for ${t}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(t,e,i,a)=>{const s=i.map(t=>this.normalizeAlias(t)),r=a.map(t=>this.normalizeAlias(t)),n=s.filter(t=>!r.includes(t)),l=r.filter(t=>!s.includes(t));await Promise.all([...n.map(i=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"DELETE",t,null,{mail:e,alias:i,action:"delete"})),...l.map(i=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${e}/sources/${i}`,"PUT",t,null,{mail:e,alias:i,action:"add"}))])},onLdapQuotaChange:(t,e,i,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${e}/size`,"PUT",t,a.toString(),{oldQuota:i,newQuota:a}),onLdapForwardChange:async(t,e,i,a)=>{const s=this.extractMailDomain(e);if(!s)return void this.logger.error(`Cannot extract domain from mail ${e} for forward management`);const r=i.filter(t=>!a.includes(t)),n=a.filter(t=>!i.includes(t));await Promise.all([...r.map(i=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...n.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/address/forwards/${e}/targets/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"}))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.identityQueue=new Map,this.quotaAttr=this.config.quota_attribute||"mailQuotaSize",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!r(e,i))return;const s=e.params.user;try{const e=a(s),r=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=r.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${s} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${s} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${s}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:s,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(t,e,i){const a=(this.identityQueue.get(e)??Promise.resolve()).then(()=>this.doUpdateJamesIdentity(t,e,i));this.identityQueue.set(e,a);try{await a}finally{this.identityQueue.get(e)===a&&this.identityQueue.delete(e)}}async doUpdateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};try{const r=`${this.webadminUrl}/users/${i}/identities`,n=await this.requestLimit(()=>t(`${r}?default=true`,{method:"GET",headers:this.createHeaders()}));let l;if(n.ok){const t=await n.json();l=(Array.isArray(t)?t:[t]).filter(t=>t&&t.id&&!1!==t.mayDelete).sort((t,e)=>(t.sortOrder??0)-(e.sortOrder??0))[0]}else if(404!==n.status)return void this.logger.error({...s,step:"get_default_identity",http_status:n.status,http_status_text:n.statusText});const o=await this.generateSignature(e),m=l?.id?{id:l.id,email:l.email,name:a}:{email:i,name:a,sortOrder:0};o&&(m.htmlSignature=o);const d=l?.id?"update_identity":"create_identity",h=await this.requestLimit(()=>t(l?.id?`${r}/${l.id}`:r,{method:l?.id?"PUT":"POST",headers:this.createHeaders("application/json"),body:JSON.stringify(m)}));h.ok?this.logger.info({...s,step:d,result:"success",http_status:h.status}):this.logger.error({...s,step:d,http_status:h.status,http_status_text:h.statusText})}catch(t){this.logger.error({...s,error:`${t}`})}}async deleteUserData(e,i){const a={plugin:this.name,event:"deleteUserData",mail:e,fromStep:i};try{const s=new URL(`${this.webadminUrl}/users/${e}`);s.searchParams.set("action","deleteData"),i&&s.searchParams.set("fromStep",i);const r=await this.requestLimit(()=>t(s.toString(),{method:"POST",headers:this.createHeaders()}));if(!r.ok)return this.logger.error({...a,http_status:r.status,http_status_text:r.statusText}),null;const n=await r.json();return this.logger.info({...a,http_status:r.status,taskId:n.taskId}),n}catch(t){return this.logger.error({...a,error:`${t}`}),null}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r),l=[];l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i}));for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>!s(t,this.config.group_dummy_user)).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(t=>!l.includes(t)),d=l.filter(t=>!o.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),u=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[g,c]=await Promise.all([Promise.all(h),Promise.all(u)]),p=[];for(const{dn:e,email:i}of g)i&&p.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"PUT",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"add"}));for(const{dn:e,email:i}of c)i&&p.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${i}`,"DELETE",t,null,{userMail:a,delegateEmail:i,delegateDN:e,action:"remove"}));await Promise.all(p)}async _getDelegateEmail(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:t,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(t){return t?Array.isArray(t)?t:[t]:[]}}export{James as default};
1
+ import t from"node-fetch";import{TwakePlugin as e}from"../../abstract/twakePlugin.js";import{asyncHandler as i,escapeLdapFilter as a,isDummyMemberDn as s}from"../../lib/utils.js";import{wantJson as r}from"../../lib/expressFormatedResponses.js";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,3 +1,12 @@
1
+ /**
2
+ * @module abstract/ldapFlat
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Abstract class to manage LDAP entries in a flat branch
6
+ * - add/delete entries
7
+ * - modify entries
8
+ * - validate with schema
9
+ */
1
10
  import type { Express, Request, Response } from 'express';
2
11
  import type { DM } from '../bin';
3
12
  import type ldapActions from '../lib/ldapActions';
@@ -26,3 +26,10 @@
26
26
  * @returns the LDAP result code, or undefined when nothing says
27
27
  */
28
28
  export declare function extractLdapCode(err: unknown): number | undefined;
29
+ /**
30
+ * Whether an LDAP result code means the directory refused the request for
31
+ * its content, see {@link SCHEMA_REFUSALS}.
32
+ *
33
+ * @param code LDAP result code, as {@link extractLdapCode} reads it
34
+ */
35
+ export declare const isSchemaRefusal: (code: number | undefined) => boolean;
@@ -0,0 +1,64 @@
1
+ import type { Config } from '../bin';
2
+ /** Directory of the schemas shipped with LDAP-Rest, whatever --schemas-path says */
3
+ export declare const shippedSchemasPath: string;
4
+ /** Prefix of an `extends` naming a shipped schema, as `ldap-rest:twake/users.json` */
5
+ export declare const SHIPPED_SCHEMA_PREFIX = "ldap-rest:";
6
+ export interface SchemaFileOptions {
7
+ /**
8
+ * Configuration whose values replace the `__FOO_BAR__` placeholders, in
9
+ * every file of the chain
10
+ */
11
+ config?: Config;
12
+ }
13
+ /**
14
+ * Apply `patch` on `base`: plain objects are merged key by key, `null`
15
+ * deletes a key, anything else (arrays included) replaces the base value
16
+ *
17
+ * @param base value being extended, left untouched
18
+ * @param patch value of the extending file
19
+ * @returns the merged value
20
+ */
21
+ export declare function mergeSchema(base: unknown, patch: unknown): unknown;
22
+ /**
23
+ * Path of the file an `extends` names
24
+ *
25
+ * @param file file holding the `extends`
26
+ * @param base its value
27
+ * @returns absolute path, or relative to the current directory when `file` is
28
+ */
29
+ export declare function resolveSchemaBase(file: string, base: string): string;
30
+ /**
31
+ * Read a schema file and the files it extends, synchronously
32
+ *
33
+ * @param file path of the schema
34
+ * @param options see {@link SchemaFileOptions}
35
+ * @returns the parsed schema, merged with its bases
36
+ */
37
+ export declare function loadSchemaFile<T = unknown>(file: string, options?: SchemaFileOptions): T;
38
+ /**
39
+ * Read a schema file and the files it extends
40
+ *
41
+ * @param file path of the schema
42
+ * @param options see {@link SchemaFileOptions}
43
+ * @returns the parsed schema, merged with its bases
44
+ */
45
+ export declare function loadSchemaFileAsync<T = unknown>(file: string, options?: SchemaFileOptions): Promise<T>;
46
+ /**
47
+ * URL the static plugin serves a schema file at: its path from the first
48
+ * `schemas` directory it is in, under --static-name
49
+ *
50
+ * @param config server configuration
51
+ * @param file path of the schema
52
+ * @returns the URL, or undefined when the path has no `schemas` directory
53
+ */
54
+ export declare function schemaUrl(config: Config, file: string): string | undefined;
55
+ /**
56
+ * The schema files the configuration names, those the plugins advertise a
57
+ * {@link schemaUrl} for. A value left at its default is not one: the
58
+ * default `--group-schema` lies in the package, and a `--static-path` of
59
+ * the deployment's own keeps serving its file at that URL.
60
+ *
61
+ * @param config server configuration
62
+ * @returns their paths, as configured
63
+ */
64
+ export declare function configuredSchemaFiles(config: Config): string[];
@@ -1,3 +1,10 @@
1
+ /**
2
+ * @module plugins/ldap/flatGeneric
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Generic plugin to manage LDAP flat entities from schema files
6
+ * Automatically creates sub-plugins based on schema metadata
7
+ */
1
8
  import type { Express } from 'express';
2
9
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
10
  import LdapFlat from '../../abstract/ldapFlat';
@@ -1,3 +1,12 @@
1
+ /**
2
+ * @module plugins/ldap/groups
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ *
5
+ * Plugin to manage groups into LDAP server
6
+ * - add/delete groups
7
+ * - add/delete members of groups
8
+ * - detect user deletion to remove them from groups (hook)
9
+ */
1
10
  import type { Express, Request, Response } from 'express';
2
11
  import DmPlugin, { type Role } from '../../abstract/plugin';
3
12
  import type { DM } from '../../bin';
@@ -13,7 +13,8 @@ import DmPlugin, { type Role } from '../abstract/plugin';
13
13
  * The server replaces `__FOO_BAR__` placeholders on the fly with the
14
14
  * value of the corresponding `--foo-bar` CLI option so that a single
15
15
  * schema file can be shared between server-side validation and
16
- * client-side rendering.
16
+ * client-side rendering. A schema that `extends` another is served
17
+ * merged with it.
17
18
  * additionalProperties: true
18
19
  * example:
19
20
  * entity:
@@ -37,6 +38,19 @@ export default class Static extends DmPlugin {
37
38
  name: string;
38
39
  roles: Role[];
39
40
  api(app: Express): void;
41
+ /**
42
+ * The handler of the schema routes
43
+ *
44
+ * @param rep static directory
45
+ * @returns a handler taking the path segments after `schemas/`, decoded
46
+ */
47
+ private schemaServer;
48
+ /**
49
+ * Schema files of the configuration that their URL would not reach under
50
+ * the static directory, by URL
51
+ */
52
+ private configuredSchemas;
53
+ private sendSchema;
40
54
  /**
41
55
  * Provide configuration for config API
42
56
  */
@@ -1158,6 +1158,19 @@ All errors return a JSON object with an error message:
1158
1158
  }
1159
1159
  ```
1160
1160
 
1161
+ **Refused by the directory schema** — an attribute the directory does not
1162
+ define, a value of the wrong syntax, a missing mandatory attribute, a broken
1163
+ constraint or naming rule. Nothing of the write is applied. These are the LDAP
1164
+ result codes 17, 18, 19, 21, 34, 64, 67 and 69 on any write, 65 on a creation,
1165
+ a rename or a move, and 20 (a value given twice) on a creation; a rename or a
1166
+ move names both DNs:
1167
+
1168
+ ```json
1169
+ {
1170
+ "error": "The directory refused this write to uid=john.doe,ou=users,dc=example,dc=com: UndefinedTypeError: mailQuota: attribute type undefined Code: 0x11"
1171
+ }
1172
+ ```
1173
+
1161
1174
  #### 401 Unauthorized
1162
1175
 
1163
1176
  **Missing Authentication:**
@@ -1212,6 +1225,19 @@ All errors return a JSON object with an error message:
1212
1225
  }
1213
1226
  ```
1214
1227
 
1228
+ A rename or a move onto a DN already taken answers the same way, naming that
1229
+ DN (LDAP result code 68).
1230
+
1231
+ **Entry that cannot hold a change** — a modify adding an attribute the
1232
+ classes of the entry do not allow, or a value it already holds (LDAP result
1233
+ codes 65 and 20 on a modify):
1234
+
1235
+ ```json
1236
+ {
1237
+ "error": "Entry uid=john.doe,ou=users,dc=example,dc=com cannot hold this change: TypeOrValueExistsError: modify/add: cn: value #0 already exists Code: 0x14"
1238
+ }
1239
+ ```
1240
+
1215
1241
  #### 422 Unprocessable Entity
1216
1242
 
1217
1243
  **List longer than the directory's size limit** (flat entities' lists, when
@@ -13,6 +13,7 @@ LDAP-Rest uses JSON schemas to define LDAP entity structure, validation rules, a
13
13
  - [Semantic Roles](#semantic-roles)
14
14
  - [Validation Rules](#validation-rules)
15
15
  - [Predefined Schemas](#predefined-schemas)
16
+ - [Extending a Schema](#extending-a-schema)
16
17
 
17
18
  ---
18
19
 
@@ -1055,6 +1056,91 @@ npx ldap-rest \
1055
1056
 
1056
1057
  ---
1057
1058
 
1059
+ ## Extending a Schema
1060
+
1061
+ To change a few points of a schema, shipped or not, write a file holding only
1062
+ the differences and naming the schema it `extends`. The deployment keeps the
1063
+ later fixes of the base schema, which a copy would miss:
1064
+
1065
+ ```json
1066
+ {
1067
+ "extends": "/app/node_modules/ldap-rest/static/schemas/twake/users.json",
1068
+ "attributes": { "personalTitle": null }
1069
+ }
1070
+ ```
1071
+
1072
+ Point the option at this file as at any schema:
1073
+ `DM_LDAP_FLAT_SCHEMA=/etc/ldap-rest/schemas/users.json`.
1074
+
1075
+ ### Naming the Base
1076
+
1077
+ `extends` takes:
1078
+
1079
+ - `ldap-rest:<path>`: a schema shipped with LDAP-Rest, `<path>` being relative
1080
+ to its `static/schemas` directory, wherever the package is installed and
1081
+ whatever `--schemas-path` says: `"extends": "ldap-rest:twake/users.json"`
1082
+ - an absolute path
1083
+ - any other path, relative to the directory of the extending file:
1084
+ `"extends": "../base/users.json"`
1085
+
1086
+ The base may extend another file in turn. A loop (`a.json` extends `b.json`,
1087
+ which extends `a.json`) is an error.
1088
+
1089
+ ### Merge Rules
1090
+
1091
+ The extending file is applied on its base:
1092
+
1093
+ - objects are merged key by key, at every depth; the extending file wins
1094
+ - `null` deletes the key
1095
+ - arrays and other values replace those of the base: an `objectClass` list
1096
+ must be written whole
1097
+ - `extends` itself is not part of the result
1098
+
1099
+ Removing an attribute and changing only the French label of another:
1100
+
1101
+ ```json
1102
+ {
1103
+ "extends": "ldap-rest:twake/users.json",
1104
+ "attributes": {
1105
+ "personalTitle": null,
1106
+ "cn": { "label": { "fr": "Nom complet" } }
1107
+ }
1108
+ }
1109
+ ```
1110
+
1111
+ An attribute added by the extending file comes after the inherited ones, and
1112
+ so does its field in the forms built from the schema.
1113
+
1114
+ The `__ldap_base__` style placeholders are replaced in each file of the chain
1115
+ before the merge: inherited values get them, and `extends` may hold one.
1116
+ `{ldap_base}` in `entity.base` is resolved on the merged schema.
1117
+
1118
+ ### Where It Applies
1119
+
1120
+ Every schema file is read this way: `--ldap-flat-schema`, `--group-schema`,
1121
+ `--organization-schema`, `--bulk-import-schemas`, the SCIM schemas of
1122
+ `--schemas-path`, and the schemas `core/static` serves. A file whose base is
1123
+ missing or invalid is not loaded, as an unreadable file; `core/static` answers
1124
+ it with a 500.
1125
+
1126
+ ### Served Schemas
1127
+
1128
+ `core/static` serves the merged schema. The configuration API gives each
1129
+ entity its merged schema inline (`schema`) and, with `core/static`, a
1130
+ `schemaUrl` made of the file path from its first `schemas` directory:
1131
+ `/etc/ldap-rest/schemas/users.json` gets `/static/schemas/users.json`.
1132
+ `core/static` serves a configured schema file at that URL even when it is
1133
+ outside `--static-path`, and before a shipped schema of the same URL:
1134
+ `/etc/ldap-rest/schemas/twake/users.json` is what
1135
+ `/static/schemas/twake/users.json` returns. An option left at its default
1136
+ does not count: without `--group-schema`, a `--static-path` of your own
1137
+ still serves its `schemas/twake/groups.json`. If two configured files get
1138
+ the same URL, the first one is served there and a warning names the other.
1139
+ A file outside any `schemas` directory has no `schemaUrl`; its schema is
1140
+ still inline.
1141
+
1142
+ ---
1143
+
1058
1144
  ## Best Practices
1059
1145
 
1060
1146
  ### 1. Always Define ObjectClass
@@ -148,7 +148,7 @@ James WebAdmin: POST /users/alice@.../rename/alice.smith@...
148
148
  ## 2. Quota Update
149
149
 
150
150
  ```
151
- LDAP: mailQuota = 1000000000 → 5000000000 (1GB → 5GB)
151
+ LDAP: mailQuotaSize = 1000000000 → 5000000000 (1GB → 5GB)
152
152
  ↓ onChange detects the change
153
153
  ↓ Hook onLdapQuotaChange triggered
154
154
  ↓
@@ -584,7 +584,7 @@ npx ldap-rest \
584
584
  --james-webadmin-url http://james:8000 \
585
585
  --james-webadmin-token "admin-token" \
586
586
  --mail-attribute mail \
587
- --quota-attribute mailQuota \
587
+ --quota-attribute mailQuotaSize \
588
588
  --alias-attribute mailAlternateAddress
589
589
  ```
590
590
 
@@ -148,7 +148,7 @@ James WebAdmin: POST /users/alice@.../rename/alice.smith@...
148
148
  ## 2. Mise à jour de quota
149
149
 
150
150
  ```
151
- LDAP: mailQuota = 1000000000 → 5000000000 (1GB → 5GB)
151
+ LDAP: mailQuotaSize = 1000000000 → 5000000000 (1GB → 5GB)
152
152
  ↓ onChange détecte le changement
153
153
  ↓ Hook onLdapQuotaChange déclenché
154
154
  ↓
@@ -584,7 +584,7 @@ npx ldap-rest \
584
584
  --james-webadmin-url http://james:8000 \
585
585
  --james-webadmin-token "admin-token" \
586
586
  --mail-attribute mail \
587
- --quota-attribute mailQuota \
587
+ --quota-attribute mailQuotaSize \
588
588
  --alias-attribute mailAlternateAddress
589
589
  ```
590
590
 
@@ -156,6 +156,10 @@ to keep the default.
156
156
  | `--mail-domain` | `--mail-domains` | `DM_MAIL_DOMAIN` | `[]` | Mail domains |
157
157
  | `--schemas-path` | | `DM_SCHEMAS_PATH` | `static/schemas` | Path to JSON schemas |
158
158
 
159
+ A schema file named by an option may extend another one, shipped or not, and
160
+ hold only the differences:
161
+ [extending a schema](../client-development/schemas/README.md#extending-a-schema).
162
+
159
163
  ## LDAP Connection
160
164
 
161
165
  | CLI | Plural | Env | Default | Description |
@@ -192,7 +196,7 @@ at `0` otherwise.
192
196
  | CLI | Env | Default | Description |
193
197
  | -------------------------- | --------------------------- | ----------------------- | ---------------------- |
194
198
  | `--mail-attribute` | `DM_MAIL_ATTRIBUTE` | `mail` | Email attribute |
195
- | `--quota-attribute` | `DM_QUOTA_ATTRIBUTE` | `mailQuota` | Quota attribute |
199
+ | `--quota-attribute` | `DM_QUOTA_ATTRIBUTE` | `mailQuotaSize` | Quota attribute |
196
200
  | `--delegation-attribute` | `DM_DELEGATION_ATTRIBUTE` | `twakeDelegatedUsers` | Delegation attribute |
197
201
  | `--alias-attribute` | `DM_ALIAS_ATTRIBUTE` | `mailAlternateAddress` | Email alias attribute |
198
202
  | `--forward-attribute` | `DM_FORWARD_ATTRIBUTE` | `mailForwardingAddress` | Forward attribute |
@@ -20,7 +20,7 @@ The `twake/james` plugin automatically synchronizes email address and quota chan
20
20
  --plugin twake/james \
21
21
  --ldap-flat-schema ./static/schemas/twake/users.json \
22
22
  --mail-attribute mail \
23
- --quota-attribute mailQuota \
23
+ --quota-attribute mailQuotaSize \
24
24
  --james-webadmin-url http://james:8000 \
25
25
  --james-webadmin-token "your-admin-token"
26
26
  ```
@@ -37,7 +37,7 @@ DM_JAMES_WEBADMIN_TOKEN="your-admin-token"
37
37
  - `--james-webadmin-url`: James WebAdmin API base URL (required)
38
38
  - `--james-webadmin-token`: Bearer token for James WebAdmin authentication (optional)
39
39
  - `--mail-attribute`: LDAP attribute for email (default: `mail`)
40
- - `--quota-attribute`: LDAP attribute for quota (default: `mailQuota`)
40
+ - `--quota-attribute`: LDAP attribute for quota (default: `mailQuotaSize`)
41
41
  - `--alias-attribute`: LDAP attribute for email aliases (default: `mailAlternateAddress`)
42
42
  - `--james-mailbox-type-attribute`: LDAP attribute for mailbox type (default: `twakeMailboxType`)
43
43
 
@@ -130,7 +130,7 @@ When a user's quota changes:
130
130
  PUT /api/v1/ldap/users/jdoe
131
131
  {
132
132
  "replace": {
133
- "mailQuota": "5000000000"
133
+ "mailQuotaSize": "5000000000"
134
134
  }
135
135
  }
136
136
  ```
@@ -646,7 +646,7 @@ Alternative authentication methods (Basic Auth, JWT) can be configured via rever
646
646
  --plugin twake/james \
647
647
  --ldap-flat-schema ./schemas/twake/users.json \
648
648
  --mail-attribute mail \
649
- --quota-attribute mailQuota \
649
+ --quota-attribute mailQuotaSize \
650
650
  --james-webadmin-url http://james:8000
651
651
  ```
652
652
 
@@ -663,7 +663,7 @@ Alternative authentication methods (Basic Auth, JWT) can be configured via rever
663
663
  --ldap-flat-schema ./schemas/twake/users.json \
664
664
  --ldap-flat-schema ./schemas/twake/positions.json \
665
665
  --mail-attribute mail \
666
- --quota-attribute mailQuota \
666
+ --quota-attribute mailQuotaSize \
667
667
  --james-webadmin-url http://james:8000 \
668
668
  --james-webadmin-token "james-admin-token" \
669
669
  --static-path ./static \
@@ -770,7 +770,7 @@ curl http://james:8000/users/john.doe@company.com
770
770
  curl -X PUT http://localhost:8081/api/v1/ldap/users/jdoe \
771
771
  -H "Authorization: Bearer admin-token" \
772
772
  -H "Content-Type: application/json" \
773
- -d '{"replace": {"mailQuota": "5000000000"}}'
773
+ -d '{"replace": {"mailQuotaSize": "5000000000"}}'
774
774
 
775
775
  # Verify in James
776
776
  curl http://james:8000/quota/users/jdoe@company.com/size
@@ -780,12 +780,12 @@ curl http://james:8000/quota/users/jdoe@company.com/size
780
780
 
781
781
  Quota values are in **bytes**:
782
782
 
783
- | Size | Bytes | Example |
784
- | --------- | ----------- | ---------------------------- |
785
- | 1 GB | 1000000000 | `"mailQuota": "1000000000"` |
786
- | 5 GB | 5000000000 | `"mailQuota": "5000000000"` |
787
- | 10 GB | 10000000000 | `"mailQuota": "10000000000"` |
788
- | Unlimited | -1 | `"mailQuota": "-1"` |
783
+ | Size | Bytes | Example |
784
+ | --------- | ----------- | -------------------------------- |
785
+ | 1 GB | 1000000000 | `"mailQuotaSize": "1000000000"` |
786
+ | 5 GB | 5000000000 | `"mailQuotaSize": "5000000000"` |
787
+ | 10 GB | 10000000000 | `"mailQuotaSize": "10000000000"` |
788
+ | Unlimited | -1 | `"mailQuotaSize": "-1"` |
789
789
 
790
790
  ## Synchronization Scenarios
791
791
 
@@ -941,7 +941,7 @@ hooks: {
941
941
 
942
942
  ```bash
943
943
  --mail-attribute mail
944
- --quota-attribute mailQuota
944
+ --quota-attribute mailQuotaSize
945
945
  ```
946
946
 
947
947
  3. Enable debug logging:
@@ -8,7 +8,7 @@ LDAP-Rest, and hooks for the attributes other plugins follow.
8
8
  ```bash
9
9
  --plugin core/ldap/onChange \
10
10
  --mail-attribute mail \
11
- --quota-attribute mailQuota
11
+ --quota-attribute mailQuotaSize
12
12
  ```
13
13
 
14
14
  ## Hooks
@@ -4,6 +4,20 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## To 0.17.0
8
+
9
+ ### The quota attribute defaults to `mailQuotaSize`
10
+
11
+ **Who is affected:** a deployment that loads `twake/james` or
12
+ `core/ldap/onChange`, or runs `bin/sync-james.mjs`, without setting
13
+ `--quota-attribute` (`DM_QUOTA_ATTRIBUTE`).
14
+
15
+ The default is now `mailQuotaSize`, the attribute the Twake schemas define,
16
+ instead of `mailQuota`. A directory that holds the quota in `mailQuota` must
17
+ set `DM_QUOTA_ATTRIBUTE=mailQuota` (or `--quota-attribute mailQuota`):
18
+ without it, quota changes are no longer sent to the mail server. A deployment
19
+ that already sets the option is unchanged.
20
+
7
21
  ## To 0.16.0
8
22
 
9
23
  ### Space events need the organization domain attribute
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -171,16 +171,6 @@
171
171
  "fr": "Autres boîtes"
172
172
  }
173
173
  },
174
- "mailQuota": {
175
- "type": "number",
176
- "required": false,
177
- "role": "emailQuota",
178
- "group": "Mailbox Settings",
179
- "label": {
180
- "en": "Mail quota",
181
- "fr": "Quota"
182
- }
183
- },
184
174
  "mailQuotaSize": {
185
175
  "type": "number",
186
176
  "required": false,