ldap-rest 0.1.2 → 0.1.3

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 t from"fs";import{wantJson as e,serverError as i,notFound as r,jsonBody as s,created as a,tryMethod as n}from"../lib/expressFormatedResponses.js";import{transformSchemas as o,launchHooksChained as h,launchHooks as d}from"../lib/utils.js";import c from"./plugin.js";class LdapFlat extends c{constructor(e,i){if(super(e),this.ldap=e.ldap,this.base=i.base,this.mainAttribute=i.mainAttribute,this.objectClass=i.objectClass,this.defaultAttributes=i.defaultAttributes||{},this.singularName=i.singularName,this.pluralName=i.pluralName,this.hookPrefix=i.hookPrefix,!this.base)throw new Error(`LDAP base is not defined for ${this.singularName}`);if(i.schemaPath)try{const e=t.readFileSync(i.schemaPath,"utf8");this.schema=JSON.parse(o(e,this.config)),this.logger.info(`${this.singularName} schema loaded from ${i.schemaPath}`)}catch(t){this.logger.error(`Failed to load ${this.singularName} schema from ${i.schemaPath}: ${t}`)}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,async(t,r)=>{if(e(t,r))try{const e={};t.query.match&&"string"==typeof t.query.match&&t.query.attribute&&"string"==typeof t.query.attribute&&(e.filter=`(${t.query.attribute}=*${t.query.match}*)`),t.query.attributes&&"string"==typeof t.query.attributes&&(e.attributes=t.query.attributes.split(","));const i=await this.listEntries(e);r.json(i)}catch(t){return i(r,t)}}),t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,async(t,e)=>this.apiGet(t,e)),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,async(t,e)=>this.apiAdd(t,e)),t.delete(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,async(t,e)=>this.apiDelete(t,e)),t.put(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,async(t,e)=>this.apiModify(t,e)),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`,async(t,e)=>this.apiMove(t,e))}async apiGet(t,s){if(!e(t,s))return;const a=decodeURIComponent(t.params.id);try{const t=/,/.test(a)?a:`${this.mainAttribute}=${a},${this.base}`,e=await this.ldap.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)return r(s,`${this.singularName} not found`);s.json(e.searchEntries[0])}catch(t){return t.code&&32===t.code?r(s,`${this.singularName} not found`):i(s,t)}}async apiAdd(t,e){const r=s(t,e,this.mainAttribute);if(!r)return;const n=r[this.mainAttribute],o={...r};delete o[this.mainAttribute],delete o.dn;try{await this.addEntry(n,o,t);const i=await this.searchEntriesByName(n,!1);return a(e,i[n])}catch(t){return i(e,t)}}async apiDelete(t,i){if(!e(t,i))return;const r=decodeURIComponent(t.params.id);await n(i,this.deleteEntry.bind(this),r)}async apiModify(t,e){const i=s(t,e);if(!i)return;const r=decodeURIComponent(t.params.id);await n(e,this.modifyEntry.bind(this),r,i)}async apiMove(t,r){if(!e(t,r))return;const a=s(t,r,"targetOrgDn");if(!a)return;const n=decodeURIComponent(t.params.id),{targetOrgDn:o}=a;if(o&&"string"==typeof o)try{const e=await this.moveEntry(n,o,t);r.json({success:!0,...e})}catch(t){return i(r,t)}else r.status(400).json({error:"Missing or invalid targetOrgDn in request body"})}async addEntry(t,e={},i){let r;if(new RegExp(`^${this.mainAttribute}=`).test(t)){r=t;const e=`,${this.base}`;if(!r.endsWith(e))throw new Error(`DN must be in the flat branch "${this.base}". Provided DN "${r}" does not end with "${e}"`);t=t.replace(new RegExp(`^${this.mainAttribute}=([^,]+).*`),"$1")}else r=`${this.mainAttribute}=${t},${this.base}`;await this.validateNewEntry(r,{objectClass:this.objectClass,[this.mainAttribute]:t,...e});let s,a={objectClass:this.objectClass,...this.defaultAttributes,[this.mainAttribute]:t,...e};this.schema?this.logger.debug("Schema loaded, attributes:",Object.keys(this.schema.attributes)):this.logger.warn("No schema available"),[r,a]=await h(this.registeredHooks[`${this.hookPrefix}add`],[r,a]),this.logger.debug("Adding LDAP entry:",{dn:r,entry:JSON.stringify(a,null,2)}),this.logger.debug("Entry attributes breakdown:");for(const[t,e]of Object.entries(a))this.logger.debug(` ${t}: ${typeof e} = ${JSON.stringify(e)}`);try{s=await this.ldap.add(r,a,i)}catch(t){throw this.logger.error("LDAP add failed:",{dn:r,entry:JSON.stringify(a,null,2),error:t}),new Error(`Failed to add ${this.singularName} ${r}: ${t}`)}return d(this.registeredHooks[`${this.hookPrefix}adddone`],[r,a]),s}async modifyEntry(t,e){let i=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`;const r=this.opNumber();if([i,e]=await h(this.registeredHooks[`${this.hookPrefix}modify`],[i,e,r]),e.add&&e.add[this.mainAttribute])throw new Error(`${this.mainAttribute} attribute is unique, cannot add`);if(e.delete){if(e.delete instanceof Object&&e.delete[this.mainAttribute])throw new Error(`Cannot delete ${this.mainAttribute} attribute`);if(Array.isArray(e.delete)&&e.delete.includes(this.mainAttribute))throw new Error(`Cannot delete ${this.mainAttribute} attribute`)}if(e.replace&&e.replace[this.mainAttribute])throw new Error(`Use dedicated API to change ${this.mainAttribute} attribute`);await this.validateChanges(i,e);const s=await this.ldap.modify(i,e);return d(this.registeredHooks[`${this.hookPrefix}modifydone`],[i,e,r]),s}async renameEntry(t,e){let i=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`,r=/,/.test(e)?e:`${this.mainAttribute}=${e},${this.base}`;[i,r]=await h(this.registeredHooks[`${this.hookPrefix}rename`],[i,r]);const s=await this.ldap.rename(i,r);return d(this.registeredHooks[`${this.hookPrefix}renamedone`],[i,r]),s}async deleteEntry(t){let e=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`;e=await h(this.registeredHooks[`${this.hookPrefix}delete`],e);const i=await this.ldap.delete(e);return d(this.registeredHooks[`${this.hookPrefix}deletedone`],e),i}async moveEntry(t,e,i){const r=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`,s=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",a=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";if(!this.schema?.attributes[s]||!this.schema?.attributes[a])throw new Error(`Schema for ${this.singularName} does not support move operation (missing ${s} or ${a})`);const n=await this.ldap.search({paged:!1,scope:"base",attributes:[s]},r,i);if(!n.searchEntries||0===n.searchEntries.length)throw new Error(`Entry ${r} not found`);const o=await this.getDepartmentPath(e,i);[,e]=await h(this.registeredHooks[`${this.hookPrefix}move`],[r,e,i]);const d={replace:{[s]:e,[a]:o}};return await this.modifyEntry(t,d),{departmentPath:o,departmentLink:e}}async getDepartmentPath(t,e){const i=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";try{const r=await this.ldap.search({paged:!1,scope:"base",attributes:[i,"ou","o"]},t,e);if(!r.searchEntries||0===r.searchEntries.length)throw new Error(`Organization ${t} not found`);const s=r.searchEntries[0];if(s[i]){const t=s[i];return Array.isArray(t)?String(t[0]):String(t)}const a=s.ou||s.o;if(a){return`/${Array.isArray(a)?String(a[0]):String(a)}`}return this.logger.warn(`Organization ${t} has no ${i} attribute, using DN`),t}catch(e){throw new Error(`Failed to fetch organization ${t}: ${e}`)}}async listEntries({filter:t,attributes:e}){const i={paged:!0,filter:t=t||"(objectClass=*)"};e&&e.length>0&&(i.attributes=e);const r=await this.ldap.search(i,this.base),s={};for await(const t of r)t.searchEntries.forEach(t=>{if(t[this.mainAttribute]){const e=t[this.mainAttribute];let i;i=Array.isArray(e)?"string"==typeof e[0]?e[0]:String(e[0]):"string"==typeof e?e:String(e),s[i]=t}});return s}async searchEntriesByName(t,e=!1,i=[this.mainAttribute]){const r=e?`(${this.mainAttribute}=*${t}*)`:`(${this.mainAttribute}=${t})`;return await this.listEntries({filter:r,attributes:i})}async validateNewEntry(t,e){if(!this.schema)return!0;for(const[t,i]of Object.entries(this.schema.attributes))i.fixed&&void 0!==i.default&&(e[t]=i.default);for(const[t,i]of Object.entries(e)){if(!this.schema.attributes[t]){if(this.schema.strict)throw new Error(`Unknown attribute "${t}" for ${this.singularName}`);continue}const e=this.schema.attributes[t];if(e.fixed&&void 0!==e.default){const r=JSON.stringify(e.default);if(r!==JSON.stringify(i))throw new Error(`Attribute "${t}" is fixed and cannot be modified. Expected: ${r}`)}if(!await this._validateOneChange(t,i))throw new Error(`Invalid value for attribute "${t}"`);if(e.required&&!i)throw new Error(`Attribute "${t}" is required`)}for(const[t,i]of Object.entries(this.schema.attributes))if(i.required&&!e[t])throw new Error(`Attribute "${t}" is required`);return!0}async validateChanges(t,e){if(!this.schema)return!0;const checkFixed=(t,e)=>{const i=this.schema?.attributes[t];if(i?.fixed)throw new Error(`Attribute "${t}" is fixed and cannot be modified`)};if(e.add)for(const[t,i]of Object.entries(e.add))if(checkFixed(t),!await this._validateOneChange(t,i))throw new Error(`Invalid value for attribute "${t}"`);if(e.replace)for(const[t,i]of Object.entries(e.replace))if(checkFixed(t),!await this._validateOneChange(t,i))throw new Error(`Invalid value for attribute "${t}"`);if(e.delete){const t=Array.isArray(e.delete)?e.delete:Object.keys(e.delete);for(const e of t){const t=this.schema.attributes[e];if(t?.fixed)throw new Error(`Attribute "${e}" is fixed and cannot be deleted`)}}return!0}async _validateOneChange(t,e){if(!this.schema)return!0;const i=this.schema.attributes[t];if(!i)return!this.schema.strict;if(!e)return!0;if("pointer"===i.type){if("string"!=typeof e)throw new Error(`Field ${t} must be a string (DN pointer)`);const r=e;if(i.branch&&i.branch.length>0){if(!i.branch.some(t=>new RegExp(`,?${t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}$`,"i").test(r)))throw new Error(`Field ${t} must point to a DN within allowed branches: ${i.branch.join(", ")}`)}try{const e=await this.ldap.search({paged:!1,scope:"base"},r);if(!e||!e.searchEntries||0===e.searchEntries.length)throw new Error(`Field ${t} points to non-existent DN: ${r}`)}catch(e){throw new Error(`Field ${t} points to invalid or non-existent DN: ${r}`)}}if(i.test){const t=new RegExp(i.test);return Array.isArray(e)?e.every(e=>t.test(e)):t.test(e)}return!0}}export{LdapFlat as default};
1
+ import t from"fs";import{wantJson as e,jsonBody as i,created as s,tryMethod as a}from"../lib/expressFormatedResponses.js";import{transformSchemas as r,asyncHandler as n,launchHooksChained as o,launchHooks as h}from"../lib/utils.js";import{NotFoundError as d,BadRequestError as c,ConflictError as u}from"../lib/errors.js";import l from"./plugin.js";class LdapFlat extends l{constructor(e,i){if(super(e),this.ldap=e.ldap,this.base=i.base,this.mainAttribute=i.mainAttribute,this.objectClass=i.objectClass,this.defaultAttributes=i.defaultAttributes||{},this.singularName=i.singularName,this.pluralName=i.pluralName,this.hookPrefix=i.hookPrefix,!this.base)throw new Error(`LDAP base is not defined for ${this.singularName}`);if(i.schemaPath)try{const e=t.readFileSync(i.schemaPath,"utf8");this.schema=JSON.parse(r(e,this.config)),this.logger.info(`${this.singularName} schema loaded from ${i.schemaPath}`)}catch(t){this.logger.error(`Failed to load ${this.singularName} schema from ${i.schemaPath}: ${t}`)}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,n(async(t,i)=>{if(!e(t,i))return;const s={};t.query.match&&"string"==typeof t.query.match&&t.query.attribute&&"string"==typeof t.query.attribute&&(s.filter=`(${t.query.attribute}=*${t.query.match}*)`),t.query.attributes&&"string"==typeof t.query.attributes&&(s.attributes=t.query.attributes.split(","));const a=await this.listEntries(s);i.json(a)})),t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,n(async(t,e)=>this.apiGet(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,n(async(t,e)=>this.apiAdd(t,e))),t.delete(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,n(async(t,e)=>this.apiDelete(t,e))),t.put(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,n(async(t,e)=>this.apiModify(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`,n(async(t,e)=>this.apiMove(t,e)))}async apiGet(t,i){if(!e(t,i))return;const s=decodeURIComponent(t.params.id);try{const t=/,/.test(s)?s:`${this.mainAttribute}=${s},${this.base}`,e=await this.ldap.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new d(`${this.singularName} not found`);i.json(e.searchEntries[0])}catch(t){if(t.code&&32===t.code)throw new d(`${this.singularName} not found`);throw t}}async apiAdd(t,e){const a=i(t,e,this.mainAttribute);if(!a)return;const r=a[this.mainAttribute],n={...a};delete n[this.mainAttribute],delete n.dn,await this.addEntry(r,n,t);const o=await this.searchEntriesByName(r,!1);return s(e,o[r])}async apiDelete(t,i){if(!e(t,i))return;const s=decodeURIComponent(t.params.id);await a(i,this.deleteEntry.bind(this),s)}async apiModify(t,e){const s=i(t,e);if(!s)return;const r=decodeURIComponent(t.params.id);await a(e,this.modifyEntry.bind(this),r,s)}async apiMove(t,s){if(!e(t,s))return;const a=i(t,s,"targetOrgDn");if(!a)return;const r=decodeURIComponent(t.params.id),{targetOrgDn:n}=a;if(!n||"string"!=typeof n)throw new c("Missing or invalid targetOrgDn in request body");const o=await this.moveEntry(r,n,t);s.json({success:!0,...o})}async addEntry(t,e={},i){let s;if(new RegExp(`^${this.mainAttribute}=`).test(t)){s=t;const e=`,${this.base}`;if(!s.endsWith(e))throw new c(`DN must be in the flat branch "${this.base}". Provided DN "${s}" does not end with "${e}"`);t=t.replace(new RegExp(`^${this.mainAttribute}=([^,]+).*`),"$1")}else s=`${this.mainAttribute}=${t},${this.base}`;await this.validateNewEntry(s,{objectClass:this.objectClass,[this.mainAttribute]:t,...e});let a,r={objectClass:this.objectClass,...this.defaultAttributes,[this.mainAttribute]:t,...e};this.schema?this.logger.debug("Schema loaded, attributes:",Object.keys(this.schema.attributes)):this.logger.warn("No schema available"),[s,r]=await o(this.registeredHooks[`${this.hookPrefix}add`],[s,r]),this.logger.debug("Adding LDAP entry:",{dn:s,entry:JSON.stringify(r,null,2)}),this.logger.debug("Entry attributes breakdown:");for(const[t,e]of Object.entries(r))this.logger.debug(` ${t}: ${typeof e} = ${JSON.stringify(e)}`);try{a=await this.ldap.add(s,r,i)}catch(t){throw this.logger.error("LDAP add failed:",{dn:s,entry:JSON.stringify(r,null,2),error:t}),new Error(`Failed to add ${this.singularName} ${s}: ${t}`)}return h(this.registeredHooks[`${this.hookPrefix}adddone`],[s,r]),a}async modifyEntry(t,e){let i=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`;const s=this.opNumber();if([i,e]=await o(this.registeredHooks[`${this.hookPrefix}modify`],[i,e,s]),e.add&&e.add[this.mainAttribute])throw new u(`${this.mainAttribute} attribute is unique, cannot add`);if(e.delete){if(e.delete instanceof Object&&e.delete[this.mainAttribute])throw new c(`Cannot delete ${this.mainAttribute} attribute`);if(Array.isArray(e.delete)&&e.delete.includes(this.mainAttribute))throw new c(`Cannot delete ${this.mainAttribute} attribute`)}if(e.replace&&e.replace[this.mainAttribute])throw new c(`Use dedicated API to change ${this.mainAttribute} attribute`);await this.validateChanges(i,e);const a=await this.ldap.modify(i,e);return h(this.registeredHooks[`${this.hookPrefix}modifydone`],[i,e,s]),a}async renameEntry(t,e){let i=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`,s=/,/.test(e)?e:`${this.mainAttribute}=${e},${this.base}`;[i,s]=await o(this.registeredHooks[`${this.hookPrefix}rename`],[i,s]);const a=await this.ldap.rename(i,s);return h(this.registeredHooks[`${this.hookPrefix}renamedone`],[i,s]),a}async deleteEntry(t){let e=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`;e=await o(this.registeredHooks[`${this.hookPrefix}delete`],e);const i=await this.ldap.delete(e);return h(this.registeredHooks[`${this.hookPrefix}deletedone`],e),i}async moveEntry(t,e,i){const s=/,/.test(t)?t:`${this.mainAttribute}=${t},${this.base}`,a=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",r=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";if(!this.schema?.attributes[a]||!this.schema?.attributes[r])throw new c(`Schema for ${this.singularName} does not support move operation (missing ${a} or ${r})`);const n=await this.ldap.search({paged:!1,scope:"base",attributes:[a]},s,i);if(!n.searchEntries||0===n.searchEntries.length)throw new d(`Entry ${s} not found`);const h=await this.getDepartmentPath(e,i);[,e]=await o(this.registeredHooks[`${this.hookPrefix}move`],[s,e,i]);const u={replace:{[a]:e,[r]:h}};return await this.modifyEntry(t,u),{departmentPath:h,departmentLink:e}}async getDepartmentPath(t,e){const i=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";try{const s=await this.ldap.search({paged:!1,scope:"base",attributes:[i,"ou","o"]},t,e);if(!s.searchEntries||0===s.searchEntries.length)throw new d(`Organization ${t} not found`);const a=s.searchEntries[0];if(a[i]){const t=a[i];return Array.isArray(t)?String(t[0]):String(t)}const r=a.ou||a.o;if(r){return`/${Array.isArray(r)?String(r[0]):String(r)}`}return this.logger.warn(`Organization ${t} has no ${i} attribute, using DN`),t}catch(e){throw new Error(`Failed to fetch organization ${t}: ${e}`)}}async listEntries({filter:t,attributes:e}){const i={paged:!0,filter:t=t||"(objectClass=*)"};e&&e.length>0&&(i.attributes=e);const s=await this.ldap.search(i,this.base),a={};for await(const t of s)t.searchEntries.forEach(t=>{if(t[this.mainAttribute]){const e=t[this.mainAttribute];let i;i=Array.isArray(e)?"string"==typeof e[0]?e[0]:String(e[0]):"string"==typeof e?e:String(e),a[i]=t}});return a}async searchEntriesByName(t,e=!1,i=[this.mainAttribute]){const s=e?`(${this.mainAttribute}=*${t}*)`:`(${this.mainAttribute}=${t})`;return await this.listEntries({filter:s,attributes:i})}async validateNewEntry(t,e){if(!this.schema)return!0;for(const[t,i]of Object.entries(this.schema.attributes))i.fixed&&void 0!==i.default&&(e[t]=i.default);for(const[t,i]of Object.entries(e)){if(!this.schema.attributes[t]){if(this.schema.strict)throw new c(`Unknown attribute "${t}" for ${this.singularName}`);continue}const e=this.schema.attributes[t];if(e.fixed&&void 0!==e.default){const s=JSON.stringify(e.default);if(s!==JSON.stringify(i))throw new c(`Attribute "${t}" is fixed and cannot be modified. Expected: ${s}`)}if(!await this._validateOneChange(t,i))throw new c(`Invalid value for attribute "${t}"`);if(e.required&&!i)throw new c(`Attribute "${t}" is required`)}for(const[t,i]of Object.entries(this.schema.attributes))if(i.required&&!e[t])throw new c(`Attribute "${t}" is required`);return!0}async validateChanges(t,e){if(!this.schema)return!0;const checkFixed=(t,e)=>{const i=this.schema?.attributes[t];if(i?.fixed)throw new c(`Attribute "${t}" is fixed and cannot be modified`)};if(e.add)for(const[t,i]of Object.entries(e.add))if(checkFixed(t),!await this._validateOneChange(t,i))throw new c(`Invalid value for attribute "${t}"`);if(e.replace)for(const[t,i]of Object.entries(e.replace))if(checkFixed(t),!await this._validateOneChange(t,i))throw new c(`Invalid value for attribute "${t}"`);if(e.delete){const t=Array.isArray(e.delete)?e.delete:Object.keys(e.delete);for(const e of t){const t=this.schema.attributes[e];if(t?.fixed)throw new c(`Attribute "${e}" is fixed and cannot be deleted`)}}return!0}async _validateOneChange(t,e){if(!this.schema)return!0;const i=this.schema.attributes[t];if(!i)return!this.schema.strict;if(!e)return!0;if("pointer"===i.type){if("string"!=typeof e)throw new c(`Field ${t} must be a string (DN pointer)`);const s=e;if(i.branch&&i.branch.length>0){if(!i.branch.some(t=>new RegExp(`,?${t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}$`,"i").test(s)))throw new c(`Field ${t} must point to a DN within allowed branches: ${i.branch.join(", ")}`)}try{const e=await this.ldap.search({paged:!1,scope:"base"},s);if(!e||!e.searchEntries||0===e.searchEntries.length)throw new c(`Field ${t} points to non-existent DN: ${s}`)}catch(e){throw new c(`Field ${t} points to invalid or non-existent DN: ${s}`)}}if(i.test){const t=new RegExp(i.test);return Array.isArray(e)?e.every(e=>t.test(e)):t.test(e)}return!0}}export{LdapFlat as default};
@@ -1 +1 @@
1
- export{asyncHandler}from"../lib/utils.js";class DmPlugin{constructor(e){this.registeredHooks={},this.server=e,this.config=e.config,this.logger=e.logger,this.registeredHooks=e.hooks}opNumber(){return this.server.operationSequence++}}export{DmPlugin as default};
1
+ export{asyncHandler}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DmPlugin{constructor(r){this.registeredHooks={},this.server=r,this.config=r.config,this.logger=r.logger,this.registeredHooks=r.hooks}opNumber(){return this.server.operationSequence++}}export{DmPlugin as default};
package/dist/bin/index.js CHANGED
@@ -1 +1 @@
1
- import{fileURLToPath as e}from"url";import{join as i,dirname as s}from"path";import r from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import g from"../lib/ldapActions.js";import{buildLogger as a}from"../logger/winston.js";import{setLogger as l}from"../lib/expressFormatedResponses.js";import h from"../plugins/priority.json.js";export{asyncHandler,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas}from"../lib/utils.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this.config=t(n),this.app=r(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=a(this.config),this.ldap=new g(this),l(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const i=this.config.plugin.find(e=>e.includes("configApi"));let s=this.config.plugin.filter(e=>!e.includes("configApi"));const r=(async()=>{for(const e of h)s.includes(e)&&(s=s.filter(i=>i!==e),await this.loadPlugin(e))})();e.push(r),e.push(r.then(async()=>{const e=s.map(e=>this.loadPlugin(e));await Promise.all(e)})),i&&e.push(Promise.all(e).then(()=>this.loadPlugin(i)))}this.ready=new Promise((i,s)=>{e.length>0?Promise.all(e).then(()=>i()).catch(e=>s(new Error("Error loading plugins: "+e))):i()})}run(){return this.app.use((e,i,s,r)=>{this.logger.error(`Unhandled error in request ${i.method} ${i.path}: ${e.message}`,{stack:e.stack,url:i.url,method:i.method}),s.headersSent||s.status(500).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})}),process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,i)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:i})}),new Promise((e,i)=>{this.server=this.app.listen(this.config.port,s=>{s?(this.logger.error(`Error starting server: ${s}`),i(s)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(r){let o,t;if(/:/.test(r)){let e=r.substring(r.indexOf(":")+1);if(r=r.substring(0,r.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${r} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||r}: ${e}`)}catch(i){this.logger.error(`Failed to parse overrides for plugin ${r}: ${i}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${r}`),r.startsWith("core/")&&(r=r.replace("core/",i(s(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,i)=>{import(r).then(async s=>{let n;if(s&&s.default&&(s=s.default),t){const e={...this.config,...t};n=new s({...this,config:e})}else n=new s(this);if(!n)return i(new Error(`Unable to load ${r}`));e(await this.registerPlugin(r,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>i(new Error(`Failed to load plugin ${r}: ${e}`)))})}async registerPlugin(e,i,s){if(i.name||(i.name=e),s&&(i.name=s),this.loadedPlugins[i.name])return this.logger.info(`Plugin ${e} already loaded as ${i.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${i.name}`),i.dependencies)for(const e in i.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${i.name} depends on ${e}, loading it first`),await this.loadPlugin(i.dependencies[e]));if(i.api&&(this.logger.debug(`Plugin ${i.name} has API, registering it`),await i.api(this.app)),i.hooks)for(const e in i.hooks){this.logger.debug(`Plugin ${i.name} has hook ${e}, registering it`);const s=i.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!s||"function"!=typeof s)throw new Error(`Plugin ${i.name}: hook ${e} is invalid`);this.hooks[e].push(s)}return this.loadedPlugins[i.name]=i,!0}}export{DM};
1
+ import{fileURLToPath as e}from"url";import{join as r,dirname as i}from"path";import s from"express";import o from"body-parser";import{parseConfig as t}from"../lib/parseConfig.js";import n from"../config/args.js";import a from"../lib/ldapActions.js";import{buildLogger as l}from"../logger/winston.js";import{setLogger as d}from"../lib/expressFormatedResponses.js";import g from"../plugins/priority.json.js";export{asyncHandler,escapeLdapFilter,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas}from"../lib/utils.js";export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError}from"../lib/errors.js";class DM{constructor(){this.hooks={},this.loadedPlugins={},this._errorMiddlewareSetup=!1,this.config=t(n),this.app=s(),this.app.use(o.json()),this.app.use(o.urlencoded({extended:!0})),this.logger=l(this.config),this.ldap=new a(this),d(this.logger),this.operationSequence=0;const e=[];if(this.config.plugin){const r=this.config.plugin.find(e=>e.includes("configApi"));let i=this.config.plugin.filter(e=>!e.includes("configApi"));const s=(async()=>{for(const e of g)i.includes(e)&&(i=i.filter(r=>r!==e),await this.loadPlugin(e))})();e.push(s),e.push(s.then(async()=>{const e=i.map(e=>this.loadPlugin(e));await Promise.all(e)})),r&&e.push(Promise.all(e).then(()=>this.loadPlugin(r)))}this.ready=new Promise((r,i)=>{e.length>0?Promise.all(e).then(()=>{this.setupErrorMiddleware(),r()}).catch(e=>i(new Error("Error loading plugins: "+e))):(this.setupErrorMiddleware(),r())})}setupErrorMiddleware(){if(this._errorMiddlewareSetup&&this._errorMiddleware){const e=this.app._router?.stack;if(e){const r=e.findIndex(e=>e.handle===this._errorMiddleware);-1!==r&&e.splice(r,1)}}this._errorMiddleware=(e,r,i,s)=>{const o="statusCode"in e?e.statusCode:500;if(o>=400&&o<500)return this.logger.warn(`Client error ${o} in request ${r.method} ${r.path}: ${e.message}`),i.headersSent?void 0:i.status(o).json({error:e.message});this.logger.error(`Server error ${o} in request ${r.method} ${r.path}: ${e.message}`,{stack:e.stack,url:r.url,method:r.method}),i.headersSent||i.status(o).json({error:"Internal Server Error",message:this.config.debug?e.message:"An error occurred"})},this.app.use(this._errorMiddleware),this._errorMiddlewareSetup=!0}run(){return process.on("uncaughtException",e=>{this.logger.error(`Uncaught exception: ${e.message}`,{stack:e.stack})}),process.on("unhandledRejection",(e,r)=>{this.logger.error(`Unhandled promise rejection: ${e}`,{reason:e,promise:r})}),new Promise((e,r)=>{this.server=this.app.listen(this.config.port,i=>{i?(this.logger.error(`Error starting server: ${i}`),r(i)):(this.logger.debug(`Server started on port ${this.config.port}`),e())})})}stop(){this.app.removeAllListeners(),this.server?.close(),this.logger.debug("Server stopped")}loadPlugin(s){let o,t;if(/:/.test(s)){let e=s.substring(s.indexOf(":")+1);if(s=s.substring(0,s.indexOf(":")),/:/.test(e)){o=e.substring(0,e.indexOf(":")),o||(o=void 0),e=e.substring(e.indexOf(":")+1);try{t=JSON.parse(e),"object"!=typeof t?(this.logger.error(`Overrides for plugin ${s} are not valid: ${e}`),t=void 0):this.logger.debug(`Overrides for plugin ${o||s}: ${e}`)}catch(r){this.logger.error(`Failed to parse overrides for plugin ${s}: ${r}, using ${e}`),t=void 0}}else o=e,o||(o=void 0)}else o=void 0;return this.logger.debug(`Loading plugin ${s}`),s.startsWith("core/")&&(s=s.replace("core/",r(i(e(import.meta.url)),"..","plugins")+"/").replace(/$/,".js")),new Promise((e,r)=>{import(s).then(async i=>{let n;if(i&&i.default&&(i=i.default),t){const e={...this.config,...t};n=new i({...this,config:e})}else n=new i(this);if(!n)return r(new Error(`Unable to load ${s}`));e(await this.registerPlugin(s,n,o)),this.logger.debug(`Plugin ${n.name} loaded`)}).catch(e=>r(new Error(`Failed to load plugin ${s}: ${e}`)))})}async registerPlugin(e,r,i){if(r.name||(r.name=e),i&&(r.name=i),this.loadedPlugins[r.name])return this.logger.info(`Plugin ${e} already loaded as ${r.name}`),!1;if(this.logger.debug(`Registering plugin ${e} as ${r.name}`),r.dependencies)for(const e in r.dependencies)this.loadedPlugins[e]||(this.logger.debug(`Plugin ${r.name} depends on ${e}, loading it first`),await this.loadPlugin(r.dependencies[e]));if(r.api&&(this.logger.debug(`Plugin ${r.name} has API, registering it`),await r.api(this.app),this._errorMiddlewareSetup&&this.setupErrorMiddleware()),r.hooks)for(const e in r.hooks){this.logger.debug(`Plugin ${r.name} has hook ${e}, registering it`);const i=r.hooks[e];if(this.hooks[e]||(this.hooks[e]=[]),!i||"function"!=typeof i)throw new Error(`Plugin ${r.name}: hook ${e} is invalid`);this.hooks[e].push(i)}return this.loadedPlugins[r.name]=r,!0}}export{DM};
@@ -1 +1 @@
1
- import{fileURLToPath as a}from"url";import{join as _,dirname as e}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls"],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",300,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(e(a(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuota"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(e(a(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes"],["--static-path","DM_STATIC_PATH",_(e(a(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"]];export{t as default};
1
+ import{fileURLToPath as a}from"url";import{join as _,dirname as e}from"path";const t=[["--port","DM_PORT",8081,"number"],["--plugin","DM_PLUGINS",[],"array","--plugins"],["--log-level","DM_LOG_LEVEL","notice"],["--logger","DM_LOGGER","console"],["--api-prefix","DM_API_PREFIX","/api"],["--mail-domain","DM_MAIL_DOMAIN",[],"array","--mail-domains"],["--ldap-base","DM_LDAP_BASE",""],["--ldap-dn","DM_LDAP_DN","cn=admin,dc=example,dc=com"],["--ldap-pwd","DM_LDAP_PWD","admin"],["--ldap-url","DM_LDAP_URL",["ldap://localhost"],"array","--ldap-urls"],["--ldap-user-main-attribute","DM_LDAP_USER_ATTRIBUTE","uid"],["--ldap-cache-max","DM_LDAP_CACHE_MAX",1e3,"number"],["--ldap-cache-ttl","DM_LDAP_CACHE_TTL",300,"number"],["--ldap-pool-size","DM_LDAP_POOL_SIZE",5,"number"],["--ldap-connection-ttl","DM_LDAP_CONNECTION_TTL",60,"number"],["--schemas-path","DM_SCHEMAS_PATH",_(e(a(import.meta.url)),"..","..","static","schemas")],["--mail-attribute","DM_MAIL_ATTRIBUTE","mail"],["--quota-attribute","DM_QUOTA_ATTRIBUTE","mailQuota"],["--delegation-attribute","DM_DELEGATION_ATTRIBUTE","twakeDelegatedUsers"],["--alias-attribute","DM_ALIAS_ATTRIBUTE","mailAlternateAddress"],["--forward-attribute","DM_FORWARD_ATTRIBUTE","mailForwardingAddress"],["--display-name-attribute","DM_DISPLAY_NAME_ATTRIBUTE","displayName"],["--user-class","DM_USER_CLASSES",["top","twakeAccount","twakeWhitePages"],"array","--user-classes"],["--ldap-top-organization","DM_LDAP_TOP_ORGANIZATION",""],["--ldap-organization-class","DM_LDAP_ORGANIZATION_CLASSES",["top","organizationalUnit","twakeDepartment"],"array","--ldap-organization-classes"],["--ldap-organization-link-attribute","DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE","twakeDepartmentLink"],["--ldap-organization-path-attribute","DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE","twakeDepartmentPath"],["--ldap-organization-path-separator","DM_LDAP_ORGANIZATION_PATH_SEPARATOR"," / "],["--ldap-organization-max-subnodes","DM_LDAP_ORGANIZATION_MAX_SUBNODES",50,"number"],["--ldap-group-base","DM_LDAP_GROUP_BASE",""],["--ldap-groups-main-attribute","DM_LDAP_GROUPS_MAIN_ATTRIBUTE","cn"],["--group-class","DM_GROUP_CLASSES",["top","groupOfNames"],"array","--group-classes"],["--group-allow-unexistent-members","DM_ALLOW_UNEXISTENT_MEMBERS",!1,"boolean"],["--group-default-attributes","DM_GROUP_DEFAULT_ATTRIBUTES",{},"json"],["--group-dummy-user","DM_GROUP_DUMMY_USER","cn=fakeuser"],["--group-schema","DM_GROUP_SCHEMA",_(e(a(import.meta.url)),"..","..","static","schemas","twake","groups.json")],["--external-members-branch","DM_EXTERNAL_MEMBERS_BRANCH","ou=contacts,dc=example,dc=com"],["--external-branch-class","DM_EXTERNAL_BRANCH_CLASSES",["top","inetOrgPerson"],"array","--external-branch-classes"],["--static-path","DM_STATIC_PATH",_(e(a(import.meta.url)),"..","..","static")],["--static-name","DM_STATIC_NAME","static"],["--ldap-flat-schema","DM_LDAP_FLAT_SCHEMA",[],"array","--ldap-flat-schemas"],["--bulk-import-schemas","DM_BULK_IMPORT_SCHEMAS",""],["--bulk-import-max-file-size","DM_BULK_IMPORT_MAX_FILE_SIZE","10485760"],["--bulk-import-batch-size","DM_BULK_IMPORT_BATCH_SIZE","100"],["--james-webadmin-url","DM_JAMES_WEBADMIN_URL","http://localhost:8000"],["--james-webadmin-token","DM_JAMES_WEBADMIN_TOKEN",""],["--james-signature-template","DM_JAMES_SIGNATURE_TEMPLATE",""],["--ldap-concurrency","DM_LDAP_CONCURRENCY",10,"number"],["--james-concurrency","DM_JAMES_CONCURRENCY",10,"number"],["--james-init-delay","DM_JAMES_INIT_DELAY",1e3,"number"],["--james-mailing-list-branch","DM_JAMES_MAILING_LIST_BRANCHES",[],"array","--james-mailing-list-branches"],["--james-mailbox-type-attribute","DM_JAMES_MAILBOX_TYPE_ATTRIBUTE","twakeMailboxType"],["--calendar-webadmin-url","DM_CALENDAR_WEBADMIN_URL","http://localhost:8080"],["--calendar-webadmin-token","DM_CALENDAR_WEBADMIN_TOKEN",""],["--calendar-concurrency","DM_CALENDAR_CONCURRENCY",10,"number"],["--calendar-resource-base","DM_CALENDAR_RESOURCE_BASE",""],["--calendar-resource-objectclass","DM_CALENDAR_RESOURCE_OBJECTCLASS",""],["--calendar-resource-creator","DM_CALENDAR_RESOURCE_CREATOR",""],["--calendar-resource-domain","DM_CALENDAR_RESOURCE_DOMAIN",""],["--applicative-account-base","DM_APPLICATIVE_ACCOUNT_BASE",""],["--max-app-accounts","DM_MAX_APP_ACCOUNTS",5,"number"],["--ldap-operational-attribute","DM_LDAP_OPERATIONAL_ATTRIBUTES",["dn","controls","structuralObjectClass","entryUUID","entryDN","subschemaSubentry","modifyTimestamp","modifiersName","createTimestamp","creatorsName","userPassword"],"array","--ldap-operational-attributes"],["--trash-base","DM_TRASH_BASE",""],["--trash-watched-bases","DM_TRASH_WATCHED_BASES",""],["--trash-add-metadata","DM_TRASH_ADD_METADATA","true"],["--trash-auto-create","DM_TRASH_AUTO_CREATE","true"],["--llng-ini","DM_LLNG_INI","/etc/lemonldap-ng/lemonldap-ng.ini"],["--auth-token","DM_AUTH_TOKENS",[],"array","--auth-tokens"],["--auth-totp","DM_AUTH_TOTP",[],"array","--auth-totps"],["--auth-totp-window","DM_AUTH_TOTP_WINDOW",1,"number"],["--auth-totp-step","DM_AUTH_TOTP_STEP",30,"number"],["--auth-hmac","DM_AUTH_HMAC",[],"array","--auth-hmacs"],["--auth-hmac-window","DM_AUTH_HMAC_WINDOW",12e4,"number"],["--authz-per-branch-config","DM_AUTHZ_PER_BRANCH_CONFIG",{default:{read:!0,write:!1,delete:!1}},"json"],["--authz-per-branch-cache-ttl","DM_AUTHZ_PER_BRANCH_CACHE_TTL",60,"number"],["--authz-local-admin-attribute","DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE","twakeLocalAdminLink"],["--oidc-server","DM_OIDC_SERVER",""],["--oidc-client-id","DM_OIDC_CLIENT_ID",""],["--oidc-client-secret","DM_OIDC_CLIENT_SECRET",""],["--base-url","DM_BASE_URL",""],["--rate-limit-window-ms","DM_RATE_LIMIT_WINDOW_MS",9e5,"number"],["--rate-limit-max","DM_RATE_LIMIT_MAX",100,"number"],["--crowdsec-url","DM_CROWDSEC_URL","http://localhost:8080/v1/decisions"],["--crowdsec-api-key","DM_CROWDSEC_API_KEY",""],["--crowdsec-cache-ttl","DM_CROWDSEC_CACHE_TTL",60,"number"],["--trusted-proxy","DM_TRUSTED_PROXIES",[],"array","--trusted-proxies"],["--trusted-proxy-auth-header","DM_TRUSTED_PROXY_AUTH_HEADER","Auth-User"]];export{t as default};
@@ -0,0 +1 @@
1
+ class HttpError extends Error{constructor(r,o=500){super(r),this.statusCode=o,this.name="HttpError"}}class BadRequestError extends HttpError{constructor(r="Bad request"){super(r,400),this.name="BadRequestError"}}class UnauthorizedError extends HttpError{constructor(r="Unauthorized"){super(r,401),this.name="UnauthorizedError"}}class ForbiddenError extends HttpError{constructor(r="Forbidden"){super(r,403),this.name="ForbiddenError"}}class NotFoundError extends HttpError{constructor(r="Not found"){super(r,404),this.name="NotFoundError"}}class ConflictError extends HttpError{constructor(r="Conflict"){super(r,409),this.name="ConflictError"}}class UriTooLongError extends HttpError{constructor(r="URI Too Long"){super(r,414),this.name="UriTooLongError"}}class TooManyRequestsError extends HttpError{constructor(r="Too Many Requests"){super(r,429),this.name="TooManyRequestsError"}}class BadGatewayError extends HttpError{constructor(r="Bad Gateway"){super(r,502),this.name="BadGatewayError"}}class ServiceUnavailableError extends HttpError{constructor(r="Service Unavailable"){super(r,503),this.name="ServiceUnavailableError"}}class GatewayTimeoutError extends HttpError{constructor(r="Gateway Timeout"){super(r,504),this.name="GatewayTimeoutError"}}export{BadGatewayError,BadRequestError,ConflictError,ForbiddenError,GatewayTimeoutError,HttpError,NotFoundError,ServiceUnavailableError,TooManyRequestsError,UnauthorizedError,UriTooLongError};
@@ -1 +1 @@
1
- let r;const setLogger=t=>{r=t},getLogger=()=>r,tryMethod=async(r,t,...e)=>{try{return await t(...e),ok(r)}catch(t){return serverError(r,t)}},tryMethodData=async(r,t,...e)=>{try{const n=await t(...e);return ok(r,n)}catch(t){return serverError(r,t)}},_rejectResponse=(r,t,e)=>{t.status(r).json({error:e})},ok=(r,t={success:!0})=>{r.status(200).json(t)},created=(r,t)=>{r.status(201).json(t)},badRequest=(r,t="Bad request")=>_rejectResponse(400,r,t),unauthorized=(r,t="Unauthorized")=>_rejectResponse(401,r,t),notFound=(r,t="Not found")=>_rejectResponse(404,r,t),serverError=(t,e)=>{e instanceof Error?r.error(e.message,e):"string"==typeof e?r.error(e):r.error("Server error",e),t.status(500).json({error:"check logs"})},wantJson=(r,t)=>!1!==r.accepts("json")||(badRequest(t),!1),jsonBody=(r,t,...e)=>{try{if(!wantJson(r,t))return!1;const n=r.body;if(e.length>0)for(let r=0;r<e.length;r++)if(null==n[e[r]])return badRequest(t,"Bad content"),!1;return n}catch(r){return badRequest(t,"Bad content"),!1}};export{badRequest,created,getLogger,jsonBody,notFound,ok,serverError,setLogger,tryMethod,tryMethodData,unauthorized,wantJson};
1
+ let r;const setLogger=t=>{r=t},getLogger=()=>r,tryMethod=async(r,t,...e)=>{try{return await t(...e),ok(r)}catch(t){return serverError(r,t)}},tryMethodData=async(r,t,...e)=>{try{const s=await t(...e);return ok(r,s)}catch(t){return serverError(r,t)}},_rejectResponse=(r,t,e)=>{t.status(r).json({error:e})},ok=(r,t={success:!0})=>{r.status(200).json(t)},created=(r,t)=>{r.status(201).json(t)},badRequest=(r,t="Bad request")=>_rejectResponse(400,r,t),unauthorized=(r,t="Unauthorized")=>_rejectResponse(401,r,t),notFound=(r,t="Not found")=>_rejectResponse(404,r,t),serverError=(t,e)=>{const s=e instanceof Error&&"statusCode"in e?e.statusCode:500;if(s>=400&&s<500){const n=e instanceof Error?e.message:"Bad request";return r.warn(`Client error ${s}: ${n}`),void t.status(s).json({error:n})}e instanceof Error?r.error(e.message,e):"string"==typeof e?r.error(e):r.error("Server error",e),t.status(s).json({error:"check logs"})},wantJson=(r,t)=>!1!==r.accepts("json")||(badRequest(t),!1),jsonBody=(r,t,...e)=>{try{if(!wantJson(r,t))return!1;const s=r.body;if(e.length>0)for(let r=0;r<e.length;r++)if(null==s[e[r]])return badRequest(t,"Bad content"),!1;return s}catch(r){return badRequest(t,"Bad content"),!1}};export{badRequest,created,getLogger,jsonBody,notFound,ok,serverError,setLogger,tryMethod,tryMethodData,unauthorized,wantJson};
package/dist/lib/utils.js CHANGED
@@ -1 +1 @@
1
- import{getLogger as t}from"./expressFormatedResponses.js";const e=t(),launchHooks=async(t,...n)=>{if(t)for(const r of t)if(r)try{await r(...n)}catch(t){e.error("Hook error",t)}},launchHooksChained=async(t,e)=>{if(t)for(const n of t)n&&(e=await n(e));return e},transformSchemas=(t,e)=>t.toString().replace(/__(\S+)__/g,(t,n)=>{if(!n||"string"!=typeof n)return t;const r=n.trim().toLowerCase();return e[r]?"object"!=typeof e[r]?e[r]:JSON.stringify(e[r]):t});function parseDn(t){const e=[];let n="",r=!1;for(let o=0;o<t.length;o++){const s=t[o];r?(n+=s,r=!1):"\\"===s?(n+=s,r=!0):","===s?(e.push(n.trim()),n=""):n+=s}return n&&e.push(n.trim()),e}function getParentDn(t){const e=parseDn(t);return e.length<=1?t:e.slice(1).join(",")}function getRdn(t){return parseDn(t)[0]||""}function isChildOf(t,e){const n=t.toLowerCase(),r=e.toLowerCase();if(!n.endsWith(r))return!1;if(n.length===r.length)return!1;return n.substring(0,n.length-r.length).endsWith(",")}const asyncHandler=t=>(e,n,r)=>{Promise.resolve(t(e,n,r)).catch(r)};export{asyncHandler,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas};
1
+ import{getLogger as e}from"./expressFormatedResponses.js";const t=e(),launchHooks=async(e,...r)=>{if(e)for(const n of e)if(n)try{await n(...r)}catch(e){t.error("Hook error",e)}},launchHooksChained=async(e,t)=>{if(e)for(const r of e)r&&(t=await r(t));return t},transformSchemas=(e,t)=>e.toString().replace(/__(\S+)__/g,(e,r)=>{if(!r||"string"!=typeof r)return e;const n=r.trim().toLowerCase();return t[n]?"object"!=typeof t[n]?t[n]:JSON.stringify(t[n]):e});function escapeLdapFilter(e){return e.replace(/\\/g,"\\5c").replace(/\*/g,"\\2a").replace(/\(/g,"\\28").replace(/\)/g,"\\29").replace(/\0/g,"\\00")}function parseDn(e){const t=[];let r="",n=!1;for(let o=0;o<e.length;o++){const s=e[o];n?(r+=s,n=!1):"\\"===s?(r+=s,n=!0):","===s?(t.push(r.trim()),r=""):r+=s}return r&&t.push(r.trim()),t}function getParentDn(e){const t=parseDn(e);return t.length<=1?e:t.slice(1).join(",")}function getRdn(e){return parseDn(e)[0]||""}function isChildOf(e,t){const r=e.toLowerCase(),n=t.toLowerCase();if(!r.endsWith(n))return!1;if(r.length===n.length)return!1;return r.substring(0,r.length-n.length).endsWith(",")}const asyncHandler=e=>(t,r,n)=>{Promise.resolve(e(t,r,n)).catch(n)};export{asyncHandler,escapeLdapFilter,getParentDn,getRdn,isChildOf,launchHooks,launchHooksChained,parseDn,transformSchemas};
@@ -0,0 +1 @@
1
+ import r from"../../abstract/plugin.js";class TrustedProxy extends r{constructor(...r){super(...r),this.name="trustedProxy",this.roles=["protect"],this.trustedNetworks=[];const e=this.config.trusted_proxy;if(!e||!Array.isArray(e)||0===e.length)throw new Error("TrustedProxy plugin requires trusted_proxy configuration (array of IPs or CIDR ranges)");for(const r of e)try{this.trustedNetworks.push(this.parseCIDR(r))}catch(e){throw new Error(`Invalid trusted proxy address "${r}": ${e instanceof Error?e.message:String(e)}`)}this.authHeader=(this.config.trusted_proxy_auth_header||"Auth-User").toLowerCase(),this.logger.info(`Trusted proxy validation enabled for ${e.length} address(es): ${e.join(", ")} (auth header: ${this.authHeader})`)}api(r){r.use((r,e,s)=>{const t=r.socket.remoteAddress;if(!t)return this.logger.debug("No remote address, removing X-Forwarded-For header"),delete r.headers["x-forwarded-for"],s();const o=r.headers["x-forwarded-for"];if(this.isTrustedProxy(t)){r.trustedProxy=!0;const e=r.headers[this.authHeader];e?(r.proxyAuthUser=Array.isArray(e)?e[0]:e,this.logger.debug(`Request from trusted proxy ${t}, Auth-User: ${r.proxyAuthUser}, X-Forwarded-For: ${o?String(o):"(none)"}`)):this.logger.debug(`Request from trusted proxy ${t}, X-Forwarded-For: ${o?String(o):"(none)"}`)}else r.trustedProxy=!1,o&&(this.logger.warn(`Removing X-Forwarded-For header from untrusted source ${t} (was: ${String(o)})`),delete r.headers["x-forwarded-for"]);s()})}isTrustedProxy(r){try{const e=this.normalizeIP(r),s=this.parseIP(e);for(const r of this.trustedNetworks)if(s.isIPv6===r.isIPv6&&(s.ip&r.mask)===(r.ip&r.mask))return!0;return!1}catch(e){return this.logger.error(`Failed to parse IP address "${r}": ${e instanceof Error?e.message:String(e)}`),!1}}normalizeIP(r){const e=r.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);return e?e[1]:r}parseIP(r){return r.includes(":")?{ip:this.parseIPv6(r),isIPv6:!0}:{ip:this.parseIPv4(r),isIPv6:!1}}parseCIDR(r){const[e,s]=r.split("/"),t=e.includes(":");let o,n;const i=t?128:32;if(o=t?this.parseIPv6(e):this.parseIPv4(e),void 0!==s){const r=parseInt(s,10);if(isNaN(r)||r<0||r>i)throw new Error(`Invalid prefix length: ${s}`);if(0===r)n=0n;else{const e=BigInt(i);n=(1n<<e)-1n^(1n<<e-BigInt(r))-1n}}else n=t?(1n<<128n)-1n:(1n<<32n)-1n;return{ip:o,mask:n,isIPv6:t}}parseIPv4(r){const e=r.split(".");if(4!==e.length)throw new Error(`Invalid IPv4 address: ${r}`);let s=0n;for(const t of e){const e=parseInt(t,10);if(isNaN(e)||e<0||e>255)throw new Error(`Invalid IPv4 address: ${r}`);s=s<<8n|BigInt(e)}return s}parseIPv6(r){let e=r;if(r.includes("::")){const s=r.split("::");if(s.length>2)throw new Error(`Invalid IPv6 address: ${r}`);const t=s[0]?s[0].split(":"):[],o=s[1]?s[1].split(":"):[],n=8-t.length-o.length;if(n<0)throw new Error(`Invalid IPv6 address: ${r}`);const i=Array(n).fill("0");e=[...t,...i,...o].join(":")}const s=e.split(":");if(8!==s.length)throw new Error(`Invalid IPv6 address: ${r}`);let t=0n;for(const e of s){const s=parseInt(e||"0",16);if(isNaN(s)||s<0||s>65535)throw new Error(`Invalid IPv6 address: ${r}`);t=t<<16n|BigInt(s)}return t}}export{TrustedProxy as default};
@@ -1 +1 @@
1
- import e from"fs";import t from"../../abstract/plugin.js";import{wantJson as r,badRequest as i,ok as s,serverError as a,jsonBody as o,tryMethod as n,notFound as d}from"../../lib/expressFormatedResponses.js";import{transformSchemas as c,launchHooksChained as h,launchHooks as p}from"../../lib/utils.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async e=>{let t=e;return Array.isArray(t)||(t=[t]),this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)}),e}},this.ldap=t.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");this.config.group_schema&&e.readFile(this.config.group_schema,(e,t)=>{if(e)this.logger.error(`Failed to load group schema from ${this.config.group_schema}: ${e}`);else try{this.schema=JSON.parse(c(t.toString(),this.config)),this.logger.debug("Group schema loaded")}catch(e){this.logger.error(`Failed to parse ${this.config.schemas_path}/group.json: ${e}`)}})}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,async(e,t)=>{if(r(e,t))try{const r={};if(e.query.match&&("string"!=typeof e.query.match||!/^[\w*=()&|]+$/.test(e.query.match)))return i(t,"Invalid match query");e.query.match&&(r.filter=/=/.test(e.query.match)?`${e.query.match}`:`(${this.cn}=${e.query.match})`),e.query.attributes&&"string"==typeof e.query.attributes&&(r.attributes=e.query.attributes.split(","));const a=await this.listGroups(r);return s(t,a)}catch(e){return a(t,e)}}),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiGet(e,t)),e.post(`${this.config.api_prefix}/v1/ldap/groups`,async(e,t)=>this.apiAdd(e,t,this.cn)),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiDelete(e,t)),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,async(e,t)=>this.apiModify(e,t)),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)return i(t,"cn is required");const s=o(e,t,"member");s&&await n(t,this.addMember.bind(this),r,s.member)}),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!s||!a)return i(t,"cn and member are required");await n(t,this.deleteMember.bind(this),s,a)}),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");const a=o(e,t,"targetOrgDn");a&&await n(t,this.moveGroup.bind(this),s,a.targetOrgDn,e)}),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,async(e,t)=>{if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");const a=o(e,t,"newCn");a&&await n(t,this.renameGroup.bind(this),s,a.newCn)})}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${i},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)return d(t,"Group not found");t.json(r.searchEntries[0])}catch(e){return e.code&&32===e.code?d(t,"Group not found"):a(t,e)}}async apiAdd(e,t,...r){const i=o(e,t,...r);if(!i)return;const s=i[this.cn],a=i.member?i.member:[],d=Object.fromEntries(Object.entries(i).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await n(t,this.addGroup.bind(this),s,a,d)}async apiDelete(e,t){if(!r(e,t))return;const s=decodeURIComponent(e.params.cn);if(!s)return i(t,"cn is required");await n(t,this.deleteGroup.bind(this),s)}async apiModify(e,t){const r=o(e,t);if(!r)return;const s=this.fixDn(decodeURIComponent(e.params.cn));if(!s)return i(t);const a=Object.fromEntries(Object.entries(r).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await n(t,this.modifyGroup.bind(this),s,a)}async addGroup(e,t=[],r={}){let i;new RegExp(`^${this.cn}=`).test(e)?(i=e,e=e.replace(new RegExp(`^${this.cn}=([^,]+).*`),"$1")):i=`${this.cn}=${e},${this.base}`,await this.validateMembers(i,t),await this.validateNewGroup(i,{objectClass:this.config.group_class,cn:e,member:t,...r});const s=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");let a,o={objectClass:s,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&o.member.push(...t),[i,o]=await h(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){throw new Error(`Failed to add group ${i}: ${e}`)}return p(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;const i=this.opNumber();if([r,t]=await h(this.registeredHooks.ldapgroupmodify,[r,t,i]),t.add){if(t.add.member)throw new Error("Use dedicated API to add members");if(t.add[this.cn])throw new Error(`${this.cn} attribute iq unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new Error("Use dedicated API to delete members");if(t.delete[this.cn])throw new Error(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new Error("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new Error(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new Error("Use dedicated API to replace members");if(t.replace[this.cn])throw new Error(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(r,t);const s=await this.ldap.modify(r,t);return p(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${t},${this.base}`;[r,i]=await h(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return p(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;t=await h(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return p(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await h(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;if([e,t]=await h(this.registeredHooks.ldapgroupdeletemember,[e,t]),t===this.config.group_dummy_user)throw new Error("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{throw new Error(`Failed to delete member ${t} from ${r}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${e}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r))))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,o=await this.ldap.search({paged:!1,scope:"base"},a);if(0===o.searchEntries.length)throw new Error(`Group ${a} not found`);const n=o.searchEntries[0][i];if(!n)throw new Error(`Group ${a} does not have ${i} attribute and cannot be moved`);if(n===t)throw new Error("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new Error(`Target organization ${t} not found`);const c=d.searchEntries[0][s];if(!c)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:c}},r),this.logger.info(`Group ${a} moved from ${n} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t}={}){const r=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let i={};for await(const e of r)e.searchEntries.map(e=>{const t=e[this.cn];t&&(i[t]=e),Array.isArray(i[t].member)||(i[t].member=[i[t].member]),i[t].member=i[t].member.filter(e=>e!==this.config.group_dummy_user)});return i=await h(this.registeredHooks._ldapgrouplist,i),i}async searchGroupsByName(e,t=!1,r=[this.cn,"member"]){const i=t?`(${this.cn}=*${e}*)`:`(${this.cn}=${e})`;return await this.listGroups({filter:i,attributes:r})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${e},${this.base}`)}async validateMembers(e,t){if([e,t]=await h(this.server.hooks.ldapgroupvalidatemembers,[e,t]),!this.config.groups_allow_unexistent_members&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{try{await this.ldap.search({paged:!1,scope:"base"},e)}catch(t){throw new Error(`Member ${e} not found: ${t}`)}})))}catch(e){throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return!0;[e,t]=await h(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new Error(`Invalid value for field ${e}`);for(const[e,r]of Object.entries(this.schema.attributes))if(r.required&&null==t[e])throw new Error(`Missing required field ${e}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await h(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))await this._validateOneChange(e,r);if(t.replace)for(const[e,r]of Object.entries(t.replace))await this._validateOneChange(e,r);if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new Error(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new Error(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new Error(`Field ${e} must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test){"string"==typeof r.items.test&&(r.items.test=new RegExp(r.items.test));for(let i of t){if(typeof i!==r.items.type)throw new Error(`Field ${e} must be of type ${r.items.type}`);if("string"!=typeof i&&(i=i.toString()),r.items.test&&!r.items.test.test(i))throw new Error(`Field ${e} has invalid value ${i}`)}}}else if("pointer"===r.type){if("string"!=typeof t)throw new Error(`Field ${e} must be a string (DN pointer)`);const i=t;if(r.branch&&r.branch.length>0){if(!r.branch.some(e=>new RegExp(`,?${e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}$`,"i").test(i)))throw new Error(`Field ${e} must point to a DN within allowed branches: ${r.branch.join(", ")}`)}try{const t=await this.ldap.search({paged:!1,scope:"base"},i);if(!t||!t.searchEntries||0===t.searchEntries.length)throw new Error(`Field ${e} points to non-existent DN: ${i}`)}catch(t){throw new Error(`Field ${e} points to invalid or non-existent DN: ${i}: ${t}`)}if(r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(i)))throw new Error(`Field ${e} has invalid value ${i}`)}else{if(typeof t!==r.type)return!1;if("string"!=typeof t&&(t=t.toString()),r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(t)))throw new Error(`Field ${e} has invalid value ${t}`)}return!0}getConfigApiData(){const e=this.config.api_prefix||"/api";let t;if(this.server.loadedPlugins.static&&this.config.group_schema){const e=this.config.static_name||"static",r=this.config.group_schema.indexOf("/schemas/");if(-1!==r){t=`/${e}${this.config.group_schema.substring(r)}`}}return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
1
+ import e from"fs";import t from"../../abstract/plugin.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{transformSchemas as o,asyncHandler as n,launchHooksChained as d,launchHooks as h}from"../../lib/utils.js";import{NotFoundError as c,BadRequestError as p}from"../../lib/errors.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async e=>{let t=e;return Array.isArray(t)||(t=[t]),this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)}),e}},this.ldap=t.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");this.config.group_schema&&e.readFile(this.config.group_schema,(e,t)=>{if(e)this.logger.error(`Failed to load group schema from ${this.config.group_schema}: ${e}`);else try{this.schema=JSON.parse(o(t.toString(),this.config)),this.logger.debug("Group schema loaded")}catch(e){this.logger.error(`Failed to parse ${this.config.schemas_path}/group.json: ${e}`)}})}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,n(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match&&("string"!=typeof e.query.match||!/^[\w*=()&|]+$/.test(e.query.match)))throw new p("Invalid match query");e.query.match&&(i.filter=/=/.test(e.query.match)?`${e.query.match}`:`(${this.cn}=${e.query.match})`),e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups(i);return a(t,s)})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,n(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,n(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,n(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new p("cn is required");const a=i(e,t,"member");a&&await s(t,this.addMember.bind(this),r,a.member)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,n(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new p("cn and member are required");await s(t,this.deleteMember.bind(this),i,a)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,n(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new p("cn is required");const o=i(e,t,"targetOrgDn");o&&await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,n(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new p("cn is required");const o=i(e,t,"newCn");o&&await s(t,this.renameGroup.bind(this),a,o.newCn)}))}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${i},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)throw new c("Group not found");t.json(r.searchEntries[0])}catch(e){if(e.code&&32===e.code)throw new c("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;const o=a[this.cn],n=a.member?a.member:[],d=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,d)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new p("cn is required");await s(t,this.deleteGroup.bind(this),i)}async apiModify(e,t){const r=i(e,t);if(!r)return;const a=this.fixDn(decodeURIComponent(e.params.cn));if(!a)throw new p("cn is required");const o=Object.fromEntries(Object.entries(r).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.modifyGroup.bind(this),a,o)}async addGroup(e,t=[],r={}){let i;new RegExp(`^${this.cn}=`).test(e)?(i=e,e=e.replace(new RegExp(`^${this.cn}=([^,]+).*`),"$1")):i=`${this.cn}=${e},${this.base}`,await this.validateMembers(i,t),await this.validateNewGroup(i,{objectClass:this.config.group_class,cn:e,member:t,...r});const s=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");let a,o={objectClass:s,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&o.member.push(...t),[i,o]=await d(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){throw new Error(`Failed to add group ${i}: ${e}`)}return h(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;const i=this.opNumber();if([r,t]=await d(this.registeredHooks.ldapgroupmodify,[r,t,i]),t.add){if(t.add.member)throw new Error("Use dedicated API to add members");if(t.add[this.cn])throw new Error(`${this.cn} attribute iq unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new Error("Use dedicated API to delete members");if(t.delete[this.cn])throw new Error(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new Error("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new Error(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new Error("Use dedicated API to replace members");if(t.replace[this.cn])throw new Error(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(r,t);const s=await this.ldap.modify(r,t);return h(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${t},${this.base}`;[r,i]=await d(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return h(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;t=await d(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return h(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await d(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${e},${this.base}`;if([e,t]=await d(this.registeredHooks.ldapgroupdeletemember,[e,t]),t===this.config.group_dummy_user)throw new Error("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{throw new Error(`Failed to delete member ${t} from ${r}: ${e}`)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${e}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.ldap.modify(t.dn,{delete:{member:e}}).catch(r=>this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r))))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${e},${this.base}`,o=await this.ldap.search({paged:!1,scope:"base"},a);if(0===o.searchEntries.length)throw new Error(`Group ${a} not found`);const n=o.searchEntries[0][i];if(!n)throw new Error(`Group ${a} does not have ${i} attribute and cannot be moved`);if(n===t)throw new Error("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new Error(`Target organization ${t} not found`);const h=d.searchEntries[0][s];if(!h)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:h}},r),this.logger.info(`Group ${a} moved from ${n} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t}={}){const r=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let i={};for await(const e of r)e.searchEntries.map(e=>{const t=e[this.cn];t&&(i[t]=e),Array.isArray(i[t].member)||(i[t].member=[i[t].member]),i[t].member=i[t].member.filter(e=>e!==this.config.group_dummy_user)});return i=await d(this.registeredHooks._ldapgrouplist,i),i}async searchGroupsByName(e,t=!1,r=[this.cn,"member"]){const i=t?`(${this.cn}=*${e}*)`:`(${this.cn}=${e})`;return await this.listGroups({filter:i,attributes:r})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${e},${this.base}`)}async validateMembers(e,t){if([e,t]=await d(this.server.hooks.ldapgroupvalidatemembers,[e,t]),!this.config.groups_allow_unexistent_members&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{try{await this.ldap.search({paged:!1,scope:"base"},e)}catch(t){throw new Error(`Member ${e} not found: ${t}`)}})))}catch(e){throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return!0;[e,t]=await d(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new Error(`Invalid value for field ${e}`);for(const[e,r]of Object.entries(this.schema.attributes))if(r.required&&null==t[e])throw new Error(`Missing required field ${e}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await d(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))await this._validateOneChange(e,r);if(t.replace)for(const[e,r]of Object.entries(t.replace))await this._validateOneChange(e,r);if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new Error(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new Error(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new Error(`Field ${e} must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test){"string"==typeof r.items.test&&(r.items.test=new RegExp(r.items.test));for(let i of t){if(typeof i!==r.items.type)throw new Error(`Field ${e} must be of type ${r.items.type}`);if("string"!=typeof i&&(i=i.toString()),r.items.test&&!r.items.test.test(i))throw new Error(`Field ${e} has invalid value ${i}`)}}}else if("pointer"===r.type){if("string"!=typeof t)throw new Error(`Field ${e} must be a string (DN pointer)`);const i=t;if(r.branch&&r.branch.length>0){if(!r.branch.some(e=>new RegExp(`,?${e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}$`,"i").test(i)))throw new Error(`Field ${e} must point to a DN within allowed branches: ${r.branch.join(", ")}`)}try{const t=await this.ldap.search({paged:!1,scope:"base"},i);if(!t||!t.searchEntries||0===t.searchEntries.length)throw new Error(`Field ${e} points to non-existent DN: ${i}`)}catch(t){throw new Error(`Field ${e} points to invalid or non-existent DN: ${i}: ${t}`)}if(r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(i)))throw new Error(`Field ${e} has invalid value ${i}`)}else{if(typeof t!==r.type)return!1;if("string"!=typeof t&&(t=t.toString()),r.test&&("string"==typeof r.test&&(r.test=new RegExp(r.test)),r.test&&!r.test.test(t)))throw new Error(`Field ${e} has invalid value ${t}`)}return!0}getConfigApiData(){const e=this.config.api_prefix||"/api";let t;if(this.server.loadedPlugins.static&&this.config.group_schema){const e=this.config.static_name||"static",r=this.config.group_schema.indexOf("/schemas/");if(-1!==r){t=`/${e}${this.config.group_schema.substring(r)}`}}return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
@@ -1 +1 @@
1
- import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,badRequest as e,jsonBody as r,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{transformSchemas as o,launchHooksChained as h}from"../../lib/utils.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a])=>{let e,r={},n={};const checkIsOu=async()=>{if(void 0!==e)return e;if(i.replace?.objectClass)e=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)e=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);e=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return e};if(i.delete){const t=Array.isArray(i.delete)?i.delete.includes(this.linkAttr):i.delete[this.linkAttr],a=Array.isArray(i.delete)?i.delete.includes(this.pathAttr):i.delete[this.pathAttr];if(t||a){if(!await checkIsOu()&&t)throw new Error("An organization link cannot be deleted");if(a)throw new Error("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(r={...i.replace}),i.replace[this.pathAttr]&&(n={...i.replace})),i.add&&(i.add[this.linkAttr]&&(r={...r,...i.add}),i.add[this.pathAttr]&&(n={...n,...i.add})),Object.keys(r).length>0){await checkIsOu()||await this.checkDeptLink(r)}if(Object.keys(n).length>0){await checkIsOu()&&await this.checkDeptPath(n)}return[t,i,a]},ldapdeleterequest:async([t])=>(/^ou=/.test(t)&&await this.isEmptyOrganization(t),[t]),ldaprenamerequest:([t,i])=>[t,i]},!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(o(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,this.getOrganisationTop.bind(this),t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationByDn.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationSubnodes.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,async(t,i)=>{const r=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)return e(i,'query parameter "q" is required');await a(i,this.searchOrganisationSubnodes.bind(this),r,n)}),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,async(t,i)=>this.apiAdd(t,i)),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>this.apiModify(t,i)),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>this.apiDelete(t,i)),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,async(t,i)=>this.apiMove(t,i))}async apiAdd(t,i){const a=r(t,i,"ou");if(!a)return;const e=a.parentDn||this.config.ldap_top_organization,s=`ou=${a.ou},${e}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),s,o,t)}async apiModify(t,i){const a=r(t,i);if(!a)return;const s=decodeURIComponent(t.params.dn);if(!s)return e(i,"dn is required");await n(i,this.modifyOrganization.bind(this),s,a)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)return e(i,"dn is required");await n(i,this.deleteOrganization.bind(this),a)}async apiMove(t,i){if(!s(t,i))return;const n=r(t,i,"targetOrgDn");if(!n)return;const o=decodeURIComponent(t.params.dn);if(!o)return e(i,"dn is required");const{targetOrgDn:h}=n;if(!h||"string"!=typeof h)return e(i,"Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),o,h,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new Error(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new Error(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t){if(t[this.pathAttr]){const i=t[this.pathAttr],a=Array.isArray(i)?i[0]:i,e=this.config.ldap_organization_path_separator||" / ";let r=a;if(this.isOu(t)){const i=t.ou,n=Array.isArray(i)?i[0]:i;if(!a.startsWith(n+e))throw new Error(`Organization path must start with its own name followed by separator "${e}"`);r=a.slice(n.length+e.length)}const[n,s]=r.split(e,2),o=await this.server.ldap.search({paged:!1,filter:`(ou=${n})`,scope:"sub"},this.config.ldap_top_organization);if(0===o.searchEntries.length)throw new Error(`Invalid organization path ${a}`);if(s){let t=!1;for(const i of o.searchEntries){const a=i[this.pathAttr],e=Array.isArray(a)?a[0]:a;if(e&&e===s){t=!0;break}}if(!t)throw new Error(`Invalid organization path ${a}: no matching entry for ${n} with path ${s}`)}else{let t=!1;for(const i of o.searchEntries){const a=i.dn,r=this.config.ldap_top_organization;if(a.toLowerCase()===r.toLowerCase()||a.toLowerCase().endsWith(`,${r.toLowerCase()}`)){const a=i[this.pathAttr],r=Array.isArray(a)?a[0]:a;if(!r||r===n||!r.includes(e)){t=!0;break}}}if(!t)throw new Error(`Invalid organization path ${a}: no matching top-level entry for ${n}`)}}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${t})`})).searchEntries.length>0)throw new Error(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=t.objectClass.map(t=>t.toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new Error("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new Error("Top organization not found");const[,a]=await h(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new Error(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,r=i?.query?.objectClass;if(!r||"organizationalUnit"===r)try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:"(objectClass=organizationalUnit)"},t,i);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs for ${t}`),a.push(...e.searchEntries)}catch(i){this.server.logger.debug(`No sub-OUs found for ${t}: ${i}`)}const n=this.config.ldap_top_organization.replace(/^ou=[^,]+,/,"");let s=`(${this.config.ldap_organization_link_attribute}=${t})`;r&&(s=`(&${s}(objectClass=${r}))`),this.server.logger.debug(`Searching for linked entities with filter: ${s} in ${n}`);const o=await this.server.ldap.search({paged:!0,filter:s},n,i);let h=0,c=0;for await(const t of o){c+=t.searchEntries.length;const i=e-h;if(i>0){const e=t.searchEntries.slice(0,i);a.push(...e),h+=e.length}}return this.server.logger.debug(`Found ${c} linked entities for ${t}, returning ${h}`),c>e&&a.push({dn:`more-${t}`,cn:[`... ${c-e} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:c.toString(),_displayedCount:e.toString()}),a}async addOrganization(t,i,a){return this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i){return this.validateChanges(t,i),await this.server.ldap.modify(t,i)}validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!this._validateOneChange(t,a))throw new Error(`Invalid value for field ${t}`);for(const[t,a]of Object.entries(this.schema.attributes))if(a.required&&null==i[t])throw new Error(`Missing required field ${t}`);return!0}validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!this._validateOneChange(t,a))throw new Error(`Invalid value for field ${t}`);if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!this._validateOneChange(t,a))throw new Error(`Invalid value for field ${t}`);return!0}_validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new Error(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new Error(`Field ${t} is required`);return!0}if(a.type)switch(a.type){case"string":if(Array.isArray(i))throw new Error(`Field ${t} must be a single value`);break;case"array":if(!Array.isArray(i))throw new Error(`Field ${t} must be an array`)}if(a.test){const e=Array.isArray(i)?i[0]:i;if(!("string"==typeof a.test?new RegExp(a.test):a.test).test(String(e)))throw new Error(`Field ${t} does not match required pattern`)}return!0}async moveOrganization(t,i,a){try{const t=await this.server.ldap.search({paged:!1,scope:"base"},i);if(!t.searchEntries||0===t.searchEntries.length)throw new Error(`Target organization ${i} not found`);if(!this.isOu(t.searchEntries[0]))throw new Error(`Target ${i} is not an organizational unit`)}catch(t){throw new Error(`Invalid target organization: ${t}`)}const e=`${t.split(",")[0]},${i}`;try{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);if(!i.searchEntries||0===i.searchEntries.length)throw new Error(`Source organization ${t} not found`)}catch(t){throw new Error(`Source organization not found: ${t}`)}if(e===t)throw new Error("Cannot move organization to its current location");if(i===t||i.endsWith(`,${t}`))throw new Error("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,e,a),this.logger.info(`Moved organization from ${t} to ${e}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:e}}async deleteOrganization(t){return await this.server.ldap.delete(t)}async searchOrganisationSubnodes(t,i){const a=[];try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:`(&(objectClass=organizationalUnit)(|(ou=*${i}*)(description=*${i}*)))`},t);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs matching "${i}" for ${t}`),a.push(...e.searchEntries)}catch(a){this.server.logger.debug(`No sub-OUs found matching "${i}" for ${t}: ${a}`)}const e=this.config.ldap_top_organization.replace(/^ou=[^,]+,/,""),r=`(&(${this.config.ldap_organization_link_attribute}=${t})(|(uid=*${i}*)(cn=*${i}*)(mail=*${i}*)(sn=*${i}*)(givenName=*${i}*)))`;this.server.logger.debug(`Searching for linked entities with filter: ${r} in ${e}`);const n=await this.server.ldap.search({paged:!0,filter:r},e);let s=0;for await(const t of n)s+=t.searchEntries.length,a.push(...t.searchEntries);return this.server.logger.debug(`Found ${s} linked entities matching "${i}" for ${t}`),a}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{LdapOrganizations as default};
1
+ import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as r}from"../../lib/expressFormatedResponses.js";import{transformSchemas as s,asyncHandler as o,launchHooksChained as h}from"../../lib/utils.js";import{BadRequestError as c,NotFoundError as d,ConflictError as g}from"../../lib/errors.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a])=>{let e,n={},r={};const checkIsOu=async()=>{if(void 0!==e)return e;if(i.replace?.objectClass)e=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)e=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);e=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return e};if(i.delete){const t=Array.isArray(i.delete)?i.delete.includes(this.linkAttr):i.delete[this.linkAttr],a=Array.isArray(i.delete)?i.delete.includes(this.pathAttr):i.delete[this.pathAttr];if(t||a){if(!await checkIsOu()&&t)throw new c("An organization link cannot be deleted");if(a)throw new c("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(n={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(n={...n,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(n).length>0){await checkIsOu()||await this.checkDeptLink(n)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r)}return[t,i,a]},ldapdeleterequest:async([t])=>(/^ou=/.test(t)&&await this.isEmptyOrganization(t),[t]),ldaprenamerequest:([t,i])=>[t,i]},!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(s(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,this.getOrganisationTop.bind(this),t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationByDn.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,this.getOrganisationSubnodes.bind(this),e,t)}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new c('query parameter "q" is required');await a(i,this.searchOrganisationSubnodes.bind(this),e,n)})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;const r=a.parentDn||this.config.ldap_top_organization,s=`ou=${a.ou},${r}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),s,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new c("dn is required");await n(i,this.modifyOrganization.bind(this),r,a)}async apiDelete(t,i){if(!r(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new c("dn is required");await n(i,this.deleteOrganization.bind(this),a)}async apiMove(t,i){if(!r(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new c("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new c("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),s,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new d(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new c(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t){if(t[this.pathAttr]){const i=t[this.pathAttr],a=Array.isArray(i)?i[0]:i,e=this.config.ldap_organization_path_separator||" / ";let n=a;if(this.isOu(t)){const i=t.ou,r=Array.isArray(i)?i[0]:i;if(!a.startsWith(r+e))throw new c(`Organization path must start with its own name followed by separator "${e}"`);n=a.slice(r.length+e.length)}const[r,s]=n.split(e,2),o=await this.server.ldap.search({paged:!1,filter:`(ou=${r})`,scope:"sub"},this.config.ldap_top_organization);if(0===o.searchEntries.length)throw new c(`Invalid organization path ${a}`);if(s){let t=!1;for(const i of o.searchEntries){const a=i[this.pathAttr],e=Array.isArray(a)?a[0]:a;if(e&&e===s){t=!0;break}}if(!t)throw new c(`Invalid organization path ${a}: no matching entry for ${r} with path ${s}`)}else{let t=!1;for(const i of o.searchEntries){const a=i.dn,n=this.config.ldap_top_organization;if(a.toLowerCase()===n.toLowerCase()||a.toLowerCase().endsWith(`,${n.toLowerCase()}`)){const a=i[this.pathAttr],n=Array.isArray(a)?a[0]:a;if(!n||n===r||!n.includes(e)){t=!0;break}}}if(!t)throw new c(`Invalid organization path ${a}: no matching top-level entry for ${r}`)}}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${t})`})).searchEntries.length>0)throw new g(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=t.objectClass.map(t=>t.toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new c("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new d("Top organization not found");const[,a]=await h(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new d(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;if(!n||"organizationalUnit"===n)try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:"(objectClass=organizationalUnit)"},t,i);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs for ${t}`),a.push(...e.searchEntries)}catch(i){this.server.logger.debug(`No sub-OUs found for ${t}: ${i}`)}const r=this.config.ldap_top_organization.replace(/^ou=[^,]+,/,"");let s=`(${this.config.ldap_organization_link_attribute}=${t})`;n&&(s=`(&${s}(objectClass=${n}))`),this.server.logger.debug(`Searching for linked entities with filter: ${s} in ${r}`);const o=await this.server.ldap.search({paged:!0,filter:s},r,i);let h=0,c=0;for await(const t of o){c+=t.searchEntries.length;const i=e-h;if(i>0){const e=t.searchEntries.slice(0,i);a.push(...e),h+=e.length}}return this.server.logger.debug(`Found ${c} linked entities for ${t}, returning ${h}`),c>e&&a.push({dn:`more-${t}`,cn:[`... ${c-e} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:c.toString(),_displayedCount:e.toString()}),a}async addOrganization(t,i,a){return this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i){return this.validateChanges(t,i),await this.server.ldap.modify(t,i)}validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!this._validateOneChange(t,a))throw new c(`Invalid value for field ${t}`);for(const[t,a]of Object.entries(this.schema.attributes))if(a.required&&null==i[t])throw new c(`Missing required field ${t}`);return!0}validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!this._validateOneChange(t,a))throw new c(`Invalid value for field ${t}`);if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!this._validateOneChange(t,a))throw new c(`Invalid value for field ${t}`);return!0}_validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new Error(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new Error(`Field ${t} is required`);return!0}if(a.type)switch(a.type){case"string":if(Array.isArray(i))throw new Error(`Field ${t} must be a single value`);break;case"array":if(!Array.isArray(i))throw new Error(`Field ${t} must be an array`)}if(a.test){const e=Array.isArray(i)?i[0]:i;if(!("string"==typeof a.test?new RegExp(a.test):a.test).test(String(e)))throw new Error(`Field ${t} does not match required pattern`)}return!0}async moveOrganization(t,i,a){try{const t=await this.server.ldap.search({paged:!1,scope:"base"},i);if(!t.searchEntries||0===t.searchEntries.length)throw new Error(`Target organization ${i} not found`);if(!this.isOu(t.searchEntries[0]))throw new Error(`Target ${i} is not an organizational unit`)}catch(t){throw new Error(`Invalid target organization: ${t}`)}const e=`${t.split(",")[0]},${i}`;try{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);if(!i.searchEntries||0===i.searchEntries.length)throw new Error(`Source organization ${t} not found`)}catch(t){throw new Error(`Source organization not found: ${t}`)}if(e===t)throw new Error("Cannot move organization to its current location");if(i===t||i.endsWith(`,${t}`))throw new Error("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,e,a),this.logger.info(`Moved organization from ${t} to ${e}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:e}}async deleteOrganization(t){return await this.server.ldap.delete(t)}async searchOrganisationSubnodes(t,i){const a=[];try{const e=await this.server.ldap.search({paged:!1,scope:"one",filter:`(&(objectClass=organizationalUnit)(|(ou=*${i}*)(description=*${i}*)))`},t);this.server.logger.debug(`Found ${e.searchEntries.length} sub-OUs matching "${i}" for ${t}`),a.push(...e.searchEntries)}catch(a){this.server.logger.debug(`No sub-OUs found matching "${i}" for ${t}: ${a}`)}const e=this.config.ldap_top_organization.replace(/^ou=[^,]+,/,""),n=`(&(${this.config.ldap_organization_link_attribute}=${t})(|(uid=*${i}*)(cn=*${i}*)(mail=*${i}*)(sn=*${i}*)(givenName=*${i}*)))`;this.server.logger.debug(`Searching for linked entities with filter: ${n} in ${e}`);const r=await this.server.ldap.search({paged:!0,filter:n},e);let s=0;for await(const t of r)s+=t.searchEntries.length,a.push(...t.searchEntries);return this.server.logger.debug(`Found ${s} linked entities matching "${i}" for ${t}`),a}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{LdapOrganizations as default};
@@ -1 +1 @@
1
- var e=["core/weblogs","core/auth/crowdsec","core/auth/rateLimit","core/auth/token","core/auth/llng","core/auth/openidconnect"];export{e as default};
1
+ var e=["core/auth/trustedProxy","core/weblogs","core/auth/crowdsec","core/auth/rateLimit","core/auth/token","core/auth/llng","core/auth/openidconnect"];export{e as default};
@@ -1 +1 @@
1
- import i from"node-fetch";import{TwakePlugin as t}from"../../abstract/twakePlugin.js";class James extends t{constructor(i){super(i,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async i=>{const[t,e]=i,a=e[this.mailAttr],s=e[this.quotaAttr],r=e[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(i=>setTimeout(i,this.initDelay)),s){const i=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(i)&&i>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",t,i.toString(),{mail:n,quota:i})}if(r){const i=this.getAliases(r);await Promise.all(i.map(i=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${i}`,"PUT",t,null,{mail:n,alias:i})))}const l=this.getDisplayNameFromAttributes(e);l&&await this.updateJamesIdentity(t,n,l)},onLdapMailChange:async(i,t,e)=>{const a=t?Array.isArray(t)?String(t[0]):String(t):null,s=e?Array.isArray(e)?String(e[0]):String(e):null;if(a){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename`,"POST",i,null,{oldmail:a,newmail:s});try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.aliasAttr]},i);if(t.searchEntries&&t.searchEntries.length>0){const e=this.getAliases(t.searchEntries[0][this.aliasAttr]);e.length>0&&await Promise.all([...e.map(t=>this.callWebAdminApi("onLdapMailChange-delete",`${this.webadminUrl}/address/aliases/${a}/sources/${t}`,"DELETE",i,null,{oldmail:a,alias:t})),...e.map(t=>this.callWebAdminApi("onLdapMailChange-create",`${this.webadminUrl}/address/aliases/${s}/sources/${t}`,"PUT",i,null,{newmail:s,alias:t}))])}}catch(i){this.logger.debug("Could not fetch aliases for mail change:",i)}}else this.logger.debug(`Skipping mail rename for ${i}: oldmail is empty (mail attribute was added, not changed)`)},onLdapAliasChange:async(i,t,e,a)=>{const s=e.map(i=>this.normalizeAlias(i)),r=a.map(i=>this.normalizeAlias(i)),n=s.filter(i=>!r.includes(i)),l=r.filter(i=>!s.includes(i));await Promise.all([...n.map(e=>this.callWebAdminApi("onLdapAliasChange-delete",`${this.webadminUrl}/address/aliases/${t}/sources/${e}`,"DELETE",i,null,{mail:t,alias:e,action:"delete"})),...l.map(e=>this.callWebAdminApi("onLdapAliasChange-add",`${this.webadminUrl}/address/aliases/${t}/sources/${e}`,"PUT",i,null,{mail:t,alias:e,action:"add"}))])},onLdapQuotaChange:(i,t,e,a)=>this.callWebAdminApi("onLdapQuotaChange",`${this.webadminUrl}/quota/users/${t}/size`,"PUT",i,a.toString(),{oldQuota:e,newQuota:a}),onLdapForwardChange:async(i,t,e,a)=>{const s=this.extractMailDomain(t);if(!s)return void this.logger.error(`Cannot extract domain from mail ${t} for forward management`);const r=e.filter(i=>!a.includes(i)),n=a.filter(i=>!e.includes(i));await Promise.all([...r.map(e=>this.callWebAdminApi("onLdapForwardChange-delete",`${this.webadminUrl}/domains/${s}/forwards/${t}/${e}`,"DELETE",i,null,{mail:t,forward:e,domain:s,action:"delete"})),...n.map(e=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/domains/${s}/forwards/${t}/${e}`,"PUT",i,null,{mail:t,forward:e,domain:s,action:"add"}))])},onLdapChange:async(i,t)=>{this.config.delegation_attribute&&t[this.config.delegation_attribute]&&await this._handleDelegationChange(i,t)},onLdapDisplayNameChange:async i=>{const t=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],e=await this.ldapGetAttributes(i,t);if(!e)return void this.logger.warn(`Cannot update James identity: entry not found for ${i}`);const a=this.attributeToString(e[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${i}`);const s=this.getDisplayNameFromAttributes(e);if(s)return this.updateJamesIdentity(i,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${i}`)},ldapgroupadddone:async i=>{const[t,e]=i,a=e.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);const s=this.getMailboxType(e),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const i=this.config.james_mailing_list_branch||[];if(i.length>0&&this.isInAllowedBranches(t,i))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:t,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${i.join(", ")}`});await this.createTeamMailbox(t,e,r)}else if("mailingList"===s||null===s){const i=this.config.james_mailing_list_branch||[];if(i.length>0&&!this.isInAllowedBranches(t,i))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:t,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${i.join(", ")}`});await this.createMailingList(t,e,r)}},ldapgroupmodifydone:async i=>{const[t,e]=i;if(e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(t,e);const a=await this.getGroupMail(t);if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(t),r=s?this.extractMailDomain(a):null,n=[];if(e.add?.member){const i=e.add.member,l=Array.isArray(i)?i:[i],o=await this.getMemberEmails(l);n.push(...o.map(i=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${i}`,"PUT",t,null,{domain:r,groupMail:a,memberMail:i,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${i}`,"PUT",t,null,{groupMail:a,memberMail:i,action:"add"})))}if(this.isAttributesList(e.delete)){const i=e.delete.member;if(i){const e=Array.isArray(i)?i:[i],l=await this.getMemberEmails(e);n.push(...l.map(i=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${i}`,"DELETE",t,null,{domain:r,groupMail:a,memberMail:i,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${i}`,"DELETE",t,null,{groupMail:a,memberMail:i,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async i=>{const t=await this.getGroupMail(i);if(!t)return void this.logger.debug(`Group ${i} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(i)?await this.removeAllTeamMailboxMembers(i,t):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${t}`,"DELETE",i,null,{groupMail:t})}},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}async handleMailboxTypeTransition(i,t){const e=await this.ldapGetAttributes(i,["mail","member",this.mailboxTypeAttr]);if(!e)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:i,error:"Could not fetch group attributes"});const a=e.mail;if(!a)return void this.logger.debug(`Group ${i} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=t.replace?.[this.mailboxTypeAttr]||t.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(e);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:i,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(i,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(i,s),"mailingList"===l){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(i,t))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:i,newType:l,error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(i,e,s)}else if("teamMailbox"===l){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(i,t))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:i,newType:l,error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(i,e,s)}}getDisplayNameFromAttributes(i){const t=this.attributeToString(i[this.displayNameAttr]);if(t)return t;const e=this.attributeToString(i.cn);if(e)return e;const a=this.attributeToString(i.givenName),s=this.attributeToString(i.sn);if(a||s){const i=[];return a&&i.push(a),s&&i.push(s),i.join(" ")}const r=this.attributeToString(i[this.mailAttr]);return r||null}async getMailFromDN(i){const t=await this.ldapGetAttributes(i,[this.mailAttr]);return t?this.attributeToString(t[this.mailAttr]):null}async getDisplayNameFromDN(i){const t=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],e=await this.ldapGetAttributes(i,t);return e?this.getDisplayNameFromAttributes(e):null}async generateSignature(i){const t=this.config.james_signature_template;if(!t)return null;const e=await this.ldapGetAttributes(i);if(!e)return null;let a=t;return a=a.replace(/\{(\w+)\}/g,(i,t)=>"string"==typeof t&&e&&"object"==typeof e&&Object.prototype.hasOwnProperty.call(e,t)&&this.attributeToString(e[t])||""),a}async updateJamesIdentity(t,e,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:t,mail:e,displayName:a};try{const r=`${this.webadminUrl}/jmap/identities/${e}`,n=await this.requestLimit(()=>i(r,{method:"GET",headers:this.createHeaders()}));if(!n.ok)return void this.logger.error({...s,step:"get_identities",http_status:n.status,http_status_text:n.statusText});const l=await n.json(),o=l.find(i=>i.email===e)||l[0];if(!o)return void this.logger.warn({...s,step:"find_identity",message:"No identity found for user"});const m=await this.generateSignature(t),d=`${this.webadminUrl}/jmap/identities/${e}/${o.id}`,h={id:o.id,email:o.email,name:a};m&&(h.htmlSignature=m);const g=await this.requestLimit(()=>i(d,{method:"PUT",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));g.ok?this.logger.info({...s,result:"success",http_status:g.status}):this.logger.error({...s,step:"update_identity",http_status:g.status,http_status_text:g.statusText})}catch(i){this.logger.error({...s,error:`${i}`})}}isAttributesList(i){return void 0!==i&&"object"==typeof i&&!Array.isArray(i)}getMailboxType(i){const t=i[this.mailboxTypeAttr];if(!t)return null;const e=(Array.isArray(t)?String(t[0]):String(t)).match(/^cn=([^,]+)/);if(!e)return null;const a=e[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(i,t){return!t||0===t.length||t.some(t=>i===t||i.endsWith(","+t))}async createMailingList(i,t,e){const a=t.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${e} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(t=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${e}/${t}`,"PUT",i,null,{groupMail:e,memberMail:t})))}async createTeamMailbox(i,t,e){const a=this.extractMailDomain(e);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:i,mail:e,error:"Cannot extract domain from mail address"});const s=t.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${e} 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/${e}`,"PUT",i,null,{domain:a,teamMailbox:e}));for(const t of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${e}/members/${t}`,"PUT",i,null,{domain:a,teamMailbox:e,memberMail:t}));await Promise.all(l)}async deleteTeamMailbox(i,t){const e=this.extractMailDomain(t);e?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${e}/team-mailboxes/${t}`,"DELETE",i,null,{domain:e,teamMailbox:t}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:i,mail:t,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(i,t){const e=this.extractMailDomain(t);if(e)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},i);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/${e}/team-mailboxes/${t}/members/${a}`,"DELETE",i,null,{domain:e,teamMailbox:t,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:i,teamMailbox:t,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(i){const e=i instanceof Error?i.message:String(i);this.logger.error(`Failed to remove members from team mailbox ${t}: ${e}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:i,mail:t,error:"Cannot extract domain from mail address"})}async deleteMailingList(i,t){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${t}`,"DELETE",i,null,{groupMail:t})}async getMemberEmails(i){const t=i.filter(i=>i!==this.config.group_dummy_user).map(i=>this.server.ldap.queryLimit(async()=>{const t=await this.ldapGetAttributes(i,[this.mailAttr]);return t?this.attributeToString(t[this.mailAttr]):null}));return(await Promise.all(t)).filter(i=>null!==i)}async getGroupMail(i){const t=await this.ldapGetAttributes(i,["mail"]);return t?this.attributeToString(t.mail):null}async getGroupMailboxType(i){try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},i);if(t.searchEntries&&t.searchEntries.length>0)return this.getMailboxType(t.searchEntries[0])}catch(t){const e=t instanceof Error?t.message:String(t);this.logger.debug(`Could not get mailbox type for group ${i}: ${e}`)}return null}async _handleDelegationChange(i,t){const e=await this.ldapGetAttributes(i,[this.mailAttr]);if(!e)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:i,message:"Could not find user entry to get mail attribute"});const a=e[this.mailAttr];if(!a||"string"!=typeof a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:i,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=t[s]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(i=>!l.includes(i)),d=l.filter(i=>!o.includes(i)),h=m.map(i=>this.server.ldap.queryLimit(async()=>{const t=await this._getDelegateEmail(i);return{dn:i,email:t}})),g=d.map(i=>this.server.ldap.queryLimit(async()=>{const t=await this._getDelegateEmail(i);return{dn:i,email:t}})),[u,p]=await Promise.all([Promise.all(h),Promise.all(g)]),c=[];for(const{dn:t,email:e}of u)e&&c.push(this.callWebAdminApi("onLdapChange:addDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${e}`,"PUT",i,null,{userMail:a,delegateEmail:e,delegateDN:t,action:"add"}));for(const{dn:t,email:e}of p)e&&c.push(this.callWebAdminApi("onLdapChange:removeDelegation",`${this.webadminUrl}/users/${a}/authorizedUsers/${e}`,"DELETE",i,null,{userMail:a,delegateEmail:e,delegateDN:t,action:"remove"}));await Promise.all(c)}async _getDelegateEmail(i){const t=await this.ldapGetAttributes(i,[this.mailAttr]);return t?this.attributeToString(t[this.mailAttr]):(this.logger.warn({plugin:this.name,event:"getDelegateEmail",dn:i,message:"Could not resolve delegate DN to email"}),null)}_normalizeToArray(i){return i?Array.isArray(i)?i:[i]:[]}}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}from"../../lib/utils.js";import{wantJson as s}from"../../lib/expressFormatedResponses.js";class James extends e{constructor(t){super(t,"james_webadmin_url","james_webadmin_token","james_concurrency"),this.name="james",this.roles=["consistency","api"],this.dependencies={onLdapChange:"core/ldap/onChange",ldapGroups:"core/ldap/groups"},this.hooks={ldapadddone:async t=>{const[e,i]=t,a=i[this.mailAttr],s=i[this.quotaAttr],r=i[this.aliasAttr];if(!a)return;const n=Array.isArray(a)?String(a[0]):String(a);if(this.initDelay>0&&await new Promise(t=>setTimeout(t,this.initDelay)),s){const t=Array.isArray(s)?Number(s[0]):Number(s);!isNaN(t)&&t>0&&await this.callWebAdminApi("ldapadddone:quota",`${this.webadminUrl}/quota/users/${n}/size`,"PUT",e,t.toString(),{mail:n,quota:t})}if(r){const t=this.getAliases(r);await Promise.all(t.map(t=>this.callWebAdminApi("ldapadddone:alias",`${this.webadminUrl}/address/aliases/${n}/sources/${t}`,"PUT",e,null,{mail:n,alias:t})))}const l=this.getDisplayNameFromAttributes(i);l&&await this.updateJamesIdentity(e,n,l)},onLdapMailChange:async(t,e,i)=>{const a=e?Array.isArray(e)?String(e[0]):String(e):null,s=i?Array.isArray(i)?String(i[0]):String(i):null;if(a){await this.callWebAdminApi("onLdapMailChange",`${this.webadminUrl}/users/${a}/rename/${s}?action=rename`,"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}: 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}/domains/${s}/forwards/${e}/${i}`,"DELETE",t,null,{mail:e,forward:i,domain:s,action:"delete"})),...n.map(i=>this.callWebAdminApi("onLdapForwardChange-add",`${this.webadminUrl}/domains/${s}/forwards/${e}/${i}`,"PUT",t,null,{mail:e,forward:i,domain:s,action:"add"}))])},onLdapChange:async(t,e)=>{this.config.delegation_attribute&&e[this.config.delegation_attribute]&&await this._handleDelegationChange(t,e)},onLdapDisplayNameChange:async t=>{const e=[this.mailAttr,this.displayNameAttr,"cn","givenName","sn"],i=await this.ldapGetAttributes(t,e);if(!i)return void this.logger.warn(`Cannot update James identity: entry not found for ${t}`);const a=this.attributeToString(i[this.mailAttr]);if(!a)return void this.logger.warn(`Cannot update James identity: no mail found for ${t}`);const s=this.getDisplayNameFromAttributes(i);if(s)return this.updateJamesIdentity(t,a,s);this.logger.warn(`Cannot update James identity: no display name found for ${t}`)},ldapgroupadddone:async t=>{const[e,i]=t,a=i.mail;if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s=this.getMailboxType(i),r=Array.isArray(a)?String(a[0]):String(a);if("teamMailbox"===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:"teamMailbox",error:`Team mailbox cannot be in mailing list branches: ${t.join(", ")}`});await this.createTeamMailbox(e,i,r)}else if("mailingList"===s||null===s){const t=this.config.james_mailing_list_branch||[];if(t.length>0&&!this.isInAllowedBranches(e,t))return void this.logger.error({plugin:this.name,event:"ldapgroupadddone",dn:e,mailboxType:s||"mailingList (default)",error:`Mailing list must be in allowed branches: ${t.join(", ")}`});await this.createMailingList(e,i,r)}},ldapgroupmodifydone:async t=>{const[e,i]=t;if(i.replace?.[this.mailboxTypeAttr]||i.add?.[this.mailboxTypeAttr])return void await this.handleMailboxTypeTransition(e,i);const a=await this.getGroupMail(e);if(!a)return void this.logger.debug(`Group ${e} has no mail attribute, skipping James sync`);const s="teamMailbox"===await this.getGroupMailboxType(e),r=s?this.extractMailDomain(a):null,n=[];if(i.add?.member){const t=i.add.member,l=Array.isArray(t)?t:[t],o=await this.getMemberEmails(l);n.push(...o.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"PUT",e,null,{domain:r,groupMail:a,memberMail:t,action:"add"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"PUT",e,null,{groupMail:a,memberMail:t,action:"add"})))}if(this.isAttributesList(i.delete)){const t=i.delete.member;if(t){const i=Array.isArray(t)?t:[t],l=await this.getMemberEmails(i);n.push(...l.map(t=>s&&r?this.callWebAdminApi("ldapgroupmodifydone:teamMailbox",`${this.webadminUrl}/domains/${r}/team-mailboxes/${a}/members/${t}`,"DELETE",e,null,{domain:r,groupMail:a,memberMail:t,action:"delete"}):this.callWebAdminApi("ldapgroupmodifydone:mailingList",`${this.webadminUrl}/address/groups/${a}/${t}`,"DELETE",e,null,{groupMail:a,memberMail:t,action:"delete"})))}}await Promise.all(n)},ldapgroupdeletedone:async t=>{const e=await this.getGroupMail(t);if(!e)return void this.logger.debug(`Group ${t} has no mail attribute, skipping James sync`);"teamMailbox"===await this.getGroupMailboxType(t)?await this.removeAllTeamMailboxMembers(t,e):await this.callWebAdminApi("ldapgroupdeletedone:mailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}},this.quotaAttr=this.config.quota_attribute||"mailQuotaSize",this.aliasAttr=this.config.alias_attribute||"mailAlternateAddress",this.mailboxTypeAttr=this.config.james_mailbox_type_attribute||"twakeMailboxType",this.initDelay="number"==typeof this.config.james_init_delay?this.config.james_init_delay:1e3}api(t){const e=this.config.api_prefix||"/api";t.get(`${e}/v1/users/:user/quota-usage`,i((t,e)=>this.getUserQuota(t,e))),this.logger.info(`James API registered at ${e}/v1/users/:user/quota-usage`)}async getUserQuota(e,i){if(!s(e,i))return;const r=e.params.user;try{const e=a(r),s=await this.server.ldap.search({scope:"sub",filter:`(uid=${e})`,paged:!1,attributes:[this.mailAttr]},this.config.ldap_base||""),n=s.searchEntries?.[0];if(!n)return void i.status(404).json({error:`User ${r} not found`});const l=n[this.mailAttr];if(!l)return void i.status(400).json({error:`User ${r} has no mail attribute`});const o=Array.isArray(l)?String(l[0]):String(l),m=`${this.webadminUrl}/quota/users/${o}`,d=await this.requestLimit(()=>t(m,{method:"GET",headers:this.createHeaders()}));if(!d.ok)return void(404===d.status?i.status(404).json({error:`Quota not found for user ${r}`}):i.status(502).json({error:`Failed to retrieve quota from James: ${d.statusText}`}));const h=await d.json();i.json(h)}catch(t){this.logger.error({plugin:this.name,event:"getUserQuota",username:r,error:t instanceof Error?t.message:String(t)}),i.status(500).json({error:"Internal server error while retrieving quota"})}}async handleMailboxTypeTransition(t,e){const i=await this.ldapGetAttributes(t,["mail","member",this.mailboxTypeAttr]);if(!i)return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,error:"Could not fetch group attributes"});const a=i.mail;if(!a)return void this.logger.debug(`Group ${t} has no mail attribute, skipping mailbox type transition`);const s=Array.isArray(a)?String(a[0]):String(a),r=e.replace?.[this.mailboxTypeAttr]||e.add?.[this.mailboxTypeAttr],n=Array.isArray(r)?String(r[0]):String(r),l=this.getMailboxType({[this.mailboxTypeAttr]:n}),o=this.getMailboxType(i);if(this.logger.info({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,mail:s,oldType:o||"none",newType:l||"none"}),o!==l)if("mailingList"===o?await this.deleteMailingList(t,s):"teamMailbox"===o&&await this.removeAllTeamMailboxMembers(t,s),"mailingList"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&!this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Mailing list must be in allowed branches: ${e.join(", ")}`});await this.createMailingList(t,i,s)}else if("teamMailbox"===l){const e=this.config.james_mailing_list_branch||[];if(e.length>0&&this.isInAllowedBranches(t,e))return void this.logger.error({plugin:this.name,event:"handleMailboxTypeTransition",dn:t,newType:l,error:`Team mailbox cannot be in mailing list branches: ${e.join(", ")}`});await this.createTeamMailbox(t,i,s)}}getDisplayNameFromAttributes(t){const e=this.attributeToString(t[this.displayNameAttr]);if(e)return e;const i=this.attributeToString(t.cn);if(i)return i;const a=this.attributeToString(t.givenName),s=this.attributeToString(t.sn);if(a||s){const t=[];return a&&t.push(a),s&&t.push(s),t.join(" ")}const r=this.attributeToString(t[this.mailAttr]);return r||null}async getMailFromDN(t){const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}async getDisplayNameFromDN(t){const e=[this.displayNameAttr,"cn","givenName","sn",this.mailAttr],i=await this.ldapGetAttributes(t,e);return i?this.getDisplayNameFromAttributes(i):null}async generateSignature(t){const e=this.config.james_signature_template;if(!e)return null;const i=await this.ldapGetAttributes(t);if(!i)return null;let a=e;return a=a.replace(/\{(\w+)\}/g,(t,e)=>"string"==typeof e&&i&&"object"==typeof i&&Object.prototype.hasOwnProperty.call(i,e)&&this.attributeToString(i[e])||""),a}async updateJamesIdentity(e,i,a){const s={plugin:this.name,event:"onLdapDisplayNameChange",result:"error",dn:e,mail:i,displayName:a};try{const r=`${this.webadminUrl}/jmap/identities/${i}`,n=await this.requestLimit(()=>t(r,{method:"GET",headers:this.createHeaders()}));if(!n.ok)return void this.logger.error({...s,step:"get_identities",http_status:n.status,http_status_text:n.statusText});const l=await n.json(),o=l.find(t=>t.email===i)||l[0];if(!o)return void this.logger.warn({...s,step:"find_identity",message:"No identity found for user"});const m=await this.generateSignature(e),d=`${this.webadminUrl}/jmap/identities/${i}/${o.id}`,h={id:o.id,email:o.email,name:a};m&&(h.htmlSignature=m);const g=await this.requestLimit(()=>t(d,{method:"PUT",headers:this.createHeaders("application/json"),body:JSON.stringify(h)}));g.ok?this.logger.info({...s,result:"success",http_status:g.status}):this.logger.error({...s,step:"update_identity",http_status:g.status,http_status_text:g.statusText})}catch(t){this.logger.error({...s,error:`${t}`})}}isAttributesList(t){return void 0!==t&&"object"==typeof t&&!Array.isArray(t)}getMailboxType(t){const e=t[this.mailboxTypeAttr];if(!e)return null;const i=(Array.isArray(e)?String(e[0]):String(e)).match(/^cn=([^,]+)/);if(!i)return null;const a=i[1];return"group"===a||"mailingList"===a||"teamMailbox"===a?a:null}isInAllowedBranches(t,e){return!e||0===e.length||e.some(e=>t===e||t.endsWith(","+e))}async createMailingList(t,e,i){const a=e.member||[],s=Array.isArray(a)?a:[a];this.logger.debug(`Creating mailing list ${i} in James with ${s.length} members`);const r=await this.getMemberEmails(s);await Promise.all(r.map(e=>this.callWebAdminApi("ldapgroupadddone:mailingList",`${this.webadminUrl}/address/groups/${i}/${e}`,"PUT",t,null,{groupMail:i,memberMail:e})))}async createTeamMailbox(t,e,i){const a=this.extractMailDomain(i);if(!a)return void this.logger.error({plugin:this.name,event:"createTeamMailbox",dn:t,mail:i,error:"Cannot extract domain from mail address"});const s=e.member||[],r=Array.isArray(s)?s:[s];this.logger.debug(`Creating team mailbox ${i} in James with ${r.length} members`);const n=await this.getMemberEmails(r),l=[];l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:create",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}`,"PUT",t,null,{domain:a,teamMailbox:i}));for(const e of n)l.push(this.callWebAdminApi("ldapgroupadddone:teamMailbox:addMember",`${this.webadminUrl}/domains/${a}/team-mailboxes/${i}/members/${e}`,"PUT",t,null,{domain:a,teamMailbox:i,memberMail:e}));await Promise.all(l)}async deleteTeamMailbox(t,e){const i=this.extractMailDomain(e);i?await this.callWebAdminApi("ldapgroupdeletedone:teamMailbox",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}`,"DELETE",t,null,{domain:i,teamMailbox:e}):this.logger.error({plugin:this.name,event:"deleteTeamMailbox",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async removeAllTeamMailboxMembers(t,e){const i=this.extractMailDomain(e);if(i)try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:["member"]},t);if(a.searchEntries&&a.searchEntries.length>0){const s=a.searchEntries[0].member||[],r=Array.isArray(s)?s:[s],n=await this.getMemberEmails(r);await Promise.all(n.map(a=>this.callWebAdminApi("removeAllTeamMailboxMembers",`${this.webadminUrl}/domains/${i}/team-mailboxes/${e}/members/${a}`,"DELETE",t,null,{domain:i,teamMailbox:e,memberMail:a}))),this.logger.info({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,teamMailbox:e,membersRemoved:n.length,message:"All members removed from team mailbox (mailbox preserved)"})}}catch(t){const i=t instanceof Error?t.message:String(t);this.logger.error(`Failed to remove members from team mailbox ${e}: ${i}`)}else this.logger.error({plugin:this.name,event:"removeAllTeamMailboxMembers",dn:t,mail:e,error:"Cannot extract domain from mail address"})}async deleteMailingList(t,e){await this.callWebAdminApi("deleteMailingList",`${this.webadminUrl}/address/groups/${e}`,"DELETE",t,null,{groupMail:e})}async getMemberEmails(t){const e=t.filter(t=>t!==this.config.group_dummy_user).map(t=>this.server.ldap.queryLimit(async()=>{const e=await this.ldapGetAttributes(t,[this.mailAttr]);return e?this.attributeToString(e[this.mailAttr]):null}));return(await Promise.all(e)).filter(t=>null!==t)}async getGroupMail(t){const e=await this.ldapGetAttributes(t,["mail"]);return e?this.attributeToString(e.mail):null}async getGroupMailboxType(t){try{const e=await this.server.ldap.search({paged:!1,scope:"base",attributes:[this.mailboxTypeAttr]},t);if(e.searchEntries&&e.searchEntries.length>0)return this.getMailboxType(e.searchEntries[0])}catch(e){const i=e instanceof Error?e.message:String(e);this.logger.debug(`Could not get mailbox type for group ${t}: ${i}`)}return null}async _handleDelegationChange(t,e){const i=await this.ldapGetAttributes(t,[this.mailAttr]);if(!i)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"Could not find user entry to get mail attribute"});const a=i[this.mailAttr];if(!a||"string"!=typeof a)return void this.logger.warn({plugin:this.name,event:"onLdapChange",dn:t,message:"User has no mail attribute, cannot manage delegation"});const s=this.config.delegation_attribute;if(!s)return;const[r,n]=e[s]||[],l=this._normalizeToArray(r),o=this._normalizeToArray(n),m=o.filter(t=>!l.includes(t)),d=l.filter(t=>!o.includes(t)),h=m.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),g=d.map(t=>this.server.ldap.queryLimit(async()=>{const e=await this._getDelegateEmail(t);return{dn:t,email:e}})),[u,p]=await Promise.all([Promise.all(h),Promise.all(g)]),c=[];for(const{dn:e,email:i}of u)i&&c.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 p)i&&c.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(c)}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 +1 @@
1
- import o from"../abstract/plugin.js";class WebLogs extends o{constructor(){super(...arguments),this.name="weblogs",this.roles=["logging"]}api(o){o.use((o,e,s)=>{let r=!0;this.logger.debug(`Incoming request: ${o.method} ${o.originalUrl}`);const t=Date.now();e.on("finish",()=>{const s={};o.user&&(s.user=o.user),r&&this.log(o,e,t,s),r=!1}),e.on("error",s=>{r&&this.log(o,e,t,{error:s.message}),r=!1}),e.on("close",()=>{e.writableEnded||(r&&this.log(o,e,t,{error:`Connection closed before response was sent for ${o.method} ${o.originalUrl}`}),r=!1)}),s()})}log(o,e,s,r){const t=Date.now()-s,n={method:o.method,url:o.originalUrl,status:e.statusCode,duration:t,...r};this.logger.notice(n)}}export{WebLogs as default};
1
+ import e from"../abstract/plugin.js";class WebLogs extends e{constructor(){super(...arguments),this.name="weblogs",this.roles=["logging"]}api(e){e.use((e,r,o)=>{let s=!0;this.logger.debug(`Incoming request: ${e.method} ${e.originalUrl}`);const t=Date.now();r.on("finish",()=>{const o={};e.proxyAuthUser?o.user=e.proxyAuthUser:e.user&&(o.user=e.user),s&&this.log(e,r,t,o),s=!1}),r.on("error",o=>{s&&this.log(e,r,t,{error:o.message}),s=!1}),r.on("close",()=>{r.writableEnded||(s&&this.log(e,r,t,{error:`Connection closed before response was sent for ${e.method} ${e.originalUrl}`}),s=!1)}),o()})}log(e,r,o,s){const t=Date.now()-o,n={method:e.method,url:e.originalUrl,status:r.statusCode,duration:t,ip:this.getClientIp(e),...s};this.logger.notice(n)}getClientIp(e){const r=e.headers["x-forwarded-for"];if(r){return(Array.isArray(r)?r[0]:r).split(",")[0].trim()}return e.socket.remoteAddress||"unknown"}}export{WebLogs as default};
@@ -7,6 +7,7 @@ import type winston from 'winston';
7
7
  import type { Config, DM } from '../bin';
8
8
  import type { Hooks, MaybePromise } from '../hooks';
9
9
  export { asyncHandler } from '../lib/utils';
10
+ export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors';
10
11
  export type Role = 'auth' | 'authz' | 'protect' | 'api' | 'logging' | 'demo' | 'consistency' | 'configurable';
11
12
  export default abstract class DmPlugin {
12
13
  /**
@@ -19,6 +19,8 @@ import ldapActions from '../lib/ldapActions';
19
19
  import type DmPlugin from '../abstract/plugin';
20
20
  export type { Config };
21
21
  export * from '../lib/utils';
22
+ export { asyncHandler } from '../lib/utils';
23
+ export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors';
22
24
  /**
23
25
  * @class DM
24
26
  */
@@ -36,7 +38,10 @@ export declare class DM {
36
38
  ldap: ldapActions;
37
39
  operationSequence: number;
38
40
  logger: winston.Logger;
41
+ private _errorMiddlewareSetup;
42
+ private _errorMiddleware?;
39
43
  constructor();
44
+ setupErrorMiddleware(): void;
40
45
  run(): Promise<void>;
41
46
  stop(): void;
42
47
  loadPlugin(pluginName: string): Promise<boolean>;
@@ -84,6 +84,8 @@ export interface Config {
84
84
  crowdsec_url?: string;
85
85
  crowdsec_api_key?: string;
86
86
  crowdsec_cache_ttl?: number;
87
+ trusted_proxy?: string[];
88
+ trusted_proxy_auth_header?: string;
87
89
  mail_attribute?: string;
88
90
  quota_attribute?: string;
89
91
  delegation_attribute?: string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Custom HTTP Error classes with status codes
3
+ * @author Xavier Guimard <xguimard@linagora.com>
4
+ */
5
+ /**
6
+ * Base HTTP Error class with status code
7
+ */
8
+ export declare class HttpError extends Error {
9
+ statusCode: number;
10
+ constructor(message: string, statusCode?: number);
11
+ }
12
+ /**
13
+ * Client errors (4xx)
14
+ */
15
+ export declare class BadRequestError extends HttpError {
16
+ constructor(message?: string);
17
+ }
18
+ export declare class UnauthorizedError extends HttpError {
19
+ constructor(message?: string);
20
+ }
21
+ export declare class ForbiddenError extends HttpError {
22
+ constructor(message?: string);
23
+ }
24
+ export declare class NotFoundError extends HttpError {
25
+ constructor(message?: string);
26
+ }
27
+ export declare class ConflictError extends HttpError {
28
+ constructor(message?: string);
29
+ }
30
+ export declare class UriTooLongError extends HttpError {
31
+ constructor(message?: string);
32
+ }
33
+ export declare class TooManyRequestsError extends HttpError {
34
+ constructor(message?: string);
35
+ }
36
+ /**
37
+ * Server errors (5xx)
38
+ */
39
+ export declare class BadGatewayError extends HttpError {
40
+ constructor(message?: string);
41
+ }
42
+ export declare class ServiceUnavailableError extends HttpError {
43
+ constructor(message?: string);
44
+ }
45
+ export declare class GatewayTimeoutError extends HttpError {
46
+ constructor(message?: string);
47
+ }
@@ -7,6 +7,23 @@ import type { Config } from '../bin';
7
7
  export declare const launchHooks: (hooks: Function[] | undefined, ...args: unknown[]) => Promise<void>;
8
8
  export declare const launchHooksChained: <T>(hooks: Function[] | undefined, args: T) => Promise<T>;
9
9
  export declare const transformSchemas: (schemas: string | Buffer, config: Config) => string;
10
+ /**
11
+ * Escape special characters in LDAP filter values according to RFC 4515
12
+ * Prevents LDAP injection attacks by escaping characters that have special meaning in LDAP filters
13
+ *
14
+ * @param value - The value to escape
15
+ * @returns The escaped value safe for use in LDAP filters
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * escapeLdapFilter('user*')
20
+ * // => 'user\\2a'
21
+ *
22
+ * escapeLdapFilter('Smith, John (admin)')
23
+ * // => 'Smith, John \\28admin\\29'
24
+ * ```
25
+ */
26
+ export declare function escapeLdapFilter(value: string): string;
10
27
  /**
11
28
  * Parse a Distinguished Name (DN) into its component parts (RDNs)
12
29
  * Handles escaped commas and other special characters
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @module core/auth/trustedProxy
3
+ * Trusted proxy plugin to validate X-Forwarded-For headers
4
+ *
5
+ * This plugin validates that X-Forwarded-For headers only come from trusted
6
+ * upstream reverse proxies. If the request comes from an untrusted IP,
7
+ * the X-Forwarded-For header is removed to prevent IP spoofing attacks.
8
+ *
9
+ * This plugin MUST be loaded BEFORE any plugin that uses X-Forwarded-For
10
+ * (like crowdsec and rateLimit) to ensure they receive sanitized headers.
11
+ *
12
+ * Configuration:
13
+ * - trusted_proxy: Array of trusted proxy IPs or CIDR ranges (required)
14
+ * Example: ["127.0.0.1", "10.0.0.0/8", "192.168.1.0/24", "::1"]
15
+ * - trusted_proxy_auth_header: Header name containing the authenticated user
16
+ * from trusted proxy (optional, default: "Auth-User")
17
+ *
18
+ * When a request comes from a trusted proxy:
19
+ * - X-Forwarded-For header is preserved
20
+ * - The request is marked as trusted (req.trustedProxy = true)
21
+ * - If trusted_proxy_auth_header is set and the header is present,
22
+ * req.proxyAuthUser is set to its value for use by other plugins (e.g., weblogs)
23
+ *
24
+ * @author Xavier Guimard <xguimard@linagora.com>
25
+ */
26
+ import type { Express } from 'express';
27
+ import DmPlugin, { type Role } from '../../abstract/plugin';
28
+ declare global {
29
+ namespace Express {
30
+ interface Request {
31
+ trustedProxy?: boolean;
32
+ proxyAuthUser?: string;
33
+ }
34
+ }
35
+ }
36
+ export default class TrustedProxy extends DmPlugin {
37
+ name: string;
38
+ roles: Role[];
39
+ private trustedNetworks;
40
+ private authHeader;
41
+ constructor(...args: ConstructorParameters<typeof DmPlugin>);
42
+ api(app: Express): void;
43
+ /**
44
+ * Check if an IP address belongs to a trusted proxy network
45
+ */
46
+ private isTrustedProxy;
47
+ /**
48
+ * Normalize IP address (handle IPv4-mapped IPv6 addresses)
49
+ */
50
+ private normalizeIP;
51
+ /**
52
+ * Parse an IP address into a bigint for comparison
53
+ */
54
+ private parseIP;
55
+ /**
56
+ * Parse a CIDR notation string into network address and mask
57
+ */
58
+ private parseCIDR;
59
+ /**
60
+ * Parse an IPv4 address into a bigint
61
+ */
62
+ private parseIPv4;
63
+ /**
64
+ * Parse an IPv6 address into a bigint
65
+ */
66
+ private parseIPv6;
67
+ }
@@ -1,3 +1,4 @@
1
+ import type { Express } from 'express';
1
2
  import TwakePlugin from '../../abstract/twakePlugin';
2
3
  import { type Role } from '../../abstract/plugin';
3
4
  import type { DM } from '../../bin';
@@ -16,6 +17,14 @@ export default class James extends TwakePlugin {
16
17
  private mailboxTypeAttr;
17
18
  private initDelay;
18
19
  constructor(server: DM);
20
+ /**
21
+ * Register API routes
22
+ */
23
+ api(app: Express): void;
24
+ /**
25
+ * Get quota information for a user
26
+ */
27
+ private getUserQuota;
19
28
  hooks: Hooks;
20
29
  /**
21
30
  * Handle mailbox type transitions when twakeMailboxType changes
@@ -5,4 +5,5 @@ export default class WebLogs extends DmPlugin {
5
5
  roles: Role[];
6
6
  api(app: Express): void;
7
7
  log(req: Request, res: Response, start: number, log: Record<string, string | number>): void;
8
+ private getClientIp;
8
9
  }
@@ -11,6 +11,139 @@ LDAP-Rest provides multiple authentication plugins to secure API access. These p
11
11
  5. **OpenID Connect** (`core/auth/openidconnect`) - OAuth 2.0 / OpenID Connect authentication
12
12
  6. **Authorization Per Branch** (`core/auth/authzPerBranch`) - Branch-level access control (see [authzPerBranch.md](authzPerBranch.md))
13
13
 
14
+ ## Security Plugins
15
+
16
+ 1. **Trusted Proxy** (`core/auth/trustedProxy`) - Validate X-Forwarded-For headers from reverse proxies
17
+ 2. **Rate Limiting** (`core/auth/rateLimit`) - Prevent brute force attacks
18
+ 3. **CrowdSec** (`core/auth/crowdsec`) - IP reputation and blocking
19
+
20
+ ## Trusted Proxy
21
+
22
+ When running behind a reverse proxy (nginx, Apache, HAProxy, etc.), client IP addresses are typically passed via `X-Forwarded-For` headers. The `trustedProxy` plugin validates these headers to prevent IP spoofing attacks.
23
+
24
+ ### Configuration
25
+
26
+ ```bash
27
+ --plugin core/auth/trustedProxy \
28
+ --trusted-proxy "127.0.0.1" \
29
+ --trusted-proxy "10.0.0.0/8" \
30
+ --trusted-proxy "192.168.0.0/16"
31
+ ```
32
+
33
+ **Environment Variables:**
34
+
35
+ ```bash
36
+ # Comma-separated list of trusted proxy IPs or CIDR ranges
37
+ DM_TRUSTED_PROXIES="127.0.0.1,10.0.0.0/8,192.168.0.0/16,::1"
38
+
39
+ # Optional: Header name for authenticated user from proxy (default: Auth-User)
40
+ DM_TRUSTED_PROXY_AUTH_HEADER="Auth-User"
41
+ ```
42
+
43
+ ### How It Works
44
+
45
+ 1. **Request arrives** from reverse proxy
46
+ 2. **Plugin checks** if `req.socket.remoteAddress` matches a trusted proxy
47
+ 3. **If trusted:**
48
+ - `X-Forwarded-For` header is preserved
49
+ - `Auth-User` header (or custom header) is extracted for logging
50
+ - Request is marked as `req.trustedProxy = true`
51
+ 4. **If untrusted:**
52
+ - `X-Forwarded-For` header is **removed** to prevent IP spoofing
53
+ - Request is marked as `req.trustedProxy = false`
54
+ 5. **Other plugins** (rate limiting, CrowdSec, weblogs) see sanitized headers
55
+
56
+ ### Supported Formats
57
+
58
+ - **IPv4 addresses:** `192.168.1.1`
59
+ - **IPv6 addresses:** `::1`, `fe80::1`
60
+ - **CIDR ranges:** `10.0.0.0/8`, `192.168.0.0/16`, `2001:db8::/32`
61
+ - **IPv4-mapped IPv6:** Automatically handled (e.g., `::ffff:127.0.0.1` matches `127.0.0.1`)
62
+
63
+ ### Auth-User Header
64
+
65
+ When requests come from a trusted proxy, the plugin can extract an authenticated username from a header set by the proxy. This is useful for:
66
+
67
+ - Logging who made the request (even if LDAP-Rest doesn't handle authentication)
68
+ - Passing identity from an upstream authentication system
69
+
70
+ ```bash
71
+ # Custom header name (default: Auth-User)
72
+ DM_TRUSTED_PROXY_AUTH_HEADER="X-Remote-User"
73
+ ```
74
+
75
+ The extracted username is:
76
+ - Available as `req.proxyAuthUser` for other plugins
77
+ - Used by the `weblogs` plugin to log the user field
78
+
79
+ ### Example: nginx Configuration
80
+
81
+ ```nginx
82
+ upstream ldap_rest {
83
+ server 127.0.0.1:8081;
84
+ }
85
+
86
+ server {
87
+ listen 443 ssl;
88
+ server_name api.example.com;
89
+
90
+ location / {
91
+ proxy_pass http://ldap_rest;
92
+ proxy_set_header Host $host;
93
+ proxy_set_header X-Real-IP $remote_addr;
94
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
95
+ proxy_set_header X-Forwarded-Proto $scheme;
96
+
97
+ # Optional: Pass authenticated user from nginx auth
98
+ proxy_set_header Auth-User $remote_user;
99
+ }
100
+ }
101
+ ```
102
+
103
+ LDAP-Rest configuration:
104
+
105
+ ```bash
106
+ DM_TRUSTED_PROXIES="127.0.0.1"
107
+ ```
108
+
109
+ ### Example: HAProxy Configuration
110
+
111
+ ```haproxy
112
+ frontend https
113
+ bind *:443 ssl crt /etc/ssl/cert.pem
114
+ default_backend ldap_rest
115
+
116
+ backend ldap_rest
117
+ option forwardfor
118
+ http-request set-header X-Forwarded-Proto https
119
+ server ldap_rest 127.0.0.1:8081
120
+ ```
121
+
122
+ ### Use Cases
123
+
124
+ - **Reverse Proxy Deployments:** Ensure correct client IP for rate limiting and logging
125
+ - **Load Balancers:** Trust headers from known load balancer IPs
126
+ - **CDN Integration:** Trust headers from CDN edge servers
127
+ - **Kubernetes:** Trust headers from ingress controllers
128
+ - **Security:** Prevent attackers from spoofing `X-Forwarded-For` to bypass rate limits
129
+
130
+ ### Security Considerations
131
+
132
+ - **Only trust known proxies:** Never use `0.0.0.0/0` or trust all IPs
133
+ - **Use specific IPs/ranges:** Limit to your actual proxy infrastructure
134
+ - **HTTPS between proxy and LDAP-Rest:** Prevent header injection attacks
135
+ - **Monitor logs:** Watch for warnings about removed X-Forwarded-For headers
136
+
137
+ ### Plugin Load Order
138
+
139
+ The `trustedProxy` plugin is automatically loaded **first** (via `priority.json`) to ensure all other plugins see sanitized headers:
140
+
141
+ 1. `core/auth/trustedProxy` - Sanitizes X-Forwarded-For headers
142
+ 2. `core/weblogs` - Logs requests with correct client IP
143
+ 3. `core/auth/crowdsec` - Checks IP reputation
144
+ 4. `core/auth/rateLimit` - Rate limits by IP
145
+ 5. Authentication plugins...
146
+
14
147
  ## Token Authentication
15
148
 
16
149
  Simple stateless authentication using bearer tokens.
@@ -243,6 +243,66 @@ proxyAddresses: SMTP:john.doe@company.com
243
243
 
244
244
  The `smtp:` prefix is automatically stripped. The capitalization (SMTP vs smtp) is ignored - only the email address after the colon is used.
245
245
 
246
+ ## REST API
247
+
248
+ The James plugin exposes a REST API to retrieve quota usage and limits for users.
249
+
250
+ ### GET /api/v1/users/:user/quota-usage
251
+
252
+ Retrieve quota usage and limits for a user from James. This endpoint returns both the quota limits configured for the user and their current mailbox usage.
253
+
254
+ **Request:**
255
+
256
+ ```bash
257
+ GET /api/v1/users/jdoe/quota-usage
258
+ ```
259
+
260
+ **Response (200 OK):**
261
+
262
+ ```json
263
+ {
264
+ "global": { "count": 1000, "size": 1000000000 },
265
+ "domain": { "count": 800, "size": 800000000 },
266
+ "user": { "count": 500, "size": 75000000 },
267
+ "computed": { "count": 500, "size": 75000000 },
268
+ "occupation": {
269
+ "size": 50000000,
270
+ "count": 250,
271
+ "ratio": {
272
+ "size": 0.67,
273
+ "count": 0.5,
274
+ "max": 0.67
275
+ }
276
+ }
277
+ }
278
+ ```
279
+
280
+ **Response Fields:**
281
+
282
+ - `global`: Global quota limit (server-wide)
283
+ - `domain`: Domain-specific quota limit
284
+ - `user`: User-specific quota limit from LDAP
285
+ - `computed`: Effective quota limit applied (resolved from upper values)
286
+ - `occupation`: **Current mailbox usage statistics**
287
+ - `size`: Storage currently used (bytes)
288
+ - `count`: Number of messages in mailbox
289
+ - `ratio`: Usage ratios (used / limit)
290
+
291
+ **Error Responses:**
292
+
293
+ - `404`: User not found in LDAP or quota not found in James
294
+ - `400`: User has no mail attribute
295
+ - `502`: Failed to retrieve quota from James
296
+ - `500`: Internal server error
297
+
298
+ **Example:**
299
+
300
+ ```bash
301
+ # Get quota usage for user 'jdoe'
302
+ curl http://localhost:8081/api/v1/users/jdoe/quota-usage \
303
+ -H "Authorization: Bearer admin-token"
304
+ ```
305
+
246
306
  ## James WebAdmin API
247
307
 
248
308
  ### Endpoints Used
@@ -251,6 +311,7 @@ The `smtp:` prefix is automatically stripped. The capitalization (SMTP vs smtp)
251
311
  | ------------------- | ------------------------------------------ | ------ |
252
312
  | Rename account | `/users/{old}/rename/{new}?action=rename` | POST |
253
313
  | Update quota | `/quota/users/{mail}/size` | PUT |
314
+ | Get quota | `/quota/users/{mail}` | GET |
254
315
  | Add delegation | `/users/{mail}/authorizedUsers/{delegate}` | PUT |
255
316
  | Remove delegation | `/users/{mail}/authorizedUsers/{delegate}` | DELETE |
256
317
  | Add alias | `/address/aliases/{mail}/sources/{alias}` | PUT |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -64,6 +64,10 @@
64
64
  "import": "./dist/plugins/auth/totp.js",
65
65
  "types": "./dist/src/plugins/auth/totp.d.ts"
66
66
  },
67
+ "./plugin-auth-trustedproxy": {
68
+ "import": "./dist/plugins/auth/trustedProxy.js",
69
+ "types": "./dist/src/plugins/auth/trustedProxy.d.ts"
70
+ },
67
71
  "./plugin-configapi": {
68
72
  "import": "./dist/plugins/configApi.js",
69
73
  "types": "./dist/src/plugins/configApi.d.ts"
@@ -450,6 +454,9 @@
450
454
  "test:dev": "./.dev.mk test",
451
455
  "test:one": "mocha --config .mocharc-one.json"
452
456
  },
457
+ "overrides": {
458
+ "glob": ">=11.0.4"
459
+ },
453
460
  "dependencies": {
454
461
  "body-parser": "^2.2.0",
455
462
  "csv-parse": "^6.1.0",