ldap-rest 0.1.3 → 0.1.4

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.
Files changed (28) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/lib/ldapActions.js +1 -1
  3. package/dist/plugins/ldap/groups.js +1 -1
  4. package/dist/src/abstract/ldapFlat.d.ts +9 -0
  5. package/dist/src/browser/ldap-user-editor/components/UserTree.d.ts +2 -1
  6. package/dist/src/browser/shared/components/DisposableComponent.d.ts +29 -0
  7. package/dist/src/browser/shared/components/Modal.d.ts +6 -1
  8. package/dist/src/lib/ldapActions.d.ts +9 -0
  9. package/dist/src/plugins/ldap/groups.d.ts +9 -0
  10. package/package.json +5 -1
  11. package/static/browser/ldap-user-editor/components/UserTree.js +6 -3
  12. package/static/browser/ldap-user-editor/components/UserTree.js.map +1 -1
  13. package/static/browser/ldap-user-editor.esm.js +62 -3
  14. package/static/browser/ldap-user-editor.esm.js.map +1 -1
  15. package/static/browser/ldap-user-editor.js +62 -3
  16. package/static/browser/ldap-user-editor.js.map +1 -1
  17. package/static/browser/ldap-user-editor.min.js +1 -1
  18. package/static/browser/ldap-user-editor.min.js.map +1 -1
  19. package/static/browser/shared/components/DisposableComponent.js +59 -0
  20. package/static/browser/shared/components/DisposableComponent.js.map +1 -0
  21. package/static/browser/shared/components/Modal.js +16 -6
  22. package/static/browser/shared/components/Modal.js.map +1 -1
  23. package/static/browser/shared.esm.js +72 -6
  24. package/static/browser/shared.esm.js.map +1 -1
  25. package/static/browser/shared.js +72 -6
  26. package/static/browser/shared.js.map +1 -1
  27. package/static/browser/shared.min.js +1 -1
  28. package/static/browser/shared.min.js.map +1 -1
@@ -1 +1 @@
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
+ 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{getCompiledRegex(t,e){const i=e?`${t}:${e}`:t;let s=this.regexCache.get(i);return s||(s=new RegExp(t,e),this.regexCache.set(i,s)),s}escapeRegex(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}constructor(e,i){if(super(e),this.regexCache=new Map,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=>this.getCompiledRegex(`,?${this.escapeRegex(t)}$`,"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="string"==typeof i.test?this.getCompiledRegex(i.test):i.test;return Array.isArray(e)?e.every(e=>t.test(e)):t.test(e)}return!0}}export{LdapFlat as default};
@@ -1 +1 @@
1
- import{Client as e,Attribute as t,Change as i}from"ldapts";import{LRUCache as n}from"lru-cache";import o from"p-limit";import{launchHooksChained as s,launchHooks as r}from"./utils.js";const a={scope:"sub",filter:"(objectClass=*)",attributes:["*"],sizeLimit:0,timeLimit:10,paged:{pageSize:100}};class ldapActions{constructor(e){this.connectionPool=[],this.currentUrlIndex=0,this.parent=e,this.logger=e.logger,this.config=e.config,this.poolSize=this.config.ldap_pool_size||5,this.connectionTtl=1e3*(this.config.ldap_connection_ttl||60),this.logger.info(`LDAP connection pool initialized: size=${this.poolSize}, ttl=${this.connectionTtl/1e3}s`);const t=this.config.ldap_concurrency||10;this.queryLimit=o(t),this.logger.info(`Global LDAP query concurrency limit set to ${t}`);const i="string"==typeof this.config.ldap_cache_max?parseInt(this.config.ldap_cache_max,10)||1e3:this.config.ldap_cache_max??1e3,s=1e3*(this.config.ldap_cache_ttl||300);if(this.searchCache=new n({max:i,ttl:s,updateAgeOnGet:!1,updateAgeOnHas:!1}),this.logger.info(`LDAP search cache initialized: max=${i}, ttl=${s/1e3}s`),!e.config.ldap_url||0===e.config.ldap_url.length)throw new Error("LDAP URL is not defined");if(!e.config.ldap_dn)throw new Error("LDAP DN is not defined");if(!e.config.ldap_pwd)throw new Error("LDAP password is not defined");e.config.ldap_base?this.base=e.config.ldap_base:(this.base=e.config.ldap_dn.split(",",2)[1],this.logger.warn(`LDAP base is not defined, using "${this.base}"`)),this.ldapUrls=e.config.ldap_url,this.logger.info(`LDAP failover configured with ${this.ldapUrls.length} URL(s): ${this.ldapUrls.join(", ")}`),this.options={url:this.ldapUrls[0],timeout:0,connectTimeout:0,strictDN:!1},this.ldapUrls[0].startsWith("ldaps://")&&(this.options.tlsOptions={minVersion:"TLSv1.2"}),this.dn=e.config.ldap_dn,this.pwd=e.config.ldap_pwd}async createConnection(){const t=[];for(let i=0;i<this.ldapUrls.length;i++){const n=(this.currentUrlIndex+i)%this.ldapUrls.length,o=this.ldapUrls[n];try{this.logger.debug(`Attempting connection to ${o}`);const t={...this.options,url:o,tlsOptions:o.startsWith("ldaps://")?{minVersion:"TLSv1.2"}:void 0},i=new e(t);return await i.bind(this.dn,this.pwd),n!==this.currentUrlIndex&&(this.logger.info(`LDAP failover: switched from ${this.ldapUrls[this.currentUrlIndex]} to ${o}`),this.currentUrlIndex=n),i}catch(e){this.logger.warn(`Failed to connect to ${o}: ${String(e)}`),t.push(e)}}throw this.logger.error(`LDAP connection failed for all ${this.ldapUrls.length} URL(s)`),new Error(`LDAP connection failed for all URLs: ${t.map(e=>e.message).join(", ")}`)}cleanupExpiredConnections(){const e=Date.now(),t=[];for(let i=this.connectionPool.length-1;i>=0;i--){const n=this.connectionPool[i];!n.inUse&&e-n.createdAt>this.connectionTtl&&(t.push(n),this.connectionPool.splice(i,1))}for(const e of t)e.client.unbind().catch(e=>{this.logger.debug(`Error unbinding expired connection: ${String(e)}`)});t.length>0&&this.logger.debug(`Cleaned up ${t.length} expired LDAP connections`)}async acquireConnection(){this.cleanupExpiredConnections();const e=this.connectionPool.find(e=>!e.inUse);if(e)return e.inUse=!0,this.logger.debug("Reusing pooled LDAP connection"),e;if(this.connectionPool.length<this.poolSize){const e={client:await this.createConnection(),createdAt:Date.now(),inUse:!0};return this.connectionPool.push(e),this.logger.debug(`Created new LDAP connection (pool: ${this.connectionPool.length}/${this.poolSize})`),e}return this.logger.debug("LDAP connection pool full, waiting for available connection"),new Promise(e=>{const t=setInterval(()=>{this.cleanupExpiredConnections();const i=this.connectionPool.find(e=>!e.inUse);i&&(clearInterval(t),i.inUse=!0,e(i))},50)})}releaseConnection(e){e.inUse=!1,this.logger.debug("Released LDAP connection back to pool")}getCacheKey(e,t){const i=t.attributes?[...t.attributes].sort().join(","):"*",n="string"==typeof t.filter?t.filter:t.filter?t.filter.toString():"(objectClass=*)";return`${e}:${t.scope||"sub"}:${n}:${i}`}invalidateCache(e){for(const t of this.searchCache.keys())t.startsWith(e)&&this.searchCache.delete(t)}async search(e,t=this.base,i){let n={...a,...e};if(n=await s(this.parent.hooks.ldapsearchopts,n),[t,n]=await s(this.parent.hooks.ldapsearchrequest,[t,n,i]),!n.paged&&"base"===n.scope){const e=this.getCacheKey(t,n),i=this.searchCache.get(e);if(i)return this.logger.debug(`LDAP search cache hit: ${e}`),i}const o=await this.acquireConnection();try{let e=n.paged?o.client.searchPaginated(t,n):o.client.search(t,n);if(e=await s(this.parent.hooks.ldapsearchresult,e),!n.paged&&"base"===n.scope&&e instanceof Promise){const i=await e,o=this.getCacheKey(t,n);return this.searchCache.set(o,i),this.logger.debug(`LDAP search cached: ${o}`),i}return n.paged?this.wrapPaginatedSearch(e,o):e}finally{n.paged||this.releaseConnection(o)}}async*wrapPaginatedSearch(e,t){try{for await(const t of e)yield t}finally{this.releaseConnection(t)}}async add(e,i,n){e=this.setDn(e),i.objectClass&&0!==i.objectClass.length||!this.config.user_class||(i.objectClass=this.config.user_class);const o={};for(const[e,t]of Object.entries(i))Buffer.isBuffer(t)?o[e]=t.toString():Array.isArray(t)&&t.length>0&&Buffer.isBuffer(t[0])?o[e]=t.map(e=>e.toString()):o[e]=t;[e,i]=await s(this.parent.hooks.ldapaddrequest,[e,o,n]);const a=[];for(const[e,i]of Object.entries(o)){const n=Array.isArray(i)?i:[i];a.push(new t({type:e,values:n}))}const c=await this.acquireConnection();try{return await c.client.add(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapadddone,[e,i]),!0}catch(e){throw new Error(`LDAP add error: ${e}`)}finally{this.releaseConnection(c)}}async modify(e,n,o){e=this.setDn(e);const a=[],c=this.opNumber();if([e,n]=await s(this.parent.hooks.ldapmodifyrequest,[e,n,c,o]),n.add)for(const[e,o]of Object.entries(n.add))a.push(new i({operation:"add",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.replace)for(const[e,o]of Object.entries(n.replace))a.push(new i({operation:"replace",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.delete)if(Array.isArray(n.delete))for(const e of n.delete)e&&a.push(new i({operation:"delete",modification:new t({type:e,values:[]})}));else for(const[e,o]of Object.entries(n.delete)){const n=new i({operation:"delete",modification:new t(o?{type:e,values:Array.isArray(o)?o:[o]}:{type:e})});a.push(n)}if(0===a.length)return this.logger.error("No changes to apply"),r(this.parent.hooks.ldapmodifydone,[e,{},c]),!1;{const t=await this.acquireConnection();try{return await t.client.modify(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapmodifydone,[e,n,c]),!0}catch(t){throw this.logger.warn(`Changes that failed: ${e}, ${JSON.stringify(a)}`),new Error(`LDAP modify error: ${t}`)}finally{this.releaseConnection(t)}}}async rename(e,t,i){e=this.setDn(e),t=this.setDn(t),[e,t]=await s(this.parent.hooks.ldaprenamerequest,[e,t,i]);const n=await this.acquireConnection();try{return await n.client.modifyDN(e,t),r(this.parent.hooks.ldaprenamedone,[e,t]),!0}catch(e){throw new Error(`LDAP rename error: ${e}`)}finally{this.releaseConnection(n)}}async move(e,t){e=this.setDn(e),t=this.setDn(t);const i=await this.acquireConnection();try{return await i.client.modifyDN(e,t),this.logger.debug(`LDAP move: ${e} -> ${t}`),!0}catch(e){throw new Error(`LDAP move error: ${e}`)}finally{this.releaseConnection(i)}}async delete(e){e=Array.isArray(e)?e.map(e=>this.setDn(e)):this.setDn(e),Array.isArray(e)||(e=[e]),e=await s(this.parent?.hooks.ldapdeleterequest,e);const t=await this.acquireConnection();try{for(const i of e){try{await t.client.del(i),this.invalidateCache(i)}catch(e){throw new Error(`LDAP delete error: ${e}`)}r(this.parent.hooks.ldapdeletedone,i)}return!0}finally{this.releaseConnection(t)}}setDn(e){return/=/.test(e)?/,/.test(e)||(e+=`,${this.base}`):e=`${this.config.ldap_user_main_attribute}=${e},${this.base}`,e}opNumber(){return this.parent.operationSequence++}}export{ldapActions as default};
1
+ import{Client as e,Attribute as t,Change as i}from"ldapts";import{LRUCache as n}from"lru-cache";import o from"p-limit";import{launchHooksChained as s,launchHooks as r}from"./utils.js";const a={scope:"sub",filter:"(objectClass=*)",attributes:["*"],sizeLimit:0,timeLimit:10,paged:{pageSize:100}};class ldapActions{constructor(e){this.connectionPool=[],this.currentUrlIndex=0,this.attrSignatureCache=new Map,this.waitingResolvers=[],this.availableConnections=[],this.parent=e,this.logger=e.logger,this.config=e.config,this.poolSize=this.config.ldap_pool_size||5,this.connectionTtl=1e3*(this.config.ldap_connection_ttl||60),this.logger.info(`LDAP connection pool initialized: size=${this.poolSize}, ttl=${this.connectionTtl/1e3}s`);const t=this.config.ldap_concurrency||10;this.queryLimit=o(t),this.logger.info(`Global LDAP query concurrency limit set to ${t}`);const i="string"==typeof this.config.ldap_cache_max?parseInt(this.config.ldap_cache_max,10)||1e3:this.config.ldap_cache_max??1e3,s=1e3*(this.config.ldap_cache_ttl||300);if(this.searchCache=new n({max:i,ttl:s,updateAgeOnGet:!1,updateAgeOnHas:!1}),this.logger.info(`LDAP search cache initialized: max=${i}, ttl=${s/1e3}s`),!e.config.ldap_url||0===e.config.ldap_url.length)throw new Error("LDAP URL is not defined");if(!e.config.ldap_dn)throw new Error("LDAP DN is not defined");if(!e.config.ldap_pwd)throw new Error("LDAP password is not defined");e.config.ldap_base?this.base=e.config.ldap_base:(this.base=e.config.ldap_dn.split(",",2)[1],this.logger.warn(`LDAP base is not defined, using "${this.base}"`)),this.ldapUrls=e.config.ldap_url,this.logger.info(`LDAP failover configured with ${this.ldapUrls.length} URL(s): ${this.ldapUrls.join(", ")}`),this.options={url:this.ldapUrls[0],timeout:0,connectTimeout:0,strictDN:!1},this.ldapUrls[0].startsWith("ldaps://")&&(this.options.tlsOptions={minVersion:"TLSv1.2"}),this.dn=e.config.ldap_dn,this.pwd=e.config.ldap_pwd}async createConnection(){const t=[];for(let i=0;i<this.ldapUrls.length;i++){const n=(this.currentUrlIndex+i)%this.ldapUrls.length,o=this.ldapUrls[n];try{this.logger.debug(`Attempting connection to ${o}`);const t={...this.options,url:o,tlsOptions:o.startsWith("ldaps://")?{minVersion:"TLSv1.2"}:void 0},i=new e(t);return await i.bind(this.dn,this.pwd),n!==this.currentUrlIndex&&(this.logger.info(`LDAP failover: switched from ${this.ldapUrls[this.currentUrlIndex]} to ${o}`),this.currentUrlIndex=n),i}catch(e){this.logger.warn(`Failed to connect to ${o}: ${String(e)}`),t.push(e)}}throw this.logger.error(`LDAP connection failed for all ${this.ldapUrls.length} URL(s)`),new Error(`LDAP connection failed for all URLs: ${t.map(e=>e.message).join(", ")}`)}cleanupExpiredConnections(){const e=Date.now(),t=[];for(let i=this.availableConnections.length-1;i>=0;i--){const n=this.availableConnections[i];if(e-n.createdAt>this.connectionTtl){t.push(n),this.availableConnections.splice(i,1);const e=this.connectionPool.indexOf(n);-1!==e&&this.connectionPool.splice(e,1)}}for(const e of t)e.client.unbind().catch(e=>{this.logger.debug(`Error unbinding expired connection: ${String(e)}`)});t.length>0&&this.logger.debug(`Cleaned up ${t.length} expired LDAP connections`)}async acquireConnection(){if(this.cleanupExpiredConnections(),this.availableConnections.length>0){const e=this.availableConnections.pop();return e.inUse=!0,this.logger.debug("Reusing pooled LDAP connection"),e}if(this.connectionPool.length<this.poolSize){const e={client:await this.createConnection(),createdAt:Date.now(),inUse:!0};return this.connectionPool.push(e),this.logger.debug(`Created new LDAP connection (pool: ${this.connectionPool.length}/${this.poolSize})`),e}return this.logger.debug("LDAP connection pool full, waiting for available connection"),new Promise((e,t)=>{const i=setTimeout(()=>{const e=this.waitingResolvers.indexOf(resolveWrapper);-1!==e&&this.waitingResolvers.splice(e,1),t(new Error("LDAP connection pool timeout after 30s"))},3e4),resolveWrapper=t=>{clearTimeout(i),e(t)};this.waitingResolvers.push(resolveWrapper)})}releaseConnection(e){if(Date.now()-e.createdAt>this.connectionTtl){const t=this.connectionPool.indexOf(e);return-1!==t&&this.connectionPool.splice(t,1),e.client.unbind().catch(()=>{}),void this.logger.debug("Released expired LDAP connection (discarded)")}if(this.waitingResolvers.length>0){const t=this.waitingResolvers.shift();return this.logger.debug("Released LDAP connection to waiting request"),void t(e)}e.inUse=!1,this.availableConnections.push(e),this.logger.debug("Released LDAP connection back to pool")}getAttributeSignature(e){if(!e||0===e.length)return"*";const t=e.join("|");let i=this.attrSignatureCache.get(t);return i||(i=[...e].sort().join(","),this.attrSignatureCache.set(t,i)),i}getCacheKey(e,t){const i=this.getAttributeSignature(t.attributes),n="string"==typeof t.filter?t.filter:t.filter?t.filter.toString():"(objectClass=*)";return`${e}:${t.scope||"sub"}:${n}:${i}`}invalidateCache(e){for(const t of this.searchCache.keys())t.startsWith(e)&&this.searchCache.delete(t)}async search(e,t=this.base,i){let n={...a,...e};if(n=await s(this.parent.hooks.ldapsearchopts,n),[t,n]=await s(this.parent.hooks.ldapsearchrequest,[t,n,i]),!n.paged&&"base"===n.scope){const e=this.getCacheKey(t,n),i=this.searchCache.get(e);if(i)return this.logger.debug(`LDAP search cache hit: ${e}`),i}const o=await this.acquireConnection();try{let e=n.paged?o.client.searchPaginated(t,n):o.client.search(t,n);if(e=await s(this.parent.hooks.ldapsearchresult,e),!n.paged&&"base"===n.scope&&e instanceof Promise){const i=await e,o=this.getCacheKey(t,n);return this.searchCache.set(o,i),this.logger.debug(`LDAP search cached: ${o}`),i}return n.paged?this.wrapPaginatedSearch(e,o):e}finally{n.paged||this.releaseConnection(o)}}async*wrapPaginatedSearch(e,t){try{for await(const t of e)yield t}finally{this.releaseConnection(t)}}async add(e,i,n){e=this.setDn(e),i.objectClass&&0!==i.objectClass.length||!this.config.user_class||(i.objectClass=this.config.user_class);const o={};for(const[e,t]of Object.entries(i))Buffer.isBuffer(t)?o[e]=t.toString():Array.isArray(t)&&t.length>0&&Buffer.isBuffer(t[0])?o[e]=t.map(e=>e.toString()):o[e]=t;[e,i]=await s(this.parent.hooks.ldapaddrequest,[e,o,n]);const a=[];for(const[e,i]of Object.entries(o)){const n=Array.isArray(i)?i:[i];a.push(new t({type:e,values:n}))}const c=await this.acquireConnection();try{return await c.client.add(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapadddone,[e,i]),!0}catch(e){throw new Error(`LDAP add error: ${e}`)}finally{this.releaseConnection(c)}}async modify(e,n,o){e=this.setDn(e);const a=[],c=this.opNumber();if([e,n]=await s(this.parent.hooks.ldapmodifyrequest,[e,n,c,o]),n.add)for(const[e,o]of Object.entries(n.add))a.push(new i({operation:"add",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.replace)for(const[e,o]of Object.entries(n.replace))a.push(new i({operation:"replace",modification:new t({type:e,values:Array.isArray(o)?o:[o]})}));if(n.delete)if(Array.isArray(n.delete))for(const e of n.delete)e&&a.push(new i({operation:"delete",modification:new t({type:e,values:[]})}));else for(const[e,o]of Object.entries(n.delete)){const n=new i({operation:"delete",modification:new t(o?{type:e,values:Array.isArray(o)?o:[o]}:{type:e})});a.push(n)}if(0===a.length)return this.logger.error("No changes to apply"),r(this.parent.hooks.ldapmodifydone,[e,{},c]),!1;{const t=await this.acquireConnection();try{return await t.client.modify(e,a),this.invalidateCache(e),r(this.parent.hooks.ldapmodifydone,[e,n,c]),!0}catch(t){throw this.logger.warn(`Changes that failed: ${e}, ${JSON.stringify(a)}`),new Error(`LDAP modify error: ${t}`)}finally{this.releaseConnection(t)}}}async rename(e,t,i){e=this.setDn(e),t=this.setDn(t),[e,t]=await s(this.parent.hooks.ldaprenamerequest,[e,t,i]);const n=await this.acquireConnection();try{return await n.client.modifyDN(e,t),r(this.parent.hooks.ldaprenamedone,[e,t]),!0}catch(e){throw new Error(`LDAP rename error: ${e}`)}finally{this.releaseConnection(n)}}async move(e,t){e=this.setDn(e),t=this.setDn(t);const i=await this.acquireConnection();try{return await i.client.modifyDN(e,t),this.logger.debug(`LDAP move: ${e} -> ${t}`),!0}catch(e){throw new Error(`LDAP move error: ${e}`)}finally{this.releaseConnection(i)}}async delete(e){e=Array.isArray(e)?e.map(e=>this.setDn(e)):this.setDn(e),Array.isArray(e)||(e=[e]),e=await s(this.parent?.hooks.ldapdeleterequest,e);const t=await this.acquireConnection();try{for(const i of e){try{await t.client.del(i),this.invalidateCache(i)}catch(e){throw new Error(`LDAP delete error: ${e}`)}r(this.parent.hooks.ldapdeletedone,i)}return!0}finally{this.releaseConnection(t)}}setDn(e){return/=/.test(e)?/,/.test(e)||(e+=`,${this.base}`):e=`${this.config.ldap_user_main_attribute}=${e},${this.base}`,e}opNumber(){return this.parent.operationSequence++}}export{ldapActions as default};
@@ -1 +1 @@
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
+ 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{getCompiledRegex(e,t){const r=t?`${e}:${t}`:e;let i=this.regexCache.get(r);return i||(i=new RegExp(e,t),this.regexCache.set(r,i)),i}escapeRegex(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.regexCache=new Map,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){const i="string"==typeof r.items.test?this.getCompiledRegex(r.items.test):r.items.test;for(let s of t){if(typeof s!==r.items.type)throw new Error(`Field ${e} must be of type ${r.items.type}`);if("string"!=typeof s&&(s=s.toString()),!i.test(s))throw new Error(`Field ${e} has invalid value ${s}`)}}}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=>this.getCompiledRegex(`,?${this.escapeRegex(e)}$`,"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){if(!("string"==typeof r.test?this.getCompiledRegex(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){if(!("string"==typeof r.test?this.getCompiledRegex(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};
@@ -48,6 +48,15 @@ export default abstract class LdapFlat extends DmPlugin {
48
48
  singularName: string;
49
49
  pluralName: string;
50
50
  hookPrefix: string;
51
+ private regexCache;
52
+ /**
53
+ * Get a compiled RegExp from cache, or compile and cache it
54
+ */
55
+ protected getCompiledRegex(pattern: string, flags?: string): RegExp;
56
+ /**
57
+ * Escape special regex characters in a string
58
+ */
59
+ protected escapeRegex(str: string): string;
51
60
  constructor(server: DM, config: LdapFlatConfig);
52
61
  /**
53
62
  * API routes
@@ -2,7 +2,8 @@
2
2
  * LDAP User Editor - Organization Tree Component
3
3
  * Shows only LDAP organization tree (filters out users/groups)
4
4
  */
5
- export declare class UserTree {
5
+ import { DisposableComponent } from '../../shared/components/DisposableComponent';
6
+ export declare class UserTree extends DisposableComponent {
6
7
  private container;
7
8
  private baseUrl;
8
9
  private onSelectOrg;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Base class for browser components with proper lifecycle management
3
+ * Ensures all event listeners and intervals are cleaned up on destroy
4
+ * @module browser/shared/components/DisposableComponent
5
+ */
6
+ export declare abstract class DisposableComponent {
7
+ private _eventListeners;
8
+ private _intervals;
9
+ private _isDestroyed;
10
+ /**
11
+ * Register an event listener for automatic cleanup
12
+ * Use this instead of element.addEventListener directly
13
+ */
14
+ protected addManagedEventListener(target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
15
+ /**
16
+ * Register an interval for automatic cleanup
17
+ * Use this instead of setInterval directly
18
+ */
19
+ protected addManagedInterval(callback: () => void, ms: number): ReturnType<typeof setInterval>;
20
+ /**
21
+ * Check if the component has been destroyed
22
+ */
23
+ protected get isDestroyed(): boolean;
24
+ /**
25
+ * Clean up all managed resources
26
+ * Override in subclasses but ALWAYS call super.destroy()
27
+ */
28
+ destroy(): void;
29
+ }
@@ -2,18 +2,23 @@
2
2
  * Reusable Modal component
3
3
  * @module browser/shared/components/Modal
4
4
  */
5
+ import { DisposableComponent } from './DisposableComponent';
5
6
  export interface ModalOptions {
6
7
  id: string;
7
8
  title: string;
8
9
  onClose?: () => void;
9
10
  }
10
- export declare class Modal {
11
+ export declare class Modal extends DisposableComponent {
11
12
  private overlay;
12
13
  private options;
13
14
  constructor(options: ModalOptions);
14
15
  private attachEventListeners;
15
16
  open(): void;
16
17
  close(): void;
18
+ /**
19
+ * Clean up all event listeners and resources
20
+ */
21
+ destroy(): void;
17
22
  isOpen(): boolean;
18
23
  getFormElement(): HTMLFormElement | null;
19
24
  resetForm(): void;
@@ -32,6 +32,9 @@ declare class ldapActions {
32
32
  private connectionTtl;
33
33
  private ldapUrls;
34
34
  private currentUrlIndex;
35
+ private attrSignatureCache;
36
+ private waitingResolvers;
37
+ private availableConnections;
35
38
  constructor(server: DM);
36
39
  /**
37
40
  * Create a new LDAP connection with failover support
@@ -43,12 +46,18 @@ declare class ldapActions {
43
46
  private cleanupExpiredConnections;
44
47
  /**
45
48
  * Acquire a connection from the pool or create a new one
49
+ * Optimized for O(1) lookup using separate available connections queue
46
50
  */
47
51
  private acquireConnection;
48
52
  /**
49
53
  * Release a connection back to the pool
54
+ * If there are waiting requests, hand off the connection directly
50
55
  */
51
56
  private releaseConnection;
57
+ /**
58
+ * Get a sorted signature for attribute list, using cache for performance
59
+ */
60
+ private getAttributeSignature;
52
61
  /**
53
62
  * Generate cache key for LDAP search
54
63
  */
@@ -17,6 +17,15 @@ export default class LdapGroups extends DmPlugin {
17
17
  ldap: ldapActions;
18
18
  cn: string;
19
19
  schema?: Schema;
20
+ private regexCache;
21
+ /**
22
+ * Get a compiled RegExp from cache, or compile and cache it
23
+ */
24
+ private getCompiledRegex;
25
+ /**
26
+ * Escape special regex characters in a string
27
+ */
28
+ private escapeRegex;
20
29
  constructor(server: DM);
21
30
  /**
22
31
  * Catch all deletion to remove deleted users from groups
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"
@@ -296,6 +296,10 @@
296
296
  "import": "./static/browser/ldap-user-editor/types.js",
297
297
  "types": "./dist/src/browser/ldap-user-editor/types.d.ts"
298
298
  },
299
+ "./browser-shared-components-disposablecomponent": {
300
+ "import": "./static/browser/shared/components/DisposableComponent.js",
301
+ "types": "./dist/src/browser/shared/components/DisposableComponent.d.ts"
302
+ },
299
303
  "./browser-shared-components-modal": {
300
304
  "import": "./static/browser/shared/components/Modal.js",
301
305
  "types": "./dist/src/browser/shared/components/Modal.d.ts"
@@ -1,11 +1,13 @@
1
1
  import { CacheManager } from '../cache/CacheManager.js';
2
+ import { DisposableComponent } from '../../shared/components/DisposableComponent.js';
2
3
 
3
4
  /**
4
5
  * LDAP User Editor - Organization Tree Component
5
6
  * Shows only LDAP organization tree (filters out users/groups)
6
7
  */
7
- class UserTree {
8
+ class UserTree extends DisposableComponent {
8
9
  constructor(container, baseUrl, onSelectOrg) {
10
+ super();
9
11
  this.expandedNodes = new Set();
10
12
  this.selectedDn = null;
11
13
  this.rootDn = null;
@@ -13,8 +15,8 @@ class UserTree {
13
15
  this.baseUrl = baseUrl;
14
16
  this.onSelectOrg = onSelectOrg;
15
17
  this.cache = new CacheManager();
16
- // Clean expired entries every 5 minutes
17
- window.setInterval(() => this.cache.cleanExpired(), 5 * 60 * 1000);
18
+ // Clean expired entries every 5 minutes - now properly cleaned up
19
+ this.addManagedInterval(() => this.cache.cleanExpired(), 5 * 60 * 1000);
18
20
  }
19
21
  /**
20
22
  * Fetch with cache support
@@ -157,6 +159,7 @@ class UserTree {
157
159
  }
158
160
  destroy() {
159
161
  this.container.innerHTML = '';
162
+ super.destroy();
160
163
  }
161
164
  }
162
165
 
@@ -1 +1 @@
1
- {"version":3,"file":"UserTree.js","sources":["../../../../../src/browser/ldap-user-editor/components/UserTree.ts"],"sourcesContent":[null],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"UserTree.js","sources":["../../../../../src/browser/ldap-user-editor/components/UserTree.ts"],"sourcesContent":[null],"names":[],"mappings":"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"}
@@ -324,12 +324,70 @@ class UserApiClient {
324
324
  }
325
325
  }
326
326
 
327
+ /**
328
+ * Base class for browser components with proper lifecycle management
329
+ * Ensures all event listeners and intervals are cleaned up on destroy
330
+ * @module browser/shared/components/DisposableComponent
331
+ */
332
+ class DisposableComponent {
333
+ constructor() {
334
+ this._eventListeners = [];
335
+ this._intervals = [];
336
+ this._isDestroyed = false;
337
+ }
338
+ /**
339
+ * Register an event listener for automatic cleanup
340
+ * Use this instead of element.addEventListener directly
341
+ */
342
+ addManagedEventListener(target, type, listener, options) {
343
+ if (this._isDestroyed)
344
+ return;
345
+ target.addEventListener(type, listener, options);
346
+ this._eventListeners.push({ target, type, listener, options });
347
+ }
348
+ /**
349
+ * Register an interval for automatic cleanup
350
+ * Use this instead of setInterval directly
351
+ */
352
+ addManagedInterval(callback, ms) {
353
+ const id = setInterval(callback, ms);
354
+ this._intervals.push(id);
355
+ return id;
356
+ }
357
+ /**
358
+ * Check if the component has been destroyed
359
+ */
360
+ get isDestroyed() {
361
+ return this._isDestroyed;
362
+ }
363
+ /**
364
+ * Clean up all managed resources
365
+ * Override in subclasses but ALWAYS call super.destroy()
366
+ */
367
+ destroy() {
368
+ if (this._isDestroyed)
369
+ return;
370
+ this._isDestroyed = true;
371
+ // Remove all event listeners
372
+ for (const { target, type, listener, options } of this._eventListeners) {
373
+ target.removeEventListener(type, listener, options);
374
+ }
375
+ this._eventListeners = [];
376
+ // Clear all intervals
377
+ for (const id of this._intervals) {
378
+ clearInterval(id);
379
+ }
380
+ this._intervals = [];
381
+ }
382
+ }
383
+
327
384
  /**
328
385
  * LDAP User Editor - Organization Tree Component
329
386
  * Shows only LDAP organization tree (filters out users/groups)
330
387
  */
331
- class UserTree {
388
+ class UserTree extends DisposableComponent {
332
389
  constructor(container, baseUrl, onSelectOrg) {
390
+ super();
333
391
  this.expandedNodes = new Set();
334
392
  this.selectedDn = null;
335
393
  this.rootDn = null;
@@ -337,8 +395,8 @@ class UserTree {
337
395
  this.baseUrl = baseUrl;
338
396
  this.onSelectOrg = onSelectOrg;
339
397
  this.cache = new CacheManager();
340
- // Clean expired entries every 5 minutes
341
- window.setInterval(() => this.cache.cleanExpired(), 5 * 60 * 1000);
398
+ // Clean expired entries every 5 minutes - now properly cleaned up
399
+ this.addManagedInterval(() => this.cache.cleanExpired(), 5 * 60 * 1000);
342
400
  }
343
401
  /**
344
402
  * Fetch with cache support
@@ -481,6 +539,7 @@ class UserTree {
481
539
  }
482
540
  destroy() {
483
541
  this.container.innerHTML = '';
542
+ super.destroy();
484
543
  }
485
544
  }
486
545