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.
- package/bin/sync-james.mjs +4 -4
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/ldapCodes.js +1 -1
- package/dist/lib/schemaFile.js +1 -0
- package/dist/plugins/ldap/bulkImport.js +1 -1
- package/dist/plugins/ldap/flatGeneric.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/scim/discovery.js +1 -1
- package/dist/plugins/scim/errors.js +1 -1
- package/dist/plugins/static.js +1 -1
- package/dist/plugins/twake/james.js +1 -1
- package/dist/src/abstract/ldapFlat.d.ts +9 -0
- package/dist/src/lib/ldapCodes.d.ts +7 -0
- package/dist/src/lib/schemaFile.d.ts +64 -0
- package/dist/src/plugins/ldap/flatGeneric.d.ts +7 -0
- package/dist/src/plugins/ldap/groups.d.ts +9 -0
- package/dist/src/plugins/ldap/organizations.d.ts +45 -0
- package/dist/src/plugins/static.d.ts +15 -1
- package/docs/client-development/api/rest-api.md +87 -1
- package/docs/client-development/schemas/README.md +86 -0
- package/docs/presentations/en.md +2 -2
- package/docs/presentations/fr.md +2 -2
- package/docs/usage/configuration.md +5 -1
- package/docs/usage/plugins/integrations/james-mail.md +13 -13
- package/docs/usage/plugins/ldap/README.md +1 -0
- package/docs/usage/plugins/ldap/on-change.md +1 -1
- package/docs/usage/upgrading.md +14 -0
- package/package.json +1 -1
- package/static/schemas/twake/users.json +0 -10
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"
|
|
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
|
|
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};
|
package/dist/plugins/static.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import
|
|
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
|
package/docs/presentations/en.md
CHANGED
|
@@ -148,7 +148,7 @@ James WebAdmin: POST /users/alice@.../rename/alice.smith@...
|
|
|
148
148
|
## 2. Quota Update
|
|
149
149
|
|
|
150
150
|
```
|
|
151
|
-
LDAP:
|
|
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
|
|
587
|
+
--quota-attribute mailQuotaSize \
|
|
588
588
|
--alias-attribute mailAlternateAddress
|
|
589
589
|
```
|
|
590
590
|
|
package/docs/presentations/fr.md
CHANGED
|
@@ -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:
|
|
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
|
|
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` | `
|
|
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 |
|