ldap-rest 0.11.0 → 0.11.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 i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{transformSchemas as r,asyncHandler as o,validateDnValue as h,escapeDnValue as c,getRdn as d,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as f,escapeLdapFilter as u,launchHooksChained as w,isChildOf as m}from"../../lib/utils.js";import{BadRequestError as z,NotFoundError as y,ConflictError as b}from"../../lib/errors.js";import{extractLdapCode as _}from"../../lib/ldapCodes.js";import{assertClientMaySet as $,modifiedAttributeNames as v,missingRequiredAttribute as O,checkDnValues as C,matchesPattern as E}from"../../config/schema.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new z("An organization link cannot be deleted");if(e)throw new z("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(r(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new z('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;$(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const s=a.parentDn||this.config.ldap_top_organization,r=`ou=${c(a.ou)},${s}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new z("dn is required");$(this.schema,v(a)),await n(i,this.modifyOrganization.bind(this),s,a,t)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new z("dn is required");await n(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!s(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new z("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new z("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new y(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new z(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(d(i))&&l(i,s)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let c=e;if((this.isOu(t)||void 0!==r)&&h){const t=s?g(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?p(s):"";if(!i||!t||!l(i,s)||p(i)===t||p(f(i))===t)return;throw new z(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new z(`Organization path must end with its own name, preceded by separator "${n}"`);c=e.slice(0,e.length-n.length-h.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${u(c)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new z(`Invalid organization path ${e}: no organization holds "${c}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${u(t)})`})).searchEntries.length>0)throw new b(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new z("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new y("Top organization not found");const[,a]=await w(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new y(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=f(s);let o=`(${this.config.ldap_organization_link_attribute}=${u(t)})`;return n&&(o=`(&${o}(objectClass=${u(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=_(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,{dn:`more-organizations-${t}`,cn:["... more organizations than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:n.length.toString()}]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==_(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);return this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`),[...r.searchEntries.slice(0,a),{dn:`more-${e}`,cn:["... more elements than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:Math.min(r.searchEntries.length,a).toString()}]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,{dn:`more-${e}`,cn:[`... ${r-a} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:r.toString(),_displayedCount:a.toString()}]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));const a=O(this.schema,i,this.server.loadedPlugins);if(a)throw new z(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new z(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new z(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new z(`Field ${t} must be a single value`);return await C(t,a,i,t=>this.entryExists(t)),E(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new y(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new y(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new z(`Target ${i} is not an organizational unit`);const n=`${d(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new y(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new y(`Source organization ${t} not found`);if(n===t)throw new z("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||m(i,t))throw new z("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50,s=u(i);e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)(|(ou=*${s}*)(description=*${s}*)))`,n,a));const r=this.config.ldap_top_organization,o=f(r),h=u(t),c=`(&(${this.config.ldap_organization_link_attribute}=${h})(|(uid=*${s}*)(cn=*${s}*)(mail=*${s}*)(sn=*${s}*)(givenName=*${s}*)))`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${o}`),e.push(...await this.linkedEntities(o,c,n,t,a)),e}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
1
+ import t from"fs";import i from"../../abstract/plugin.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{transformSchemas as r,asyncHandler as o,validateDnValue as h,escapeDnValue as c,getRdn as d,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as f,escapeLdapFilter as u,launchHooksChained as w,isChildOf as m}from"../../lib/utils.js";import{BadRequestError as z,NotFoundError as _,ConflictError as b}from"../../lib/errors.js";import{extractLdapCode as y}from"../../lib/ldapCodes.js";import{assertClientMaySet as $,modifiedAttributeNames as v,missingRequiredAttribute as O,checkDnValues as C,matchesPattern as E}from"../../config/schema.js";class LdapOrganizations extends i{constructor(i){if(super(i),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new z("An organization link cannot be deleted");if(e)throw new z("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,i.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema&&t.readFile(this.config.organization_schema,(t,i)=>{if(t)this.logger.error(`Failed to load organization schema from ${this.config.organization_schema}: ${t}`);else try{this.schema=JSON.parse(r(i.toString(),this.config)),this.logger.debug("Organization schema loaded")}catch(t){this.logger.error(`Failed to parse organization schema: ${t}`)}})}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,o(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new z('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,o(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,o(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,o(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;$(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const s=a.parentDn||this.config.ldap_top_organization,r=`ou=${c(a.ou)},${s}`,o={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const a=e(t,i);if(!a)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new z("dn is required");$(this.schema,v(a)),await n(i,this.modifyOrganization.bind(this),s,a,t)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new z("dn is required");await n(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!s(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new z("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new z("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new _(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new z(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(d(i))&&l(i,s)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let c=e;if((this.isOu(t)||void 0!==r)&&h){const t=s?g(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?p(s):"";if(!i||!t||!l(i,s)||p(i)===t||p(f(i))===t)return;throw new z(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new z(`Organization path must end with its own name, preceded by separator "${n}"`);c=e.slice(0,e.length-n.length-h.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${u(c)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new z(`Invalid organization path ${e}: no organization holds "${c}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${u(t)})`})).searchEntries.length>0)throw new b(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new z("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new _("Top organization not found");const[,a]=await w(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}async getOrganisationByDn(t,i){const a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i);if(1!==a.searchEntries.length)throw new _(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=f(s);let o=`(${this.config.ldap_organization_link_attribute}=${u(t)})`;return n&&(o=`(&${o}(objectClass=${u(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=y(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(4!==a)throw i}const n=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${n.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...n,{dn:`more-organizations-${t}`,cn:["... more organizations than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:n.length.toString()}]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==y(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);return this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`),[...r.searchEntries.slice(0,a),{dn:`more-${e}`,cn:["... more elements than the directory will list"],objectClass:["moreIndicator"],_isMoreIndicator:"true",_displayedCount:Math.min(r.searchEntries.length,a).toString()}]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${s.length}`),r<=a?s:[...s,{dn:`more-${e}`,cn:[`... ${r-a} more elements`],objectClass:["moreIndicator"],_isMoreIndicator:"true",_totalCount:r.toString(),_displayedCount:a.toString()}]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));const a=O(this.schema,i,this.server.loadedPlugins);if(a)throw new z(`Missing required field ${a}`);return!0}invalidValueMessage(t){const i=this.schema?.attributes[t],a=i?.hint||i?.items?.hint;return a?`Invalid value for field ${t}: ${a}`:`Invalid value for field ${t}`}async validateChanges(t,i){if(!this.schema)return!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));return!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new z(`Field ${t} is not allowed`);return!0}if(null==i){if(a.required)throw new z(`Field ${t} is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new z(`Field ${t} must be a single value`);return await C(t,a,i,t=>this.entryExists(t)),E(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new _(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new _(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new z(`Target ${i} is not an organizational unit`);const n=`${d(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new _(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new _(`Source organization ${t} not found`);if(n===t)throw new z("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||m(i,t))throw new z("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){return await this.server.ldap.delete(t,i)}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50,s=u(i);e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)(|(ou=*${s}*)(description=*${s}*)))`,n,a));const r=this.config.ldap_top_organization,o=f(r),h=u(t),c=`(&(${this.config.ldap_organization_link_attribute}=${h})(|(uid=*${s}*)(cn=*${s}*)(mail=*${s}*)(sn=*${s}*)(givenName=*${s}*)))`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${o}`),e.push(...await this.linkedEntities(o,c,n,t,a)),e}getConfigApiData(){const t=this.config.api_prefix||"/api";let i;if(this.server.loadedPlugins.static&&this.config.organization_schema){const t=this.config.static_name||"static",a=this.config.organization_schema.indexOf("/schemas/");if(-1!==a){i=`/${t}${this.config.organization_schema.substring(a)}`}}return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
@@ -1 +1 @@
1
- import{FilterParser as t}from"ldapts";import e from"../../abstract/plugin.js";import{tryMethodData as s}from"../../lib/expressFormatedResponses.js";import{isDnInBranch as r,getRdn as a,getParentDn as i,asyncHandler as n}from"../../lib/utils.js";import{BadRequestError as o,ForbiddenError as h,NotFoundError as c}from"../../lib/errors.js";import{SchemaIndex as l,parseSchema as d}from"../../lib/ldapSchema.js";const u=["base","one","sub"],b=["userpassword","pwdhistory","sambantpassword","sambalmpassword","sambapasswordhistory","krbprincipalkey","krbextradata","krbpwdhistory","unicodepwd","dbcspwd","lmpwdhistory","ntpwdhistory","supplementalcredentials","userpkcs12","authpassword"];class LdapRaw extends e{constructor(t){super(t),this.name="ldapRaw",this.roles=["api"];const e=(this.config.ldap_raw_base||[]).filter(t=>t&&t.length>0);if(0===e.length){if(!this.config.ldap_base)throw new Error("Missing --ldap-base or --ldap-raw-base for plugin core/ldap/raw");e.push(this.config.ldap_base)}this.bases=e,this.showSecrets=!0===this.config.ldap_raw_show_secrets,this.hiddenAttributes=new Set([...this.showSecrets?[]:b,...(this.config.ldap_raw_hidden_attribute||[]).filter(t=>t&&t.length>0).map(t=>t.toLowerCase())]),this.maxResults=this.config.ldap_raw_max_results||200,this.schemaCacheTtl=1e3*(this.config.ldap_raw_schema_cache_ttl??3600),this.logger.info(`LDAP raw API enabled on ${this.bases.join(", ")} (read-only)`),this.showSecrets&&this.logger.warn("LDAP raw API: --ldap-raw-show-secrets is set, password hashes and other credential attributes are served over the API")}checkDn(t){if(!t||!t.trim())throw new o("DN is required");if(!this.bases.some(e=>r(t,e)||t.toLowerCase()===e.toLowerCase()))throw new h(`DN ${t} is outside the exposed bases`)}checkFilter(e){try{t.parseString(e)}catch(t){const s=t instanceof Error?t.message:String(t);throw new o(`Invalid LDAP filter ${JSON.stringify(e)}: ${s}. A filter must be parenthesised, e.g. (cn=foo) or (|(cn=*foo*)(ou=*foo*))`)}}formatEntry(t){const e={};for(const[s,r]of Object.entries(t)){if("dn"===s)continue;if("*"===s||"+"===s||"1.1"===s)continue;if(this.hiddenAttributes.has(s.split(";")[0].toLowerCase()))continue;const t=Array.isArray(r)?r:[r],a=t.some(t=>Buffer.isBuffer(t));e[s]={values:t.map(t=>Buffer.isBuffer(t)?t.toString("base64"):a?Buffer.from(String(t),"utf8").toString("base64"):String(t)),binary:a}}return{dn:t.dn,attributes:e}}async searchDirectory(t,e,s){try{return await this.server.ldap.search({paged:!1,...e},t,s)}catch(e){if(32===e?.code)throw new c(`Entry ${t||"(root DSE)"} not found`);throw e}}async getRootDse(){const t=(await this.searchDirectory("",{scope:"base",filter:"(objectClass=*)",attributes:["*","+"]})).searchEntries[0];if(!t)throw new c("Root DSE is not readable");return this.formatEntry({...t,dn:""})}async getSubschemaDn(){try{const t=await this.searchDirectory("",{scope:"base",filter:"(objectClass=*)",attributes:["subschemaSubentry"]}),e=t.searchEntries[0]?.subschemaSubentry,s=Array.isArray(e)?e[0]:e;if(s)return String(s)}catch(t){this.logger.warn(`Unable to read subschemaSubentry from root DSE: ${t}`)}return"cn=Subschema"}async getSchemaIndex(){if(this.schemaCache&&Date.now()-this.schemaCache.fetchedAt<this.schemaCacheTtl)return this.schemaCache.index;if(this.schemaPromise)return this.schemaPromise;this.schemaPromise=(async()=>{const t=await this.getSubschemaDn(),e=(await this.searchDirectory(t,{scope:"base",filter:"(objectClass=*)",attributes:["objectClasses","attributeTypes","ldapSyntaxes","matchingRules"]})).searchEntries[0];if(!e)throw new c(`Subschema entry ${t} not found`);const list=t=>{if(void 0===t)return[];return(Array.isArray(t)?t:[t]).map(t=>Buffer.isBuffer(t)?t.toString("utf8"):t)},s=new l(d({objectClasses:list(e.objectClasses),attributeTypes:list(e.attributeTypes),ldapSyntaxes:list(e.ldapSyntaxes),matchingRules:list(e.matchingRules)}));return this.schemaCache={index:s,fetchedAt:Date.now()},this.logger.info(`LDAP schema loaded from ${t}: ${s.schema.objectClasses.length} object classes, ${s.schema.attributeTypes.length} attribute types`),s})();try{return await this.schemaPromise}finally{this.schemaPromise=void 0}}async getSchema(){return(await this.getSchemaIndex()).schema}async getEntry(t,e){this.checkDn(t);const read=async s=>this.searchDirectory(t,{scope:"base",filter:"(objectClass=*)",attributes:s},e);let s;try{s=await read(["*","+"])}catch(e){if(e instanceof Error&&("statusCode"in e||/\[authz-forbidden\]/.test(e.message)))throw e;this.logger.debug(`Reading ${t} with operational attributes failed (${e}), retrying without`),s=await read(["*"])}const r=s.searchEntries[0];if(!r)throw new c(`Entry ${t} not found`);return this.formatEntry(r)}async getChildren(t,e,s){this.checkDn(t);const r=await this.searchDirectory(t,{scope:"one",filter:"(objectClass=*)",attributes:["objectClass"],sizeLimit:this.maxResults+1},s),i=r.searchEntries.length>this.maxResults,n=[];for(const t of r.searchEntries.slice(0,this.maxResults)){const e=t.objectClass;n.push({dn:t.dn,rdn:a(t.dn),objectClass:Array.isArray(e)?e.map(String):e?[String(e)]:[],hasChildren:!1})}return n.sort((t,e)=>t.rdn.localeCompare(e.rdn)),e&&await Promise.all(n.map(async t=>{t.hasChildren=await this.hasChildren(t.dn,s)})),i&&this.logger.info(`Children of ${t} truncated to ${this.maxResults} entries`),{children:n,truncated:i}}async hasChildren(t,e){try{return(await this.searchDirectory(t,{scope:"one",filter:"(objectClass=*)",attributes:["1.1"],sizeLimit:1},e)).searchEntries.length>0}catch{return!1}}async search(t,e){this.checkDn(t.base),this.checkFilter(t.filter);const s=Math.min(t.limit||this.maxResults,this.maxResults),r=await this.searchDirectory(t.base,{scope:t.scope,filter:t.filter,attributes:t.attributes?.length?t.attributes:["*"],sizeLimit:s+1},e),a=r.searchEntries.length>s;return{entries:r.searchEntries.slice(0,s).map(t=>this.formatEntry(t)),truncated:a}}parentOf(t){if(this.bases.some(e=>t.toLowerCase()===e.toLowerCase()))return null;const e=i(t);return e&&e!==t?e:null}api(t){const e=`${this.config.api_prefix}/v1/ldap/raw`;t.get(`${e}/bases`,n(async(t,e)=>{await s(e,()=>({bases:this.bases}))})),t.get(`${e}/rootdse`,n(async(t,e)=>{await s(e,this.getRootDse.bind(this))})),t.get(`${e}/schema`,n(async(t,e)=>{await s(e,this.getSchema.bind(this))})),t.get(`${e}/search`,n(async(t,e)=>{const r=t.query.scope||"sub";if(!u.includes(r))throw new o(`Invalid scope "${r}", expected one of ${u.join(", ")}`);const a="string"==typeof t.query.attributes?t.query.attributes.split(",").map(t=>t.trim()).filter(Boolean):void 0,i="string"==typeof t.query.limit?parseInt(t.query.limit,10):void 0;if(void 0!==i&&(isNaN(i)||i<1))throw new o("limit must be a positive integer");await s(e,this.search.bind(this),{base:t.query.base||this.bases[0],scope:r,filter:t.query.filter||"(objectClass=*)",attributes:a,limit:i},t)})),t.get(`${e}/children/:dn`,n(async(t,e)=>{const r=decodeURIComponent(t.params.dn),a=void 0!==t.query.children&&"false"!==t.query.children;await s(e,this.getChildren.bind(this),r,a,t)})),t.get(`${e}/entry/:dn`,n(async(t,e)=>{const r=decodeURIComponent(t.params.dn);await s(e,this.getEntry.bind(this),r,t)}))}getConfigApiData(){return{bases:this.bases,readOnly:!0,maxResults:this.maxResults,hiddenAttributes:[...this.hiddenAttributes],showSecrets:this.showSecrets}}}export{LdapRaw as default};
1
+ import{FilterParser as t}from"ldapts";import e from"../../abstract/plugin.js";import{tryMethodData as s}from"../../lib/expressFormatedResponses.js";import{isDnInBranch as r,getRdn as i,getParentDn as a,asyncHandler as n}from"../../lib/utils.js";import{BadRequestError as o,ForbiddenError as h,NotFoundError as c}from"../../lib/errors.js";const d=["base","one","sub"],l=["userpassword","pwdhistory","sambantpassword","sambalmpassword","sambapasswordhistory","krbprincipalkey","krbextradata","krbpwdhistory","unicodepwd","dbcspwd","lmpwdhistory","ntpwdhistory","supplementalcredentials","userpkcs12","authpassword"];class LdapRaw extends e{constructor(t){super(t),this.name="ldapRaw",this.roles=["api"];const e=(this.config.ldap_raw_base||[]).filter(t=>t&&t.length>0);if(0===e.length){if(!this.config.ldap_base)throw new Error("Missing --ldap-base or --ldap-raw-base for plugin core/ldap/raw");e.push(this.config.ldap_base)}this.bases=e,this.showSecrets=!0===this.config.ldap_raw_show_secrets,this.hiddenAttributes=new Set([...this.showSecrets?[]:l,...(this.config.ldap_raw_hidden_attribute||[]).filter(t=>t&&t.length>0).map(t=>t.toLowerCase())]),this.maxResults=this.config.ldap_raw_max_results||200,this.logger.info(`LDAP raw API enabled on ${this.bases.join(", ")} (read-only)`),this.showSecrets&&this.logger.warn("LDAP raw API: --ldap-raw-show-secrets is set, password hashes and other credential attributes are served over the API")}checkDn(t){if(!t||!t.trim())throw new o("DN is required");if(!this.bases.some(e=>r(t,e)||t.toLowerCase()===e.toLowerCase()))throw new h(`DN ${t} is outside the exposed bases`)}checkFilter(e){try{t.parseString(e)}catch(t){const s=t instanceof Error?t.message:String(t);throw new o(`Invalid LDAP filter ${JSON.stringify(e)}: ${s}. A filter must be parenthesised, e.g. (cn=foo) or (|(cn=*foo*)(ou=*foo*))`)}}formatEntry(t){const e={};for(const[s,r]of Object.entries(t)){if("dn"===s)continue;if("*"===s||"+"===s||"1.1"===s)continue;if(this.hiddenAttributes.has(s.split(";")[0].toLowerCase()))continue;const t=Array.isArray(r)?r:[r],i=t.some(t=>Buffer.isBuffer(t));e[s]={values:t.map(t=>Buffer.isBuffer(t)?t.toString("base64"):i?Buffer.from(String(t),"utf8").toString("base64"):String(t)),binary:i}}return{dn:t.dn,attributes:e}}async searchDirectory(t,e,s){try{return await this.server.ldap.search({paged:!1,...e},t,s)}catch(e){if(32===e?.code)throw new c(`Entry ${t||"(root DSE)"} not found`);throw e}}async getRootDse(){const t=(await this.searchDirectory("",{scope:"base",filter:"(objectClass=*)",attributes:["*","+"]})).searchEntries[0];if(!t)throw new c("Root DSE is not readable");return this.formatEntry({...t,dn:""})}async getSubschemaDn(){return this.server.ldap.subschemaDn()}async getSchemaIndex(){return this.server.ldap.schemaIndex()}async getSchema(){return(await this.getSchemaIndex()).schema}async getEntry(t,e){this.checkDn(t);const read=async s=>this.searchDirectory(t,{scope:"base",filter:"(objectClass=*)",attributes:s},e);let s;try{s=await read(["*","+"])}catch(e){if(e instanceof Error&&("statusCode"in e||/\[authz-forbidden\]/.test(e.message)))throw e;this.logger.debug(`Reading ${t} with operational attributes failed (${e}), retrying without`),s=await read(["*"])}const r=s.searchEntries[0];if(!r)throw new c(`Entry ${t} not found`);return this.formatEntry(r)}async getChildren(t,e,s){this.checkDn(t);const r=await this.searchDirectory(t,{scope:"one",filter:"(objectClass=*)",attributes:["objectClass"],sizeLimit:this.maxResults+1},s),a=r.searchEntries.length>this.maxResults,n=[];for(const t of r.searchEntries.slice(0,this.maxResults)){const e=t.objectClass;n.push({dn:t.dn,rdn:i(t.dn),objectClass:Array.isArray(e)?e.map(String):e?[String(e)]:[],hasChildren:!1})}return n.sort((t,e)=>t.rdn.localeCompare(e.rdn)),e&&await Promise.all(n.map(async t=>{t.hasChildren=await this.hasChildren(t.dn,s)})),a&&this.logger.info(`Children of ${t} truncated to ${this.maxResults} entries`),{children:n,truncated:a}}async hasChildren(t,e){try{return(await this.searchDirectory(t,{scope:"one",filter:"(objectClass=*)",attributes:["1.1"],sizeLimit:1},e)).searchEntries.length>0}catch{return!1}}async search(t,e){this.checkDn(t.base),this.checkFilter(t.filter);const s=Math.min(t.limit||this.maxResults,this.maxResults),r=await this.searchDirectory(t.base,{scope:t.scope,filter:t.filter,attributes:t.attributes?.length?t.attributes:["*"],sizeLimit:s+1},e),i=r.searchEntries.length>s;return{entries:r.searchEntries.slice(0,s).map(t=>this.formatEntry(t)),truncated:i}}parentOf(t){if(this.bases.some(e=>t.toLowerCase()===e.toLowerCase()))return null;const e=a(t);return e&&e!==t?e:null}api(t){const e=`${this.config.api_prefix}/v1/ldap/raw`;t.get(`${e}/bases`,n(async(t,e)=>{await s(e,()=>({bases:this.bases}))})),t.get(`${e}/rootdse`,n(async(t,e)=>{await s(e,this.getRootDse.bind(this))})),t.get(`${e}/schema`,n(async(t,e)=>{await s(e,this.getSchema.bind(this))})),t.get(`${e}/search`,n(async(t,e)=>{const r=t.query.scope||"sub";if(!d.includes(r))throw new o(`Invalid scope "${r}", expected one of ${d.join(", ")}`);const i="string"==typeof t.query.attributes?t.query.attributes.split(",").map(t=>t.trim()).filter(Boolean):void 0,a="string"==typeof t.query.limit?parseInt(t.query.limit,10):void 0;if(void 0!==a&&(isNaN(a)||a<1))throw new o("limit must be a positive integer");await s(e,this.search.bind(this),{base:t.query.base||this.bases[0],scope:r,filter:t.query.filter||"(objectClass=*)",attributes:i,limit:a},t)})),t.get(`${e}/children/:dn`,n(async(t,e)=>{const r=decodeURIComponent(t.params.dn),i=void 0!==t.query.children&&"false"!==t.query.children;await s(e,this.getChildren.bind(this),r,i,t)})),t.get(`${e}/entry/:dn`,n(async(t,e)=>{const r=decodeURIComponent(t.params.dn);await s(e,this.getEntry.bind(this),r,t)}))}getConfigApiData(){return{bases:this.bases,readOnly:!0,maxResults:this.maxResults,hiddenAttributes:[...this.hiddenAttributes],showSecrets:this.showSecrets}}}export{LdapRaw as default};
@@ -1 +1 @@
1
- import{createHash as e}from"node:crypto";import t from"../../lib/storage/store.js";const errorText=e=>e instanceof Error?e.message:JSON.stringify(e);class LdapStore extends t{constructor(e,t,s){super(e.logger),this.name="storage/ldap",this.server=e,this.base=t,this.objectClass=s}dn(t){return`cn=${e("sha256").update(t).digest("hex")},${this.base}`}static encode(e,t){return`${t.deadline} ${LdapStore.readable(e)}\n${t.value}`}static decode(e){const t=String(e),s=t.indexOf("\n"),r=s<0?t:t.slice(0,s),a=parseInt(r.split(" ")[0],10);return Number.isFinite(a)?{deadline:a,value:s<0?"":t.slice(s+1)}:null}async load(e){try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:["description"]},this.dn(e)),s=t.searchEntries?.[0];return s?LdapStore.decode(s.description):null}catch(e){return 32!==e?.code&&this.logger.warn(`${this.name}: cannot read a record, the answer is a guess: ${errorText(e)}`),null}}async save(e,t){const s=this.dn(e),r=LdapStore.encode(e,t);try{await this.server.ldap.add(s,{objectClass:["top",this.objectClass],cn:s.slice(3,s.indexOf(",")),description:r})}catch(e){if(68!==e?.code)throw this.logger.warn(`${this.name}: cannot write a record: ${errorText(e)}`),e;await this.server.ldap.modify(s,{replace:{description:r}})}}async drop(e){await this.server.ldap.delete(this.dn(e)).catch(e=>{32!==e?.code&&this.logger.warn(`${this.name}: cannot drop a record, it keeps counting: ${errorText(e)}`)})}async sweep(){const e=Date.now();let t=0;const s=await this.server.ldap.search({paged:!1,scope:"one",filter:`(objectClass=${this.objectClass})`,attributes:["description"]},this.base);for(const r of s.searchEntries||[]){const s=LdapStore.decode(r.description);null!==s&&s.deadline>e||(await this.server.ldap.delete(String(r.dn)).catch(()=>{}),t++)}return t}}export{LdapStore as default};
1
+ import{createHash as e}from"node:crypto";import t from"../../lib/storage/store.js";const errorText=e=>e instanceof Error?e.message:JSON.stringify(e);class LdapStore extends t{constructor(e,t,s){super(e.logger),this.name="storage/ldap",this.server=e,this.base=t,this.objectClass=s}dn(t){return`cn=${e("sha256").update(t).digest("hex")},${this.base}`}async load(e){try{const t=await this.server.ldap.search({paged:!1,scope:"base",attributes:["description"]},this.dn(e)),s=t.searchEntries?.[0];return s?LdapStore.decodeRecord(s.description):null}catch(e){return 32!==e?.code&&this.logger.warn(`${this.name}: cannot read a record, the answer is a guess: ${errorText(e)}`),null}}async save(e,t){const s=this.dn(e),r=LdapStore.encodeRecord(e,t);try{await this.server.ldap.add(s,{objectClass:["top",this.objectClass],cn:s.slice(3,s.indexOf(",")),description:r})}catch(e){if(68!==e?.code)throw this.logger.warn(`${this.name}: cannot write a record: ${errorText(e)}`),e;await this.server.ldap.modify(s,{replace:{description:r}})}}async drop(e){await this.server.ldap.delete(this.dn(e)).catch(e=>{32!==e?.code&&this.logger.warn(`${this.name}: cannot drop a record, it keeps counting: ${errorText(e)}`)})}async sweep(){const e=Date.now();let t=0;const s=await this.server.ldap.search({paged:!1,scope:"one",filter:`(objectClass=${this.objectClass})`,attributes:["description"]},this.base);for(const r of s.searchEntries||[]){const s=LdapStore.decodeRecord(r.description);null!==s&&s.deadline>e||(await this.server.ldap.delete(String(r.dn)).catch(()=>{}),t++)}return t}}export{LdapStore as default};
@@ -0,0 +1 @@
1
+ import t from"../../lib/storage/store.js";const errorText=t=>t instanceof Error?t.message:JSON.stringify(t),quote=t=>`"${t.replace(/"/g,'""')}"`;class PostgresStore extends t{constructor(t,e,a){super(t),this.name="storage/postgres",this.url=e,this.table=a,this.quoted=a.split(".").map(quote).join(".")}async driver(){return import("pg")}async open(){await this.pool()}async close(){await super.close();const t=this.connection;this.connection=void 0,t&&await((await t.catch(()=>{}))?.end())}pool(){return this.connection??(this.connection=this.connect().catch(t=>{throw this.connection=void 0,t})),this.connection}async connect(){let t;try{t=await this.driver()}catch(t){throw new Error(`${this.name}: requires the optional dependency "pg", which is not installed. Install it to keep the records in PostgreSQL, or choose another --storage-backend. (${errorText(t)})`)}const e=new t.Pool({connectionString:this.url,connectionTimeoutMillis:1e4,allowExitOnIdle:!0});e.on("error",t=>this.logger.warn(`${this.name}: connection lost: ${errorText(t)}`));try{await this.ensureTable(e)}catch(t){throw await e.end().catch(()=>{}),new Error(`${this.name}: cannot use table ${this.table}: ${errorText(t)}`)}return e}async ensureTable(t){const e=await t.query("SELECT to_regclass($1) AS t",[this.quoted]);if(e.rows[0]?.t)return;const a=this.quoted,n=quote(`${this.table.split(".").pop()}_expires_at`);await t.query(`CREATE TABLE IF NOT EXISTS ${a} (\n namespace text NOT NULL,\n key text NOT NULL,\n value text NOT NULL,\n expires_at timestamptz NOT NULL,\n PRIMARY KEY (namespace, key)\n )`),await t.query(`CREATE INDEX IF NOT EXISTS ${n} ON ${a} (expires_at)`),this.logger.info(`${this.name}: created table ${this.table}`)}static parts(t){const e=t.indexOf("\0"),a=t.slice(e+1);return a.includes("\0")?null:[t.slice(0,e),a]}async load(t){const e=PostgresStore.parts(t);if(!e)return null;try{const t=await this.pool(),a=(await t.query(`SELECT value, expires_at FROM ${this.quoted}\n WHERE namespace = $1 AND key = $2`,e)).rows[0];return a?{value:a.value,deadline:a.expires_at.getTime()}:null}catch(t){return this.logger.warn(`${this.name}: cannot read a record, the answer is a guess: ${errorText(t)}`),null}}async save(t,e){const a=PostgresStore.parts(t);if(!a)throw new Error(`${this.name}: cannot keep a key holding a NUL`);try{const t=await this.pool();await t.query(`INSERT INTO ${this.quoted}\n (namespace, key, value, expires_at) VALUES ($1, $2, $3, $4)\n ON CONFLICT (namespace, key) DO UPDATE\n SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`,[...a,e.value,new Date(e.deadline)])}catch(t){throw this.logger.warn(`${this.name}: cannot write a record: ${errorText(t)}`),t}}async drop(t){const e=PostgresStore.parts(t);if(e)try{const t=await this.pool();await t.query(`DELETE FROM ${this.quoted}\n WHERE namespace = $1 AND key = $2`,e)}catch(t){this.logger.warn(`${this.name}: cannot drop a record, it keeps counting: ${errorText(t)}`)}}async sweep(){const t=await this.pool();return(await t.query(`DELETE FROM ${this.quoted} WHERE expires_at <= $1`,[new Date])).rowCount??0}}export{PostgresStore as default};
@@ -0,0 +1 @@
1
+ import t from"../../lib/storage/store.js";const errorText=t=>t instanceof Error?t.message:JSON.stringify(t);class ValkeyStore extends t{constructor(t,e,n){super(t),this.name="storage/valkey",this.down=!1,this.url=e,this.prefix=n}async driver(){return import("iovalkey")}async open(){await this.client()}async close(){await super.close();const t=this.connection;this.connection=void 0;const e=await(t?.catch(()=>{}));e&&await e.quit().catch(()=>e.disconnect())}client(){return this.connection??(this.connection=this.connect().catch(t=>{throw this.connection=void 0,t})),this.connection}async connect(){let t;try{t=await this.driver()}catch(t){throw new Error(`${this.name}: requires the optional dependency "iovalkey", which is not installed. Install it to keep the records in Valkey, or choose another --storage-backend. (${errorText(t)})`)}const e=new t.Valkey(this.url,{lazyConnect:!0,enableOfflineQueue:!1,maxRetriesPerRequest:1,commandTimeout:5e3});let n,o=!1;e.on("error",t=>{n=t,o&&!this.down&&(this.down=!0,this.logger.warn(`${this.name}: connection lost: ${errorText(t)}`))}),e.on("ready",()=>{this.down&&this.logger.info(`${this.name}: connection back`),this.down=!1});try{await e.connect(),o=!0}catch(t){throw e.disconnect(),new Error(`${this.name}: cannot connect: ${errorText(n??t)}`)}return e}key(t){return`${this.prefix}${t}`}async load(t){try{const e=await(await this.client()).get(this.key(t));return null===e?null:ValkeyStore.decodeRecord(e)}catch(t){return this.logger.warn(`${this.name}: cannot read a record, the answer is a guess: ${errorText(t)}`),null}}async save(t,e){const n=Math.ceil(e.deadline-Date.now());try{const o=await this.client();if(n<=0)return void await o.del(this.key(t));await o.set(this.key(t),ValkeyStore.encodeRecord(t,e),"PX",n)}catch(t){throw this.logger.warn(`${this.name}: cannot write a record: ${errorText(t)}`),t}}async drop(t){try{await(await this.client()).del(this.key(t))}catch(t){this.logger.warn(`${this.name}: cannot drop a record, it keeps counting: ${errorText(t)}`)}}async sweep(){return 0}}export{ValkeyStore as default};
@@ -1 +1 @@
1
- import e from"../abstract/plugin.js";import r from"./storage/file.js";import t from"./storage/ldap.js";let s;class Storage extends e{constructor(e){if(super(e),this.name="storage",s)throw new Error(`storage: an instance is already loaded, keeping its records in ${s.store.name}. A second one would put the same record in two places and leave registration order to decide which answers. Load one, and let its consumers share it.`);const o=e.config.storage_backend||"";switch(o){case"ldap":{const r=e.config.storage_ldap_base;if(!r)throw new Error("storage: the ldap backend needs --storage-ldap-base, the branch it writes its records to. It holds records and nothing else, so it belongs outside the branches the directory serves.");this.store=new t(e,r,e.config.storage_ldap_object_class||"applicationProcess");break}case"file":{const t=e.config.storage_file_directory;if(!t)throw new Error("storage: the file backend needs --storage-file-directory, the directory it writes its records to. It writes one file per record, so nothing else should be writing there.");this.store=new r(e.logger,t,e.config.storage_sweep_interval||600);break}default:throw new Error(o?`storage: unknown backend "${o}". Known: ldap, file.`:"storage: --storage-backend says which sub-plugin keeps the records. Known: ldap, file.")}s=this,this.logger.info(`storage: records kept by ${this.store.name}`),this.store.startSweeping(e.config.storage_sweep_interval||0)}static release(e){s===e&&(s=void 0)}}export{Storage as default};
1
+ import e from"../abstract/plugin.js";import r from"./storage/file.js";import s from"./storage/ldap.js";import t from"./storage/postgres.js";import o from"./storage/valkey.js";let a;const i="ldap, file, postgres, valkey";class Storage extends e{constructor(e){if(super(e),this.name="storage",a)throw new Error(`storage: an instance is already loaded, keeping its records in ${a.store.name}. A second one would put the same record in two places and leave registration order to decide which answers. Load one, and let its consumers share it.`);const n=e.config.storage_backend||"";switch(n){case"ldap":{const r=e.config.storage_ldap_base;if(!r)throw new Error("storage: the ldap backend needs --storage-ldap-base, the branch it writes its records to. It holds records and nothing else, so it belongs outside the branches the directory serves.");this.store=new s(e,r,e.config.storage_ldap_object_class||"applicationProcess");break}case"file":{const s=e.config.storage_file_directory;if(!s)throw new Error("storage: the file backend needs --storage-file-directory, the directory it writes its records to. It writes one file per record, so nothing else should be writing there.");this.store=new r(e.logger,s,e.config.storage_sweep_interval||600);break}case"postgres":{const r=e.config.storage_postgres_url;if(!r)throw new Error("storage: the postgres backend needs --storage-postgres-url, the database it writes its records to.");this.store=new t(e.logger,r,e.config.storage_postgres_table||"ldap_rest_storage");break}case"valkey":{const r=e.config.storage_valkey_url;if(!r)throw new Error("storage: the valkey backend needs --storage-valkey-url, the server it writes its records to.");this.store=new o(e.logger,r,e.config.storage_valkey_prefix??"");break}default:throw new Error(n?`storage: unknown backend "${n}". Known: ${i}.`:`storage: --storage-backend says which sub-plugin keeps the records. Known: ${i}.`)}a=this,this.logger.info(`storage: records kept by ${this.store.name}`),this.store.open().catch(e=>this.logger.error(e instanceof Error?e.message:String(e))),this.store.startSweeping(e.config.storage_sweep_interval||0)}static release(e){a===e&&(a=void 0)}}export{Storage as default};
@@ -60,6 +60,10 @@ export interface Config {
60
60
  storage_ldap_base?: string;
61
61
  storage_ldap_object_class?: string;
62
62
  storage_file_directory?: string;
63
+ storage_postgres_url?: string;
64
+ storage_postgres_table?: string;
65
+ storage_valkey_url?: string;
66
+ storage_valkey_prefix?: string;
63
67
  bcl_retention?: number;
64
68
  ldap_top_organization?: string;
65
69
  ldap_organization_class?: string[];
@@ -69,6 +73,8 @@ export interface Config {
69
73
  ldap_organization_max_subnodes?: number;
70
74
  organization_schema?: string;
71
75
  ldap_flat_schema?: string[];
76
+ /** Add the auxiliary classes a modify needs, see ldapActions.classRepair */
77
+ ldap_flat_auto_repair?: boolean;
72
78
  enterprise_domain_name_attribute?: string;
73
79
  enterprise_domain_link_attribute?: string;
74
80
  ldap_raw_base?: string[];
@@ -111,6 +117,14 @@ export interface Config {
111
117
  * nothing there.
112
118
  */
113
119
  authz_filter_attached_entries?: boolean;
120
+ /**
121
+ * The organization an administrator hands entries over through, when
122
+ * entries are judged by their attachment: anyone administering a branch
123
+ * sees what is attached there and may claim it, and puts an entry there
124
+ * with write on the organization it leaves. An entry attached to no
125
+ * organization is in transit too, with or without this option.
126
+ */
127
+ authz_transit_branch?: string;
114
128
  authz_unresolved_user?: string;
115
129
  authz_identity?: string;
116
130
  /**
@@ -73,6 +73,28 @@ export default abstract class AuthzBase extends DmPlugin {
73
73
  * off — a relaxation in the default configuration.
74
74
  */
75
75
  protected effectiveBranch(dn: string): Promise<string>;
76
+ /**
77
+ * The organization entries are handed over through between
78
+ * administrators: `--authz-transit-branch`. It only means something when
79
+ * entries are judged by their attachment, and is `undefined` otherwise.
80
+ */
81
+ transitBranch(): string | undefined;
82
+ /**
83
+ * Whether an organization link leaves its entry in transit: attached to
84
+ * the transit branch, or to nothing at all. An account created by another
85
+ * tool than this one often carries no link, and every administrator can
86
+ * see it; in transit, one of them can also claim it.
87
+ *
88
+ * @param link the link the entry carries, `undefined` for none
89
+ * @param key the transit branch as {@link transitKey} gives it
90
+ */
91
+ protected inTransit(link: string | undefined, key?: string | undefined): boolean;
92
+ /**
93
+ * The transit branch in the form DNs are compared in. A listing asks once
94
+ * and hands it to {@link inTransit} for every entry, rather than parsing
95
+ * the same constant DN a few thousand times.
96
+ */
97
+ private transitKey;
76
98
  /**
77
99
  * The DN of the entry an identity names, or null when none does.
78
100
  *
@@ -216,6 +238,9 @@ export default abstract class AuthzBase extends DmPlugin {
216
238
  * not see never reaches another. An entry with no organization link —
217
239
  * an organization, a group, a nomenclature value — is left alone: those
218
240
  * are the reference data every administrator reads.
241
+ *
242
+ * So is an entry attached to the transit branch: it is waiting for an
243
+ * administrator to claim it.
219
244
  */
220
245
  ldapsearchfilter: ([result, req, opts]: [SearchResult, DmRequest?, SearchOptions?]) => Promise<[SearchResult, DmRequest?, SearchOptions?]>;
221
246
  getOrganisationTop: ([req, defaultTop]: [DmRequest | undefined, AttributesList | null]) => Promise<[DmRequest | undefined, AttributesList | null]>;
@@ -4,6 +4,7 @@ import type winston from 'winston';
4
4
  import pLimit from 'p-limit';
5
5
  import { type Config } from '../config/args';
6
6
  import { type DM } from '../bin';
7
+ import { SchemaIndex } from './ldapSchema';
7
8
  export type AttributeValue = Buffer | Buffer[] | string[] | string;
8
9
  export type AttributesList = Record<string, AttributeValue>;
9
10
  export type LdapList = Record<string, AttributesList>;
@@ -69,6 +70,14 @@ declare class ldapActions {
69
70
  private waitingResolvers;
70
71
  private availableConnections;
71
72
  private isCleaningUp;
73
+ /** See {@link declareObjectClasses}. */
74
+ private declaredClasses;
75
+ private schemaCache?;
76
+ private schemaLoading?;
77
+ /** Whether the last schema read failed, so a failure is logged once. */
78
+ private schemaUnavailable;
79
+ /** Class problems already reported, per entry. See {@link warnOnce}. */
80
+ private classWarnings;
72
81
  constructor(server: DM);
73
82
  /**
74
83
  * Create a new LDAP connection with failover support
@@ -142,6 +151,66 @@ declare class ldapActions {
142
151
  * hooks always see it. See {@link RequestBoundLdap}.
143
152
  */
144
153
  forRequest(req: Request): RequestBoundLdap;
154
+ /**
155
+ * Declare the object classes an entity gives the entries of its branch.
156
+ *
157
+ * An entry created by another tool may lack some of them, and a class it
158
+ * lacks can forbid an attribute written here: the organization link of an
159
+ * account made as a bare `inetOrgPerson` is refused by the directory. A
160
+ * modify writing such an attribute adds the auxiliary class that allows
161
+ * it, see {@link classRepair}.
162
+ *
163
+ * @param base branch holding the entity's entries
164
+ * @param classes object classes the entity's schema declares; a schema
165
+ * file may give a single one as a string
166
+ * @param includeBase whether the base is itself one of the entries, as the
167
+ * top organization is
168
+ */
169
+ declareObjectClasses(base: string, classes: string[] | string, includeBase?: boolean): void;
170
+ /**
171
+ * The classes declared for the branch holding a DN. Several entities may
172
+ * share a branch — the shipped `users`, `posixAccounts` and
173
+ * `sshPublicKeys` schemas all live in `ou=users` — so every declaration at
174
+ * the deepest matching base counts.
175
+ */
176
+ private declaredFor;
177
+ /**
178
+ * Log a class problem of an entry once, not on every write to it: nothing
179
+ * an administrator can do stops a sync job from touching it again.
180
+ */
181
+ private warnOnce;
182
+ /**
183
+ * The auxiliary classes a modify needs added: for each attribute it writes
184
+ * that the entry's classes do not allow, a class declared for its branch
185
+ * that allows it, provided the entry holds or receives that class's
186
+ * mandatory attributes once the change is applied.
187
+ *
188
+ * Only what the write needs is added. Entities sharing a branch declare
189
+ * classes that are not all meant for every entry: `shadowAccount` is not
190
+ * something every account should be given because it sits in `ou=users`.
191
+ * And a write that the directory would have accepted is never changed.
192
+ *
193
+ * Nothing is added when the change names `objectClass` itself, and a
194
+ * schema or entry that cannot be read skips the repair.
195
+ *
196
+ * @param dn entry being modified
197
+ * @param changes the modification, after the hooks
198
+ * @returns names of the classes to add
199
+ */
200
+ private classRepair;
201
+ /**
202
+ * DN of the subschema entry the root DSE advertises, or the conventional
203
+ * `cn=Subschema` when it advertises none.
204
+ */
205
+ subschemaDn(): Promise<string>;
206
+ /**
207
+ * The directory schema, kept for `--ldap-raw-schema-cache-ttl`: it is
208
+ * large and changes rarely. The raw browser and the object class repair
209
+ * share it, and concurrent callers share a single read.
210
+ *
211
+ * @returns indexed schema
212
+ */
213
+ schemaIndex(): Promise<SchemaIndex>;
145
214
  /**
146
215
  * Hand a search result to `ldapsearchfilter`, which may drop entries the
147
216
  * caller is not allowed to see.
@@ -156,4 +156,18 @@ export declare class SchemaIndex {
156
156
  must: string[];
157
157
  may: string[];
158
158
  };
159
+ /**
160
+ * The classes a schema declares for an entry and the entry lacks, split by
161
+ * what can be done about them: an auxiliary class can be added to an
162
+ * existing entry, a structural one cannot. Classes the directory does not
163
+ * know, and abstract ones, are left out.
164
+ *
165
+ * @param declared classes the entity's schema gives its entries
166
+ * @param present classes the entry carries
167
+ * @returns the missing auxiliary definitions and structural names
168
+ */
169
+ missingClasses(declared: string[], present: string[]): {
170
+ auxiliary: ObjectClassDefinition[];
171
+ structural: string[];
172
+ };
159
173
  }
@@ -59,6 +59,21 @@ export default abstract class Store {
59
59
  protected static scoped(namespace: string, key: string): string;
60
60
  /** The same pair, for a human reading a record. Never used to look one up. */
61
61
  protected static readable(scoped: string): string;
62
+ /**
63
+ * `<deadline> <key>\n<value>`, for a backend that keeps a record as one
64
+ * string: the deadline first, so a short read still has it, and the key
65
+ * beside it so an operator can tell what a record is about.
66
+ */
67
+ protected static encodeRecord(key: string, record: StoredRecord): string;
68
+ protected static decodeRecord(value: unknown): StoredRecord | null;
69
+ /**
70
+ * Reach whatever the backend keeps its records in. Every operation opens
71
+ * on its own; calling this early only moves a failure to startup, where an
72
+ * operator reads it, instead of the first request that needs the store.
73
+ */
74
+ open(): Promise<void>;
75
+ /** Stop sweeping and let go of any connection. */
76
+ close(): Promise<void>;
62
77
  /**
63
78
  * The value kept under this key, or null.
64
79
  *
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { Request, Response, NextFunction, RequestHandler } from 'express';
6
6
  import type { Config } from '../bin';
7
+ import type { AttributeValue } from './ldapActions';
7
8
  /**
8
9
  * Get a compiled RegExp from cache, or compile and cache it
9
10
  * This avoids recompiling the same regex patterns repeatedly
@@ -268,6 +269,21 @@ export declare function normalizeDn(dn: string): string;
268
269
  * ```
269
270
  */
270
271
  export declare function isDummyMemberDn(member: unknown, dummy: string | undefined): boolean;
272
+ /**
273
+ * The organization an entry is attached to, read off its link attribute
274
+ * (`--ldap-organization-link-attribute`), or `undefined` when it is attached
275
+ * to none.
276
+ *
277
+ * "None" arrives in three forms: the attribute missing, an empty array —
278
+ * how ldapts answers an attribute that was asked for and is absent — and an
279
+ * empty value. The empty array read as the branch "undefined" in one copy of
280
+ * this logic, and nobody could write such an entry. Every reader of the link
281
+ * asks here, so the copies cannot drift apart again.
282
+ *
283
+ * @param value - The link attribute as a search returned it
284
+ * @returns its first value, or `undefined` when there is none
285
+ */
286
+ export declare function organizationLink(value: AttributeValue | undefined): string | undefined;
271
287
  /**
272
288
  * Check whether a DN is a branch base itself or sits anywhere below it.
273
289
  *
@@ -18,7 +18,7 @@
18
18
  import type { Express, Request } from 'express';
19
19
  import DmPlugin, { type Role } from '../../abstract/plugin';
20
20
  import type { DM } from '../../bin';
21
- import { SchemaIndex, type LdapSchema } from '../../lib/ldapSchema';
21
+ import type { LdapSchema, SchemaIndex } from '../../lib/ldapSchema';
22
22
  /**
23
23
  * Shared OpenAPI schemas surfaced by this plugin. Picked up by
24
24
  * scripts/generate-openapi.ts and merged into `components.schemas`.
@@ -114,9 +114,6 @@ export default class LdapRaw extends DmPlugin {
114
114
  showSecrets: boolean;
115
115
  /** Maximum entries returned by a search or a children listing */
116
116
  maxResults: number;
117
- private schemaCacheTtl;
118
- private schemaCache?;
119
- private schemaPromise?;
120
117
  constructor(server: DM);
121
118
  /**
122
119
  * Reject any DN outside the configured bases. Authorization plugins narrow
@@ -178,8 +175,8 @@ export default class LdapRaw extends DmPlugin {
178
175
  */
179
176
  getSubschemaDn(): Promise<string>;
180
177
  /**
181
- * Fetch and parse the directory schema, with a cache: it is large and
182
- * changes rarely. Concurrent callers share a single fetch.
178
+ * The parsed directory schema, cached for `--ldap-raw-schema-cache-ttl`
179
+ * and shared with the object class repair.
183
180
  *
184
181
  * @returns indexed schema
185
182
  */
@@ -7,9 +7,6 @@ export default class LdapStore extends Store {
7
7
  private objectClass;
8
8
  constructor(server: DM, base: string, objectClass: string);
9
9
  private dn;
10
- /** `<deadline> <key>\n<value>`, the deadline first as in the file store. */
11
- private static encode;
12
- private static decode;
13
10
  protected load(key: string): Promise<StoredRecord | null>;
14
11
  protected save(key: string, record: StoredRecord): Promise<void>;
15
12
  protected drop(key: string): Promise<void>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @module plugins/storage/postgres
3
+ *
4
+ * A keyed store kept in one PostgreSQL table.
5
+ *
6
+ * `pg` is an optional dependency, loaded when this backend is chosen: a
7
+ * deployment that keeps its records elsewhere never needs it installed.
8
+ *
9
+ * The key is kept verbatim, split into the consumer's namespace and its own
10
+ * key: a `text` column has no reason to hash it the way a DN or a file name
11
+ * must, so a `SELECT` says what a record is about.
12
+ *
13
+ * @group Plugins
14
+ */
15
+ import type winston from 'winston';
16
+ import Store, { type StoredRecord } from '../../lib/storage/store';
17
+ type Pg = typeof import('pg');
18
+ export default class PostgresStore extends Store {
19
+ name: string;
20
+ private url;
21
+ private table;
22
+ /** `schema.table` or `table`, each part quoted. */
23
+ private quoted;
24
+ private connection?;
25
+ constructor(logger: winston.Logger, url: string, table: string);
26
+ /** Split out so a test can stand in for a missing package. */
27
+ protected driver(): Promise<Pg>;
28
+ open(): Promise<void>;
29
+ close(): Promise<void>;
30
+ /**
31
+ * The pool, once the table is known to be there. A failure is not kept:
32
+ * the next operation tries again, so a database down at startup does not
33
+ * leave the store unusable once it is back.
34
+ */
35
+ private pool;
36
+ private connect;
37
+ /**
38
+ * Create the table when it is not there.
39
+ *
40
+ * Looked up first: `CREATE … IF NOT EXISTS` still needs the right to create
41
+ * in the schema, which a role granted only this table's rows does not have.
42
+ */
43
+ private ensureTable;
44
+ /**
45
+ * The namespace and the consumer's own key. PostgreSQL refuses NUL in
46
+ * `text`, so a key holding one cannot be kept: `null` says so.
47
+ */
48
+ private static parts;
49
+ protected load(key: string): Promise<StoredRecord | null>;
50
+ protected save(key: string, record: StoredRecord): Promise<void>;
51
+ protected drop(key: string): Promise<void>;
52
+ sweep(): Promise<number>;
53
+ }
54
+ export {};
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @module plugins/storage/valkey
3
+ *
4
+ * A keyed store kept in Valkey, or Redis.
5
+ *
6
+ * `iovalkey` is an optional dependency, loaded when this backend is chosen: a
7
+ * deployment that keeps its records elsewhere never needs it installed.
8
+ *
9
+ * The only backend that expires its own records, so it has nothing to sweep.
10
+ * The value still carries its deadline, in the shape the LDAP store uses:
11
+ * reads judge expiry by it, as with every other backend, and the TTL only
12
+ * reclaims the memory.
13
+ *
14
+ * @group Plugins
15
+ */
16
+ import type winston from 'winston';
17
+ import Store, { type StoredRecord } from '../../lib/storage/store';
18
+ type IoValkey = typeof import('iovalkey');
19
+ export default class ValkeyStore extends Store {
20
+ name: string;
21
+ private url;
22
+ private prefix;
23
+ private connection?;
24
+ /** Set once a lost connection is reported, so a reconnect loop says it once. */
25
+ private down;
26
+ constructor(logger: winston.Logger, url: string, prefix: string);
27
+ /** Split out so a test can stand in for a missing package. */
28
+ protected driver(): Promise<IoValkey>;
29
+ open(): Promise<void>;
30
+ close(): Promise<void>;
31
+ /**
32
+ * The client, once connected. A failed first connection is not kept: the
33
+ * next operation tries again. Past that one, the client reconnects on its
34
+ * own.
35
+ */
36
+ private client;
37
+ private connect;
38
+ private key;
39
+ protected load(key: string): Promise<StoredRecord | null>;
40
+ protected save(key: string, record: StoredRecord): Promise<void>;
41
+ protected drop(key: string): Promise<void>;
42
+ /** The TTL has already reclaimed what expired. */
43
+ sweep(): Promise<number>;
44
+ }
45
+ export {};
@@ -173,9 +173,10 @@ at `0` otherwise.
173
173
 
174
174
  #### `core/ldap/flatGeneric`
175
175
 
176
- | CLI | Plural | Env | Default | Description |
177
- | -------------------- | --------------------- | --------------------- | ------- | --------------------- |
178
- | `--ldap-flat-schema` | `--ldap-flat-schemas` | `DM_LDAP_FLAT_SCHEMA` | `[]` | Entity schema path(s) |
176
+ | CLI | Plural | Env | Default | Description |
177
+ | ------------------------- | --------------------- | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
178
+ | `--ldap-flat-schema` | `--ldap-flat-schemas` | `DM_LDAP_FLAT_SCHEMA` | `[]` | Entity schema path(s) |
179
+ | `--ldap-flat-auto-repair` | | `DM_LDAP_FLAT_AUTO_REPAIR` | `true` | Add the auxiliary class a write needs to an entry lacking it — [details](plugins/ldap/flat-generic.md#entries-created-by-other-tools) |
179
180
 
180
181
  #### `core/ldap/bulkImport`
181
182
 
@@ -215,13 +216,13 @@ Maintains consistency of department links when organizations are renamed/moved:
215
216
 
216
217
  Read-only low-level browsing (root DSE, schema, entries). See [raw plugin](plugins/ldap/raw.md).
217
218
 
218
- | CLI | Plural | Env | Default | Description |
219
- | ----------------------------- | ------------------------------ | ------------------------------- | ------------- | ------------------------------- |
220
- | `--ldap-raw-base` | `--ldap-raw-bases` | `DM_LDAP_RAW_BASE` | `--ldap-base` | Subtrees exposed by the API |
221
- | `--ldap-raw-hidden-attribute` | `--ldap-raw-hidden-attributes` | `DM_LDAP_RAW_HIDDEN_ATTRIBUTES` | `[]` | Extra attributes never returned |
222
- | `--ldap-raw-show-secrets` | | `DM_LDAP_RAW_SHOW_SECRETS` | `false` | Serve credential attributes |
223
- | `--ldap-raw-max-results` | | `DM_LDAP_RAW_MAX_RESULTS` | `200` | Max entries per search/listing |
224
- | `--ldap-raw-schema-cache-ttl` | | `DM_LDAP_RAW_SCHEMA_CACHE_TTL` | `3600` | Schema cache lifetime (seconds) |
219
+ | CLI | Plural | Env | Default | Description |
220
+ | ----------------------------- | ------------------------------ | ------------------------------- | ------------- | -------------------------------------------------------------------- |
221
+ | `--ldap-raw-base` | `--ldap-raw-bases` | `DM_LDAP_RAW_BASE` | `--ldap-base` | Subtrees exposed by the API |
222
+ | `--ldap-raw-hidden-attribute` | `--ldap-raw-hidden-attributes` | `DM_LDAP_RAW_HIDDEN_ATTRIBUTES` | `[]` | Extra attributes never returned |
223
+ | `--ldap-raw-show-secrets` | | `DM_LDAP_RAW_SHOW_SECRETS` | `false` | Serve credential attributes |
224
+ | `--ldap-raw-max-results` | | `DM_LDAP_RAW_MAX_RESULTS` | `200` | Max entries per search/listing |
225
+ | `--ldap-raw-schema-cache-ttl` | | `DM_LDAP_RAW_SCHEMA_CACHE_TTL` | `3600` | Schema cache lifetime (seconds), shared with the object class repair |
225
226
 
226
227
  ### Authentication Plugins
227
228
 
@@ -333,11 +334,12 @@ whichever of the two names it selects. A value other than `req.user` or
333
334
 
334
335
  #### `core/auth/authzPerBranch`
335
336
 
336
- | CLI | Env | Default | Description |
337
- | --------------------------------- | ---------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
338
- | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
339
- | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
340
- | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch`; always on under `authzLinid1`; does nothing under `authzDynamic`. |
337
+ | CLI | Env | Default | Description |
338
+ | --------------------------------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
339
+ | `--authz-per-branch-config` | `DM_AUTHZ_PER_BRANCH_CONFIG` | `{default:{read:true,write:false,delete:false}}` | Authorization config (JSON) |
340
+ | `--authz-per-branch-cache-ttl` | `DM_AUTHZ_PER_BRANCH_CACHE_TTL` | `60` | Cache TTL (seconds) |
341
+ | `--authz-filter-attached-entries` | `DM_AUTHZ_FILTER_ATTACHED_ENTRIES` | `false` | Judge an attached entry by the organization it hangs off, and filter listings accordingly. Honoured by `authzPerBranch`; always on under `authzLinid1`; does nothing under `authzDynamic`. |
342
+ | `--authz-transit-branch` | `DM_AUTHZ_TRANSIT_BRANCH` | | Organization entries are handed over through when they are judged by attachment: every administrator sees and may claim what is there, and puts an entry there with write on the organization it leaves. An entry attached to no organization is in transit too. See [authzLinid1](plugins/auth/authz-linid1.md#transit-handing-an-entry-over). |
341
343
 
342
344
  #### `core/auth/authzScope`
343
345
 
@@ -461,13 +463,17 @@ Web access logging plugin. No configuration options - just add the plugin to ena
461
463
  Keyed storage with a deadline, for the plugins that need to keep something —
462
464
  see [Storage](plugins/utilities/storage.md).
463
465
 
464
- | CLI | Env | Default | Description |
465
- | ----------------------------- | ------------------------------ | -------------------- | -------------------------------------- |
466
- | `--storage-backend` | `DM_STORAGE_BACKEND` | | `ldap` or `file`; required |
467
- | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two expiry passes |
468
- | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | | Branch the `ldap` backend writes to |
469
- | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes |
470
- | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | | Directory the `file` backend writes to |
466
+ | CLI | Env | Default | Description |
467
+ | ----------------------------- | ------------------------------ | -------------------- | ------------------------------------------------ |
468
+ | `--storage-backend` | `DM_STORAGE_BACKEND` | | `ldap`, `file`, `postgres` or `valkey`; required |
469
+ | `--storage-sweep-interval` | `DM_STORAGE_SWEEP_INTERVAL` | `600` | Seconds between two expiry passes |
470
+ | `--storage-ldap-base` | `DM_STORAGE_LDAP_BASE` | | Branch the `ldap` backend writes to |
471
+ | `--storage-ldap-object-class` | `DM_STORAGE_LDAP_OBJECT_CLASS` | `applicationProcess` | Object class of the entries it writes |
472
+ | `--storage-file-directory` | `DM_STORAGE_FILE_DIRECTORY` | | Directory the `file` backend writes to |
473
+ | `--storage-postgres-url` | `DM_STORAGE_POSTGRES_URL` | | Connection string of the `postgres` backend |
474
+ | `--storage-postgres-table` | `DM_STORAGE_POSTGRES_TABLE` | `ldap_rest_storage` | Table it writes to |
475
+ | `--storage-valkey-url` | `DM_STORAGE_VALKEY_URL` | | URL of the `valkey` backend |
476
+ | `--storage-valkey-prefix` | `DM_STORAGE_VALKEY_PREFIX` | `ldap-rest:` | Prefix of the keys it writes |
471
477
 
472
478
  #### `core/configApi`
473
479