ldap-rest 0.16.1 → 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';
@@ -159,6 +159,21 @@ export default class LdapOrganizations extends DmPlugin {
159
159
  * @param message what to say
160
160
  */
161
161
  private warnSizeLimit;
162
+ /**
163
+ * The last row of a partial list, saying that it is one.
164
+ *
165
+ * Every list this plugin cuts ends with the same shape, whichever limit
166
+ * cut it; only the DN, the text and the counts differ. `total` is set
167
+ * when the rest was counted — ldap-rest's own cap — and left out when the
168
+ * directory refused to list it, since nothing counted it then.
169
+ *
170
+ * @param dn DN of the row, distinct per kind of list: one answer can carry
171
+ * two of them
172
+ * @param label what the row reads as
173
+ * @param displayed how many entries the list kept
174
+ * @param total how many there were, when known
175
+ */
176
+ private static moreIndicator;
162
177
  /**
163
178
  * The child organizations of a node, and what to do when the directory
164
179
  * will not list them all.
@@ -258,6 +273,36 @@ export default class LdapOrganizations extends DmPlugin {
258
273
  * @returns matching organizations then matching attached entries
259
274
  */
260
275
  searchOrganisationSubnodes(dn: string, query: string, req?: Request): Promise<AttributesList[]>;
276
+ /**
277
+ * The organizations of a whole subtree that match a query.
278
+ *
279
+ * `searchOrganisationSubnodes` looks one level down, which is no help when
280
+ * the tree is a top organization, one country node and a thousand
281
+ * departments below it: a department picker needs the match wherever it
282
+ * sits. The search runs with scope `sub`, so `dn` itself is a candidate
283
+ * too, and the path attribute is matched next to the name so that a query
284
+ * written as a path finds its department.
285
+ *
286
+ * The answer is bounded like the children listing is, and read the same
287
+ * way. The search first goes out without a `sizeLimit`: ldapts swallows a
288
+ * `sizeLimitExceeded` from a request that carried one, so a bounded first
289
+ * attempt would let a directory whose own limit sits below the cap answer
290
+ * a partial list that passes for whole. Unbounded, the refusal raises
291
+ * (code 4), and only then is a bounded search sent, whose answer is
292
+ * partial by construction. When the directory does list everything, the
293
+ * cap is ldap-rest's own, and the rest is counted.
294
+ *
295
+ * The two cuts end the list with different rows, because they call for
296
+ * different fixes: a narrower query for the cap, the size limit of the
297
+ * bind account for the refusal.
298
+ *
299
+ * @param dn organization to search under, itself included
300
+ * @param query what to look for in a name, a description or a path
301
+ * @param req incoming request, forwarded to the authorization hooks
302
+ * @returns the matching organizations, followed by a `moreIndicator` row
303
+ * when some were left out
304
+ */
305
+ searchOrganisationTree(dn: string, query: string, req?: Request): Promise<AttributesList[]>;
261
306
  /**
262
307
  * Provide configuration for config API
263
308
  */
@@ -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
  */
@@ -230,7 +230,8 @@ curl -H "Accept: application/json" \
230
230
  "getTop": "/api/v1/ldap/organizations",
231
231
  "get": "/api/v1/ldap/organizations/:dn",
232
232
  "getSubnodes": "/api/v1/ldap/organizations/:dn/subnodes",
233
- "searchSubnodes": "/api/v1/ldap/organizations/:dn/subnodes/search"
233
+ "searchSubnodes": "/api/v1/ldap/organizations/:dn/subnodes/search",
234
+ "search": "/api/v1/ldap/organizations/:dn/search"
234
235
  }
235
236
  }
236
237
  }
@@ -387,6 +388,65 @@ curl -H "Accept: application/json" \
387
388
  ]
388
389
  ```
389
390
 
391
+ ### Search Organizations in a Subtree
392
+
393
+ Search the organizations under a node, at any depth, the node itself included.
394
+ Where the subnodes search only looks at the direct children and adds the linked
395
+ users and groups, this one answers organizations only, which makes it the call
396
+ behind a department autocomplete over a wide tree.
397
+
398
+ **Endpoint:** `GET /api/v1/ldap/organizations/:dn/search?q=query`
399
+
400
+ **Parameters:**
401
+
402
+ - `:dn` - URL-encoded Distinguished Name of the node to search under
403
+ - `q` - Search query (required), matched against `ou`, `description` and, when
404
+ `--ldap-organization-path-attribute` is set, the path attribute, so
405
+ `Government / lin` matches by path
406
+
407
+ **Limit:** at most `--ldap-organization-max-subnodes` (default 50)
408
+ organizations are returned. A partial answer ends with a `moreIndicator` entry
409
+ at `more-organizations-<dn>` (`_isMoreIndicator: "true"`, `_displayedCount`
410
+ holding the number of organizations kept), which a client must drop before
411
+ using the rest. It says which limit cut the answer:
412
+
413
+ - **the cap**: more organizations matched than it allows. The entry also
414
+ carries `_totalCount`, and a narrower query is the way to the rest.
415
+ - **the directory**: it refused to list every match in one answer. There is no
416
+ `_totalCount`, since nothing counted the rest, and ldap-rest logs a warning:
417
+ the fix is the size limit of the account ldap-rest binds as (`olcLimits`, or
418
+ `olcSizeLimit`).
419
+
420
+ **Example Request:**
421
+
422
+ ```bash
423
+ curl -H "Accept: application/json" \
424
+ "http://localhost:8081/api/v1/ldap/organizations/ou%3Dorganization%2Cdc%3Dexample%2Cdc%3Dcom/search?q=lin"
425
+ ```
426
+
427
+ **Example Response:**
428
+
429
+ ```json
430
+ [
431
+ {
432
+ "dn": "ou=linagora.com,ou=Government,ou=organization,dc=example,dc=com",
433
+ "objectClass": ["organizationalUnit", "top"],
434
+ "ou": "linagora.com"
435
+ },
436
+ {
437
+ "dn": "more-organizations-ou=organization,dc=example,dc=com",
438
+ "cn": ["... 73 more organizations, narrow the search"],
439
+ "objectClass": ["moreIndicator"],
440
+ "_isMoreIndicator": "true",
441
+ "_totalCount": "123",
442
+ "_displayedCount": "50"
443
+ }
444
+ ]
445
+ ```
446
+
447
+ The `search` entry of the `endpoints` advertised by the config API tells a
448
+ client that the server has this route.
449
+
390
450
  ### Create Organization
391
451
 
392
452
  Create a new organizational unit.
@@ -1098,6 +1158,19 @@ All errors return a JSON object with an error message:
1098
1158
  }
1099
1159
  ```
1100
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
+
1101
1174
  #### 401 Unauthorized
1102
1175
 
1103
1176
  **Missing Authentication:**
@@ -1152,6 +1225,19 @@ All errors return a JSON object with an error message:
1152
1225
  }
1153
1226
  ```
1154
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
+
1155
1241
  #### 422 Unprocessable Entity
1156
1242
 
1157
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 |