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.
- package/dist/abstract/ldapFlat.js +1 -1
- package/dist/bin/index.js +1 -1
- package/dist/config/args.js +1 -1
- package/dist/lib/authz/base.js +1 -1
- package/dist/lib/ldapActions.js +1 -1
- package/dist/lib/ldapSchema.js +1 -1
- package/dist/lib/storage/store.js +1 -1
- package/dist/lib/utils.js +1 -1
- package/dist/plugins/auth/authzScope.js +1 -1
- package/dist/plugins/ldap/groups.js +1 -1
- package/dist/plugins/ldap/organizations.js +1 -1
- package/dist/plugins/ldap/raw.js +1 -1
- package/dist/plugins/storage/ldap.js +1 -1
- package/dist/plugins/storage/postgres.js +1 -0
- package/dist/plugins/storage/valkey.js +1 -0
- package/dist/plugins/storage.js +1 -1
- package/dist/src/config/args.d.ts +14 -0
- package/dist/src/lib/authz/base.d.ts +25 -0
- package/dist/src/lib/ldapActions.d.ts +69 -0
- package/dist/src/lib/ldapSchema.d.ts +14 -0
- package/dist/src/lib/storage/store.d.ts +15 -0
- package/dist/src/lib/utils.d.ts +16 -0
- package/dist/src/plugins/ldap/raw.d.ts +3 -6
- package/dist/src/plugins/storage/ldap.d.ts +0 -3
- package/dist/src/plugins/storage/postgres.d.ts +54 -0
- package/dist/src/plugins/storage/valkey.d.ts +45 -0
- package/docs/usage/configuration.md +28 -22
- package/docs/usage/plugins/README.md +5 -5
- package/docs/usage/plugins/auth/authz-linid1.md +34 -1
- package/docs/usage/plugins/ldap/flat-generic.md +26 -0
- package/docs/usage/plugins/ldap/raw.md +1 -1
- package/docs/usage/plugins/utilities/storage.md +61 -10
- package/package.json +12 -1
|
@@ -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};
|
package/dist/plugins/ldap/raw.js
CHANGED
|
@@ -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
|
|
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}`}
|
|
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};
|
package/dist/plugins/storage.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import e from"../abstract/plugin.js";import r from"./storage/file.js";import
|
|
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
|
*
|
package/dist/src/lib/utils.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
182
|
-
*
|
|
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
|
|
177
|
-
|
|
|
178
|
-
| `--ldap-flat-schema`
|
|
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 `
|
|
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
|
|