ldap-rest 0.8.0 → 0.8.2

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,jsonBody as i,created as s,tryMethod as r,multiStatus as a}from"../lib/expressFormatedResponses.js";import{transformSchemas as n,getCompiledRegex as o,escapeDnValue as h,asyncHandler as d,escapeRegex as c,validateDnValue as u,isDummyMemberDn as l,normalizeDn as f,getParentDn as m,launchHooksChained as b,launchHooks as p,escapeLdapFilter as g,isDnInBranch as w,substringSearchFilter as y}from"../lib/utils.js";import{neverReturnAttributes as $,schemaAttribute as A,modifiedAttributeNames as v,missingRequiredAttribute as E,checkDnValues as x}from"../config/schema.js";import{BadRequestError as N,ConflictError as j,NotFoundError as k,HttpError as D}from"../lib/errors.js";import O from"./plugin.js";class LdapFlat extends O{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(n(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}`)}}clientForbiddenAttributes(){const t=new Set;if(!this.schema)return t;for(const[e,i]of Object.entries(this.schema.attributes))(e!==this.mainAttribute||i.generatedFrom)&&(i.generated||i.readOnly)&&t.add(e.toLowerCase());return t}hiddenAttributes(){return $([this.schema])}project(t){const e=this.hiddenAttributes();if(0===e.size)return t;const i={};for(const[s,r]of Object.entries(t))e.has(s.split(";")[0].toLowerCase())||(i[s]=r);return i}projectList(t){if(0===this.hiddenAttributes().size)return t;const e={};for(const[i,s]of Object.entries(t))e[i]=this.project(s);return e}rejectForbiddenInput(t){const e=this.clientForbiddenAttributes();if(0!==e.size)for(const i of t){if("dn"===i)continue;if(!e.has(i.split(";")[0].toLowerCase()))continue;const t=A(this.schema,i)?.[1];throw new N(t?.readOnly?`Attribute "${i}" is read-only and cannot be set`:`Attribute "${i}" is computed by the server and cannot be set`)}}async generateMainAttribute(t){const e=this.schema?.attributes[this.mainAttribute],i=e?.generatedFrom;if(!i||!e.generated)return;const s=t[i.attribute],r=Array.isArray(s)?s[0]:s;if(null==r||""===r)throw new N(`Attribute "${i.attribute}" is required to generate "${this.mainAttribute}"`);let a=String(r);if(i.extract){const t=o(i.extract).exec(a);t&&(a=void 0!==t[1]?t[1]:t[0])}if(i.lowercase&&(a=a.toLowerCase()),i.strip&&(a=a.replace(o(i.strip,"g"),"")),!a)throw new N(`Cannot generate "${this.mainAttribute}" from "${i.attribute}"`);if(!await this.rdnExists(a))return a;if("suffix"!==i.onCollision)throw new j(`${this.singularName} "${a}" already exists (generated from ${i.attribute})`);for(let t=2;t<1e3;t++){const e=`${a}-${t}`;if(!await this.rdnExists(e))return e}throw new j(`Cannot generate a free "${this.mainAttribute}" from "${i.attribute}"`)}async rdnExists(t){try{const e=await this.ldap.search({paged:!1,scope:"base"},`${this.mainAttribute}=${h(t)},${this.base}`);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}invalidValueMessage(t){const e=this.schema?.attributes[t],i=e?.hint||e?.items?.hint;return i?`Invalid value for attribute "${t}": ${i}`:`Invalid value for attribute "${t}"`}api(t){t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,d(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=y(t.query.match,t.query.attribute)),t.query.attributes&&"string"==typeof t.query.attributes&&(s.attributes=t.query.attributes.split(","));const r=await this.listEntries(s);i.json(this.projectList(r))})),t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,d(async(t,e)=>this.apiGet(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,d(async(t,e)=>this.apiAdd(t,e))),t.delete(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,d(async(t,e)=>this.apiDelete(t,e))),t.put(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,d(async(t,e)=>this.apiModify(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`,d(async(t,e)=>this.apiMove(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/rename`,d(async(t,e)=>this.apiRename(t,e)))}async apiGet(t,i){if(!e(t,i))return;const s=decodeURIComponent(t.params.id);try{const t=this.resolveDn(s),e=await this.ldap.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new k(`${this.singularName} not found`);i.json(this.project(e.searchEntries[0]))}catch(t){if(t.code&&32===t.code)throw new k(`${this.singularName} not found`);throw t}}async apiAdd(t,e){const r=this.schema?.attributes[this.mainAttribute],a=Boolean(r?.generated&&r.generatedFrom),n=a?i(t,e):i(t,e,this.mainAttribute);if(!n)return;this.rejectForbiddenInput(Object.keys(n));const o=a?await this.generateMainAttribute(n):n[this.mainAttribute],h={...n};delete h[this.mainAttribute],delete h.dn,await this.addEntry(o,h,t);const d=await this.searchEntriesByName(o,!1);return s(e,this.project(d[o]))}async apiDelete(t,i){if(!e(t,i))return;const s=decodeURIComponent(t.params.id);await r(i,this.deleteEntry.bind(this),s)}async apiModify(t,e){const s=i(t,e);if(!s)return;this.rejectForbiddenInput(v(s));const a=decodeURIComponent(t.params.id);await r(e,this.modifyEntry.bind(this),a,s)}async apiMove(t,s){if(!e(t,s))return;const r=i(t,s,"targetOrgDn");if(!r)return;const a=decodeURIComponent(t.params.id),{targetOrgDn:n}=r;if(!n||"string"!=typeof n)throw new N("Missing or invalid targetOrgDn in request body");const o=await this.moveEntry(a,n,t);s.json({success:!0,...o})}async apiRename(t,s){if(!e(t,s))return;const r=i(t,s,"newId");if(!r)return;const n=r.newId;if("string"!=typeof n)throw new N("Missing or invalid newId in request body");if(this.rejectForbiddenInput(Object.keys(r).filter(t=>"newId"!==t)),new RegExp(`^${c(this.mainAttribute)}=`,"i").test(n))throw new N(`newId must be a ${this.mainAttribute} value, not a DN`);try{u(n,this.mainAttribute)}catch(t){throw new N(t instanceof Error?t.message:"Invalid newId")}if(!await this._validateOneChange(this.mainAttribute,n))throw new N(this.invalidValueMessage(this.mainAttribute));const o=decodeURIComponent(t.params.id),h=this.resolveDn(o),d=this.resolveDn(n),m=this.config.group_dummy_user;if(m&&(l(h,m)||l(d,m)))throw new j(`${m} is the configured placeholder member and cannot be renamed`);const b=f(h)===f(d),p=await this.entryExists(h),g=b?p:await this.entryExists(d),w=this.requirePlugin("ldapEnterpriseRules");let y=[];if(b){if(!p)throw new k(`${this.singularName} not found`)}else if(p){if(g)throw new j(`${this.singularName} "${n}" already exists`);y=await(w?.findReferences(h))||[],await this.renameEntry(h,d,t)}else{if(!g)throw new k(`${this.singularName} not found`);y=await(w?.findReferences(h))||[]}const $=w&&y.length>0?await w.rewriteReferences(y,h,d,t):{updated:0,failed:[]},A={success:0===$.failed.length,dn:d,referencesUpdated:$.updated};0!==$.failed.length?a(s,{...A,referencesFailed:$.failed}):s.json(A)}async entryExists(t){try{const e=await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}resolveDn(t){if(new RegExp(`^${c(this.mainAttribute)}=`,"i").test(t)){const e=m(t),i=e!==t;if(!i||e.toLowerCase()!==this.base.toLowerCase())throw new N(`DN must be a direct child of "${this.base}". Provided DN "${t}" has parent "${i?e:"<none>"}"`);return t}return`${this.mainAttribute}=${h(t)},${this.base}`}async addEntry(t,e={},i){let s;new RegExp(`^${this.mainAttribute}=`,"i").test(t)?(s=this.resolveDn(t),t=t.replace(new RegExp(`^${this.mainAttribute}=((?:\\\\.|[^,])+)(?:,.*)?$`,"i"),"$1")):(u(t,this.mainAttribute),s=this.resolveDn(t)),await this.validateNewEntry(s,{objectClass:this.objectClass,[this.mainAttribute]:t,...e});let r,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"),[s,a]=await b(this.registeredHooks[`${this.hookPrefix}add`],[s,a]),this.logger.debug("Adding LDAP entry:",{dn:s,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{r=await this.ldap.add(s,a,i)}catch(t){const e=t instanceof D&&t.statusCode<500;if(this.logger[e?"debug":"error"]("LDAP add failed:",{dn:s,entry:JSON.stringify(a,null,2),error:t}),t instanceof D)throw t;throw new Error(`Failed to add ${this.singularName} ${s}: ${t}`)}return p(this.registeredHooks[`${this.hookPrefix}adddone`],[s,a]),r}async modifyEntry(t,e){let i=this.resolveDn(t);const s=this.opNumber();if([i,e]=await b(this.registeredHooks[`${this.hookPrefix}modify`],[i,e,s]),e.add&&e.add[this.mainAttribute])throw new j(`${this.mainAttribute} attribute is unique, cannot add`);if(e.delete){if(e.delete instanceof Object&&e.delete[this.mainAttribute])throw new N(`Cannot delete ${this.mainAttribute} attribute`);if(Array.isArray(e.delete)&&e.delete.includes(this.mainAttribute))throw new N(`Cannot delete ${this.mainAttribute} attribute`)}if(e.replace&&e.replace[this.mainAttribute])throw new N(`Use dedicated API to change ${this.mainAttribute} attribute`);await this.validateChanges(i,e);const r=await this.ldap.modify(i,e);return p(this.registeredHooks[`${this.hookPrefix}modifydone`],[i,e,s]),r}async renameEntry(t,e,i){/,/.test(t)||u(t,this.mainAttribute),/,/.test(e)||u(e,this.mainAttribute);let s=this.resolveDn(t),r=this.resolveDn(e);[s,r]=await b(this.registeredHooks[`${this.hookPrefix}rename`],[s,r]);const a=await(i?this.ldap.forRequest(i).rename(s,r):this.ldap.rename(s,r));return p(this.registeredHooks[`${this.hookPrefix}renamedone`],[s,r]),a}async deleteEntry(t){let e=this.resolveDn(t);e=await b(this.registeredHooks[`${this.hookPrefix}delete`],e);const i=await this.ldap.delete(e);return p(this.registeredHooks[`${this.hookPrefix}deletedone`],e),i}async moveEntry(t,e,i){const s=this.resolveDn(t),r=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",a=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";if(!this.schema?.attributes[r]||!this.schema?.attributes[a])throw new N(`Schema for ${this.singularName} does not support move operation (missing ${r} or ${a})`);const n=await this.ldap.search({paged:!1,scope:"base",attributes:[r]},s,i);if(!n.searchEntries||0===n.searchEntries.length)throw new k(`Entry ${s} not found`);const o=await this.getDepartmentPath(e,i);[,e]=await b(this.registeredHooks[`${this.hookPrefix}move`],[s,e,i]);const h={replace:{[r]:e,[a]:o}};return await this.modifyEntry(t,h),{departmentPath:o,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 k(`Organization ${t} not found`);const r=s.searchEntries[0];if(r[i]){const t=r[i];return Array.isArray(t)?String(t[0]):String(t)}const a=r.ou||r.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){if(e instanceof k)throw e;if(32===e.code)throw new k(`Organization ${t} not found`);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),r={};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),r[i]=t}});return r}async searchEntriesByName(t,e=!1,i=[this.mainAttribute]){const s=g(t),r=e?`(${this.mainAttribute}=*${s}*)`:`(${this.mainAttribute}=${s})`;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 N(`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 N(`Attribute "${t}" is fixed and cannot be modified. Expected: ${s}`)}if(!await this._validateOneChange(t,i))throw new N(this.invalidValueMessage(t));if(e.required&&!i&&!e.generated)throw new N(`Attribute "${t}" is required`)}const i=E(this.schema,e,this.server.loadedPlugins);if(i)throw new N(`Attribute "${i}" 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 N(`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 N(this.invalidValueMessage(t));if(e.replace)for(const[t,i]of Object.entries(e.replace))if(checkFixed(t),!await this._validateOneChange(t,i))throw new N(this.invalidValueMessage(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 N(`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 N(`Field ${t} must be a string (DN pointer)`);const s=e;if(i.branch&&i.branch.length>0){if(!i.branch.some(t=>w(s,t)))throw new N(`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 N(`Field ${t} points to non-existent DN: ${s}`)}catch(e){throw new N(`Field ${t} points to invalid or non-existent DN: ${s}`)}}"pointer"!==i.type&&await x(t,i,e,async t=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0);const s=i.test??i.items?.test;if(s){const t="string"==typeof s?o(s):s;return function(t){return(Array.isArray(t)?t:[t]).map(t=>String(t))}(e).every(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 r,multiStatus as a}from"../lib/expressFormatedResponses.js";import{transformSchemas as n,getCompiledRegex as o,escapeDnValue as h,asyncHandler as d,escapeRegex as c,validateDnValue as u,isDummyMemberDn as l,normalizeDn as f,getParentDn as m,launchHooksChained as b,launchHooks as p,escapeLdapFilter as g,isDnInBranch as w,substringSearchFilter as y}from"../lib/utils.js";import{neverReturnAttributes as $,schemaAttribute as A,modifiedAttributeNames as v,missingRequiredAttribute as E,checkDnValues as x}from"../config/schema.js";import{BadRequestError as N,ConflictError as j,NotFoundError as k,HttpError as D}from"../lib/errors.js";import O from"./plugin.js";class LdapFlat extends O{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(n(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}`)}}clientForbiddenAttributes(){const t=new Set;if(!this.schema)return t;for(const[e,i]of Object.entries(this.schema.attributes))(e!==this.mainAttribute||i.generatedFrom)&&(i.generated||i.readOnly)&&t.add(e.toLowerCase());return t}hiddenAttributes(){return $([this.schema])}project(t){const e=this.hiddenAttributes();if(0===e.size)return t;const i={};for(const[s,r]of Object.entries(t))e.has(s.split(";")[0].toLowerCase())||(i[s]=r);return i}projectList(t){if(0===this.hiddenAttributes().size)return t;const e={};for(const[i,s]of Object.entries(t))e[i]=this.project(s);return e}rejectForbiddenInput(t){const e=this.clientForbiddenAttributes();if(0!==e.size)for(const i of t){if("dn"===i)continue;if(!e.has(i.split(";")[0].toLowerCase()))continue;const t=A(this.schema,i)?.[1];throw new N(t?.readOnly?`Attribute "${i}" is read-only and cannot be set`:`Attribute "${i}" is computed by the server and cannot be set`)}}async generateMainAttribute(t){const e=this.schema?.attributes[this.mainAttribute],i=e?.generatedFrom;if(!i||!e.generated)return;const s=t[i.attribute],r=Array.isArray(s)?s[0]:s;if(null==r||""===r)throw new N(`Attribute "${i.attribute}" is required to generate "${this.mainAttribute}"`);let a=String(r);if(i.extract){const t=o(i.extract).exec(a);t&&(a=void 0!==t[1]?t[1]:t[0])}if(i.lowercase&&(a=a.toLowerCase()),i.strip&&(a=a.replace(o(i.strip,"g"),"")),!a)throw new N(`Cannot generate "${this.mainAttribute}" from "${i.attribute}"`);if(!await this.rdnExists(a))return a;if("suffix"!==i.onCollision)throw new j(`${this.singularName} "${a}" already exists (generated from ${i.attribute})`);for(let t=2;t<1e3;t++){const e=`${a}-${t}`;if(!await this.rdnExists(e))return e}throw new j(`Cannot generate a free "${this.mainAttribute}" from "${i.attribute}"`)}async rdnExists(t){try{const e=await this.ldap.search({paged:!1,scope:"base"},`${this.mainAttribute}=${h(t)},${this.base}`);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}invalidValueMessage(t){const e=this.schema?.attributes[t],i=e?.hint||e?.items?.hint;return i?`Invalid value for attribute "${t}": ${i}`:`Invalid value for attribute "${t}"`}api(t){t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,d(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=y(t.query.match,t.query.attribute)),t.query.attributes&&"string"==typeof t.query.attributes&&(s.attributes=t.query.attributes.split(","));const r=await this.listEntries({...s,req:t});i.json(this.projectList(r))})),t.get(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,d(async(t,e)=>this.apiGet(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,d(async(t,e)=>this.apiAdd(t,e))),t.delete(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,d(async(t,e)=>this.apiDelete(t,e))),t.put(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,d(async(t,e)=>this.apiModify(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`,d(async(t,e)=>this.apiMove(t,e))),t.post(`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/rename`,d(async(t,e)=>this.apiRename(t,e)))}async apiGet(t,i){if(!e(t,i))return;const s=decodeURIComponent(t.params.id);try{const e=this.resolveDn(s),r=await this.ldap.search({paged:!1,scope:"base"},e,t);if(0===r.searchEntries.length)throw new k(`${this.singularName} not found`);i.json(this.project(r.searchEntries[0]))}catch(t){if(t.code&&32===t.code)throw new k(`${this.singularName} not found`);throw t}}async apiAdd(t,e){const r=this.schema?.attributes[this.mainAttribute],a=Boolean(r?.generated&&r.generatedFrom),n=a?i(t,e):i(t,e,this.mainAttribute);if(!n)return;this.rejectForbiddenInput(Object.keys(n));const o=a?await this.generateMainAttribute(n):n[this.mainAttribute],h={...n};delete h[this.mainAttribute],delete h.dn,await this.addEntry(o,h,t);const d=await this.searchEntriesByName(o,!1);return s(e,this.project(d[o]))}async apiDelete(t,i){if(!e(t,i))return;const s=decodeURIComponent(t.params.id);await r(i,this.deleteEntry.bind(this),s,t)}async apiModify(t,e){const s=i(t,e);if(!s)return;this.rejectForbiddenInput(v(s));const a=decodeURIComponent(t.params.id);await r(e,this.modifyEntry.bind(this),a,s,t)}async apiMove(t,s){if(!e(t,s))return;const r=i(t,s,"targetOrgDn");if(!r)return;const a=decodeURIComponent(t.params.id),{targetOrgDn:n}=r;if(!n||"string"!=typeof n)throw new N("Missing or invalid targetOrgDn in request body");const o=await this.moveEntry(a,n,t);s.json({success:!0,...o})}async apiRename(t,s){if(!e(t,s))return;const r=i(t,s,"newId");if(!r)return;const n=r.newId;if("string"!=typeof n)throw new N("Missing or invalid newId in request body");if(this.rejectForbiddenInput(Object.keys(r).filter(t=>"newId"!==t)),new RegExp(`^${c(this.mainAttribute)}=`,"i").test(n))throw new N(`newId must be a ${this.mainAttribute} value, not a DN`);try{u(n,this.mainAttribute)}catch(t){throw new N(t instanceof Error?t.message:"Invalid newId")}if(!await this._validateOneChange(this.mainAttribute,n))throw new N(this.invalidValueMessage(this.mainAttribute));const o=decodeURIComponent(t.params.id),h=this.resolveDn(o),d=this.resolveDn(n),m=this.config.group_dummy_user;if(m&&(l(h,m)||l(d,m)))throw new j(`${m} is the configured placeholder member and cannot be renamed`);const b=f(h)===f(d),p=await this.entryExists(h),g=b?p:await this.entryExists(d),w=this.requirePlugin("ldapEnterpriseRules");let y=[];if(b){if(!p)throw new k(`${this.singularName} not found`)}else if(p){if(g)throw new j(`${this.singularName} "${n}" already exists`);y=await(w?.findReferences(h))||[],await this.renameEntry(h,d,t)}else{if(!g)throw new k(`${this.singularName} not found`);y=await(w?.findReferences(h))||[]}const $=w&&y.length>0?await w.rewriteReferences(y,h,d,t):{updated:0,failed:[]},A={success:0===$.failed.length,dn:d,referencesUpdated:$.updated};0!==$.failed.length?a(s,{...A,referencesFailed:$.failed}):s.json(A)}async entryExists(t){try{const e=await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}resolveDn(t){if(new RegExp(`^${c(this.mainAttribute)}=`,"i").test(t)){const e=m(t),i=e!==t;if(!i||e.toLowerCase()!==this.base.toLowerCase())throw new N(`DN must be a direct child of "${this.base}". Provided DN "${t}" has parent "${i?e:"<none>"}"`);return t}return`${this.mainAttribute}=${h(t)},${this.base}`}async addEntry(t,e={},i){let s;new RegExp(`^${this.mainAttribute}=`,"i").test(t)?(s=this.resolveDn(t),t=t.replace(new RegExp(`^${this.mainAttribute}=((?:\\\\.|[^,])+)(?:,.*)?$`,"i"),"$1")):(u(t,this.mainAttribute),s=this.resolveDn(t)),await this.validateNewEntry(s,{objectClass:this.objectClass,[this.mainAttribute]:t,...e});let r,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"),[s,a]=await b(this.registeredHooks[`${this.hookPrefix}add`],[s,a]),this.logger.debug("Adding LDAP entry:",{dn:s,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{r=await this.ldap.add(s,a,i)}catch(t){const e=t instanceof D&&t.statusCode<500;if(this.logger[e?"debug":"error"]("LDAP add failed:",{dn:s,entry:JSON.stringify(a,null,2),error:t}),t instanceof D)throw t;throw new Error(`Failed to add ${this.singularName} ${s}: ${t}`)}return p(this.registeredHooks[`${this.hookPrefix}adddone`],[s,a]),r}async modifyEntry(t,e,i){let s=this.resolveDn(t);const r=this.opNumber();if([s,e]=await b(this.registeredHooks[`${this.hookPrefix}modify`],[s,e,r]),e.add&&e.add[this.mainAttribute])throw new j(`${this.mainAttribute} attribute is unique, cannot add`);if(e.delete){if(e.delete instanceof Object&&e.delete[this.mainAttribute])throw new N(`Cannot delete ${this.mainAttribute} attribute`);if(Array.isArray(e.delete)&&e.delete.includes(this.mainAttribute))throw new N(`Cannot delete ${this.mainAttribute} attribute`)}if(e.replace&&e.replace[this.mainAttribute])throw new N(`Use dedicated API to change ${this.mainAttribute} attribute`);await this.validateChanges(s,e);const a=await this.ldap.modify(s,e,i);return p(this.registeredHooks[`${this.hookPrefix}modifydone`],[s,e,r]),a}async renameEntry(t,e,i){/,/.test(t)||u(t,this.mainAttribute),/,/.test(e)||u(e,this.mainAttribute);let s=this.resolveDn(t),r=this.resolveDn(e);[s,r]=await b(this.registeredHooks[`${this.hookPrefix}rename`],[s,r]);const a=await(i?this.ldap.forRequest(i).rename(s,r):this.ldap.rename(s,r));return p(this.registeredHooks[`${this.hookPrefix}renamedone`],[s,r]),a}async deleteEntry(t,e){let i=this.resolveDn(t);i=await b(this.registeredHooks[`${this.hookPrefix}delete`],i);const s=await this.ldap.delete(i,e);return p(this.registeredHooks[`${this.hookPrefix}deletedone`],i),s}async moveEntry(t,e,i){const s=this.resolveDn(t),r=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",a=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";if(!this.schema?.attributes[r]||!this.schema?.attributes[a])throw new N(`Schema for ${this.singularName} does not support move operation (missing ${r} or ${a})`);const n=await this.ldap.search({paged:!1,scope:"base",attributes:[r]},s,i);if(!n.searchEntries||0===n.searchEntries.length)throw new k(`Entry ${s} not found`);const o=await this.getDepartmentPath(e,i);[,e]=await b(this.registeredHooks[`${this.hookPrefix}move`],[s,e,i]);const h={replace:{[r]:e,[a]:o}};return await this.modifyEntry(t,h),{departmentPath:o,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 k(`Organization ${t} not found`);const r=s.searchEntries[0];if(r[i]){const t=r[i];return Array.isArray(t)?String(t[0]):String(t)}const a=r.ou||r.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){if(e instanceof k)throw e;if(32===e.code)throw new k(`Organization ${t} not found`);throw new Error(`Failed to fetch organization ${t}: ${e}`)}}async listEntries({filter:t,attributes:e,req:i}){const s={paged:!0,filter:t=t||"(objectClass=*)"};e&&e.length>0&&(s.attributes=e);const r=await this.ldap.search(s,this.base,i),a={};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),a[i]=t}});return a}async searchEntriesByName(t,e=!1,i=[this.mainAttribute]){const s=g(t),r=e?`(${this.mainAttribute}=*${s}*)`:`(${this.mainAttribute}=${s})`;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 N(`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 N(`Attribute "${t}" is fixed and cannot be modified. Expected: ${s}`)}if(!await this._validateOneChange(t,i))throw new N(this.invalidValueMessage(t));if(e.required&&!i&&!e.generated)throw new N(`Attribute "${t}" is required`)}const i=E(this.schema,e,this.server.loadedPlugins);if(i)throw new N(`Attribute "${i}" 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 N(`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 N(this.invalidValueMessage(t));if(e.replace)for(const[t,i]of Object.entries(e.replace))if(checkFixed(t),!await this._validateOneChange(t,i))throw new N(this.invalidValueMessage(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 N(`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 N(`Field ${t} must be a string (DN pointer)`);const s=e;if(i.branch&&i.branch.length>0){if(!i.branch.some(t=>w(s,t)))throw new N(`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 N(`Field ${t} points to non-existent DN: ${s}`)}catch(e){throw new N(`Field ${t} points to invalid or non-existent DN: ${s}`)}}"pointer"!==i.type&&await x(t,i,e,async t=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0);const s=i.test??i.items?.test;if(s){const t="string"==typeof s?o(s):s;return function(t){return(Array.isArray(t)?t:[t]).map(t=>String(t))}(e).every(e=>t.test(e))}return!0}}export{LdapFlat 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,isDummyMemberDn as h,escapeDnValue as c,validateDnValue as d,launchHooksChained as p,launchHooks as m,substringSearchFilter as l,escapeLdapFilter as u}from"../../lib/utils.js";import{NotFoundError as f,BadRequestError as g,HttpError as b}from"../../lib/errors.js";import{assertClientMaySet as w,modifiedAttributeNames as y,missingRequiredAttribute as $,checkDnValues as v,matchesPattern as _}from"../../config/schema.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async([e,t])=>{let r=e;return Array.isArray(r)||(r=[r]),this.logger.debug(`User deletion detected, removing from groups: ${r.join(", ")}`),await Promise.all(r.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)}),[e,t]}},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){if("string"!=typeof e.query.match)throw new g("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=l(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, -]+$/.test(e.query.match))throw new g("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=u(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups(i);return a(t,this.hideNeverReturn(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 g("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new g("member must be a string or string array");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 g("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 g("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new g("targetOrgDn must be a string");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 g("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new g("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn)}}))}isDummyMember(e){return h(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${c(i)},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)throw new f("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(r.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new f("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;w(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=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,h)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new g("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 g("cn is required");w(this.schema,y(r));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")):(d(e,this.cn),i=`${this.cn}=${c(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 p(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){if(e instanceof b)throw e;throw new Error(`Failed to add group ${i}: ${e}`)}return m(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;const i=this.opNumber();if([r,t]=await p(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 m(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){/,/.test(e)||d(e,this.cn),/,/.test(t)||d(t,this.cn);let r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${c(t)},${this.base}`;[r,i]=await p(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return m(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;t=await p(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return m(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await p(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{if(e instanceof b)throw e;throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;if([e,t]=await p(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new g("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{if(e instanceof b)throw 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}=${c(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 h;try{h=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===h.searchEntries.length)throw new Error(`Target organization ${t} not found`);const d=h.searchEntries[0][s];if(!d)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:d}},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)for(const t of e.searchEntries){const e=t[this.cn];e&&(i[e]=this.withoutDummyMember(t))}return i=await p(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}=${c(e)},${this.base}`)}async validateMembers(e,t){if([e,t]=await p(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 p(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new g(`Invalid value for field ${e}`);const r=$(this.schema,t,this.server.loadedPlugins);if(r)throw new g(`Missing required field ${r}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await p(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 g(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new g(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new g(`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&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new g(`Field ${e} must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await v(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!_(r,t))throw new g(`Field ${e} has invalid value ${String(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,isDummyMemberDn as h,escapeDnValue as c,validateDnValue as d,launchHooksChained as p,launchHooks as m,substringSearchFilter as l,escapeLdapFilter as u}from"../../lib/utils.js";import{NotFoundError as f,BadRequestError as g,HttpError as b}from"../../lib/errors.js";import{assertClientMaySet as w,modifiedAttributeNames as y,missingRequiredAttribute as $,checkDnValues as v,matchesPattern as _}from"../../config/schema.js";class LdapGroups extends t{constructor(t){if(super(t),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeleterequest:async([e,t])=>{let r=e;return Array.isArray(r)||(r=[r]),this.logger.debug(`User deletion detected, removing from groups: ${r.join(", ")}`),await Promise.all(r.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)}),[e,t]}},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){if("string"!=typeof e.query.match)throw new g("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=l(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, -]+$/.test(e.query.match))throw new g("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=u(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups(i);return a(t,this.hideNeverReturn(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 g("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new g("member must be a string or string array");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 g("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 g("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new g("targetOrgDn must be a string");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 g("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new g("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn)}}))}isDummyMember(e){return h(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const e=/,/.test(i)?i:`${this.cn}=${c(i)},${this.base}`,r=await this.ldap.search({paged:!1,scope:"base"},e);if(0===r.searchEntries.length)throw new f("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(r.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new f("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;w(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=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,h)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new g("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 g("cn is required");w(this.schema,y(r));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")):(d(e,this.cn),i=`${this.cn}=${c(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 p(this.registeredHooks.ldapgroupadd,[i,o]);try{a=await this.ldap.add(i,o)}catch(e){if(e instanceof b)throw e;throw new Error(`Failed to add group ${i}: ${e}`)}return m(this.registeredHooks.ldapgroupadddone,[i,o]),a}async modifyGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;const i=this.opNumber();if([r,t]=await p(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 m(this.registeredHooks.ldapgroupmodifydone,[r,t,i]),s}async renameGroup(e,t){/,/.test(e)||d(e,this.cn),/,/.test(t)||d(t,this.cn);let r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`,i=/,/.test(t)?t:`${this.cn}=${c(t)},${this.base}`;[r,i]=await p(this.registeredHooks.ldapgrouprename,[r,i]);const s=await this.ldap.rename(r,i);return m(this.registeredHooks.ldapgrouprenamedone,[r,i]),s}async deleteGroup(e){let t=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;t=await p(this.registeredHooks.ldapgroupdelete,t);const r=await this.ldap.delete(t);return m(this.registeredHooks.ldapgroupdeletedone,t),r}async addMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await p(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(r,t),await this.ldap.modify(r,{add:{member:t}}).catch(e=>{if(e instanceof b)throw e;throw new Error(`Failed to add member(s) to ${r}: ${e}`)})}async deleteMember(e,t){const r=/,/.test(e)?e:`${this.cn}=${c(e)},${this.base}`;if([e,t]=await p(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new g("Cannot delete dummy member from group");return await this.ldap.modify(r,{delete:{member:t}}).catch(e=>{if(e instanceof b)throw 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}=${c(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 h;try{h=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===h.searchEntries.length)throw new Error(`Target organization ${t} not found`);const d=h.searchEntries[0][s];if(!d)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:d}},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)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(i[e]=this.withoutDummyMember(t))}return i=await p(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}=${c(e)},${this.base}`)}async validateMembers(e,t){if([e,t]=await p(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 p(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new g(`Invalid value for field ${e}`);const r=$(this.schema,t,this.server.loadedPlugins);if(r)throw new g(`Missing required field ${r}`);return!0}async validateChanges(e,t){if(!this.schema)return!0;if([e,t]=await p(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 g(`Field ${e} is not allowed`);return!0}if(null==t){if(r.required)throw new g(`Field ${e} is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new g(`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&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new g(`Field ${e} must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await v(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!_(r,t))throw new g(`Field ${e} has invalid value ${String(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};
@@ -329,7 +329,7 @@ export default abstract class LdapFlat extends DmPlugin {
329
329
  */
330
330
  protected resolveDn(id: string): string;
331
331
  addEntry(id: string, additional?: AttributesList, req?: Request): Promise<boolean>;
332
- modifyEntry(id: string, changes: ModifyRequest): Promise<boolean>;
332
+ modifyEntry(id: string, changes: ModifyRequest, req?: Request): Promise<boolean>;
333
333
  /**
334
334
  * Change the RDN of an entry.
335
335
  *
@@ -346,7 +346,7 @@ export default abstract class LdapFlat extends DmPlugin {
346
346
  * @returns true when the directory accepted the rename
347
347
  */
348
348
  renameEntry(id: string, newId: string, req?: Request): Promise<boolean>;
349
- deleteEntry(id: string): Promise<boolean>;
349
+ deleteEntry(id: string, req?: Request): Promise<boolean>;
350
350
  /**
351
351
  * Move entry to a different organization
352
352
  * Updates department link and path attributes
@@ -363,9 +363,10 @@ export default abstract class LdapFlat extends DmPlugin {
363
363
  /**
364
364
  * List entries from LDAP
365
365
  */
366
- listEntries({ filter, attributes, }: {
366
+ listEntries({ filter, attributes, req, }: {
367
367
  filter?: string;
368
368
  attributes?: string[];
369
+ req?: Request;
369
370
  }): Promise<LdapList>;
370
371
  searchEntriesByName(name: string, partial?: boolean, attrs?: string[]): Promise<LdapList>;
371
372
  validateNewEntry(dn: string, entry: AttributesList): Promise<boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"