ldap-rest 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/plugin.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/options.js +1 -1
  5. package/dist/config/schema.js +1 -1
  6. package/dist/lib/authz/base.js +1 -1
  7. package/dist/lib/errors.js +1 -1
  8. package/dist/lib/ldap/callerView.js +1 -0
  9. package/dist/lib/ldap/pool.js +1 -0
  10. package/dist/lib/ldap/schema.js +1 -0
  11. package/dist/lib/ldap/searchCache.js +1 -0
  12. package/dist/lib/ldapActions.js +1 -1
  13. package/dist/lib/parseConfig.js +1 -1
  14. package/dist/lib/services/index.js +1 -0
  15. package/dist/lib/utils.js +1 -1
  16. package/dist/plugins/ldap/accountLifecycle.js +1 -1
  17. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  18. package/dist/plugins/ldap/groups.js +1 -1
  19. package/dist/plugins/ldap/organizations.js +1 -1
  20. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  21. package/dist/plugins/ldap/raw.js +1 -1
  22. package/dist/plugins/ldap/trash.js +1 -1
  23. package/dist/plugins/scim/mapping.js +1 -1
  24. package/dist/plugins/scim/patch.js +1 -1
  25. package/dist/plugins/storage/file.js +1 -1
  26. package/dist/plugins/twake/instances.js +1 -1
  27. package/dist/plugins/twake/lifecycleAttributes.js +1 -1
  28. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  29. package/dist/plugins/twake/tombstone.js +1 -1
  30. package/dist/src/abstract/ldapFlat.d.ts +13 -0
  31. package/dist/src/abstract/plugin.d.ts +21 -0
  32. package/dist/src/bin/index.d.ts +18 -0
  33. package/dist/src/config/options.d.ts +16 -1
  34. package/dist/src/lib/errors.d.ts +2 -1
  35. package/dist/src/lib/ldap/callerView.d.ts +45 -0
  36. package/dist/src/lib/ldap/pool.d.ts +108 -0
  37. package/dist/src/lib/ldap/schema.d.ts +157 -0
  38. package/dist/src/lib/ldap/searchCache.d.ts +112 -0
  39. package/dist/src/lib/ldapActions.d.ts +25 -262
  40. package/dist/src/lib/parseConfig.d.ts +26 -2
  41. package/dist/src/lib/services/index.d.ts +56 -0
  42. package/dist/src/lib/utils.d.ts +72 -3
  43. package/dist/src/plugins/ldap/enterpriseRules.d.ts +0 -14
  44. package/dist/src/plugins/ldap/passwordPolicy.d.ts +3 -2
  45. package/dist/src/plugins/scim/mapping.d.ts +16 -3
  46. package/dist/src/plugins/scim/types.d.ts +2 -0
  47. package/dist/src/plugins/twake/instances.d.ts +31 -0
  48. package/dist/src/plugins/twake/lifecycleAttributes.d.ts +13 -2
  49. package/dist/src/plugins/twake/tombstone.d.ts +9 -2
  50. package/docs/README.md +16 -2
  51. package/docs/client-development/api/rest-api.md +4 -0
  52. package/docs/development/contracts.md +109 -0
  53. package/docs/plugin-development/README.md +8 -1
  54. package/docs/plugin-development/dependencies.md +13 -2
  55. package/docs/plugin-development/hooks.md +2 -1
  56. package/docs/plugin-development/options.md +40 -13
  57. package/docs/plugin-development/services.md +95 -0
  58. package/docs/usage/operations.md +9 -0
  59. package/docs/usage/plugins/integrations/instances.md +11 -3
  60. package/docs/usage/plugins/integrations/lifecycle-events.md +4 -1
  61. package/docs/usage/plugins/integrations/scim.md +20 -18
  62. package/docs/usage/plugins/integrations/tombstone.md +10 -6
  63. package/docs/usage/plugins/ldap/flat-generic.md +8 -1
  64. package/docs/usage/plugins/ldap/groups.md +4 -1
  65. package/docs/usage/plugins/ldap/password-policy.md +7 -0
  66. package/docs/usage/upgrading.md +59 -0
  67. package/package.json +17 -12
  68. package/static/browser/ldap-tree-viewer/LdapTreeViewer.js.map +1 -1
  69. package/static/browser/ldap-tree-viewer.esm.js.map +1 -1
  70. package/static/browser/ldap-tree-viewer.js.map +1 -1
  71. package/static/browser/ldap-tree-viewer.min.js.map +1 -1
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as n,invalidValueMessage as r}from"../../config/schema.js";import{normalizeDn as s,isDnInBranch as i,getParentDn as a,movedToTrash as o,rdnValue as c,escapeLdapFilter as l,isDummyMemberDn as u}from"../../lib/utils.js";import{BadRequestError as f,ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";import{enterpriseRulesOptions as m,organizationOptionsOf as p,groupsOptionsOf as g}from"./ldap.options.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){for(const[n,r]of Object.entries(e))if(spells(n,t))return valueList(r);return[]}function spells(e,t){return e.split(";")[0].toLowerCase()===t.toLowerCase()}function allValues(e,t){return Object.entries(e||{}).filter(([e])=>spells(e,t)).flatMap(([,e])=>valueList(e))}function respell(e,t){if(!t)return;const n=Object.keys(e.attributes);for(const e of Object.keys(t)){if(e.includes(";"))continue;const r=n.find(t=>t!==e&&spells(e,t));r&&!(r in t)&&(t[r]=t[e],delete t[e])}}function asDn(e){try{return s(e)}catch{return e}}function uniquenessBases(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),n=[];for(const r of[e,...t||[]]){const e=r.toLowerCase();if(!n.some(t=>contains(t.toLowerCase(),e))){for(let t=n.length-1;t>=0;t--)contains(e,n[t].toLowerCase())&&n.splice(t,1);n.push(r)}}return n}const b={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return assertSafeSize(Math.round(e));const t=String(e).trim(),n=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!n)throw new f(`"${t}" is not a valid size`);const r=(n[2]||"B").toUpperCase();return assertSafeSize(Math.round(parseFloat(n[1])*b[r]))}function assertSafeSize(e){if(!Number.isSafeInteger(e))throw new f("the size is too large to be written");return e}function fillsGeneratedAttribute(e,n,r){return!(void 0===n.default||!n.generated&&!n.normalize)||t(r,"organizationPath")===e}function parseDirectoryDate(e){const t=String(e).trim(),n=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})?(\d{2})?(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$/.exec(t);if(!n){const e=new Date(t);return isNaN(e.getTime())?null:e}const[,r,s,i,a,o,c,l,u]=n,[f,d,h,m,p,g]=[r,s,i,a,o,c].map(e=>void 0===e?0:Number(e));if(d<1||d>12||m>23||p>59||g>60)return null;const b=new Date(0);if(b.setUTCFullYear(f,d-1,h),b.getUTCMonth()!==d-1||b.getUTCDate()!==h)return null;b.setUTCHours(m,p,g);let y=b.getTime();if(void 0!==l){const e=void 0!==c?1e3:void 0!==o?6e4:36e5;y+=Math.round(Number(`0.${l}`)*e)}if(u&&"Z"!==u){const e=u.slice(1).replace(":",""),t=Number(e.slice(0,2)),n=e.length>2?Number(e.slice(2,4)):0;if(t>23||n>59)return null;y-=("-"===u[0]?-1:1)*(60*t+n)*6e4}return new Date(y)}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,n=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const r=e.lastIndexOf("@");if(r<0)return!1;const s=e.slice(r+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return s===t||n&&s.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{static optionDefinitions=m;name="ldapEnterpriseRules";roles=["consistency"];dummyMember;topOrganization;enterpriseSettings;orgSettings;constructor(e){super(e),this.enterpriseSettings=this.options(m),this.orgSettings=p(e.config),this.dummyMember=g(e.config).group_dummy_user,this.topOrganization=this.orgSettings.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,n){return fillsGeneratedAttribute(e,t,n)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const n of t?.instances||[])n.schema&&e.push({base:n.base,subtree:!1,schema:n.schema,label:n.singularName});const n=this.server.loadedPlugins.ldapGroups;n?.base&&n.schema&&e.push({base:n.base,subtree:!0,schema:n.schema,label:"group"});const r=this.server.loadedPlugins.ldapOrganizations;return r?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:r.schema,label:"organization"}),e}resolveEntity(e){const t=s(e);let n,r=-1;for(const o of this.bindings()){const c=s(o.base);if(t===c)continue;if(!i(e,o.base))continue;if(!o.subtree&&s(a(e))!==c)continue;const l=c.split(",").length;l>r&&(n=o,r=l)}return n}hooks={ldapaddrequest:async([e,t,n])=>{const r=this.resolveEntity(e);return r&&(respell(r.schema,t),await this.runChecks(r,e,t,null),await this.runTransformations(r,e,t,!0)),void 0!==n?[e,t,n]:[e,t]},ldapmodifyrequest:async([e,n,r,s])=>{const i=this.resolveEntity(e);if(!i)return[e,n,r,s];respell(i.schema,n.add),respell(i.schema,n.replace);const a={...n.add||{},...n.replace||{}};if(0===Object.keys(a).length)return[e,n,r,s];const o=await this.readEntry(e),c=t(i.schema,"organizationLink"),l={...a},sent=e=>Object.keys(l).some(t=>spells(t,e));if(c&&sent(c)&&o)for(const[e,t]of Object.entries(i.schema.attributes)){if(!t.mailDomainScope)continue;if(sent(e))continue;const n=allValues(o,e);n.length&&(l[e]=n)}await this.runChecks(i,e,l,o),await this.runTransformations(i,e,a,!1);for(const[e,t]of Object.entries(a))n.replace&&e in n.replace?n.replace[e]=t:n.add&&e in n.add?n.add[e]=t:(n.replace=n.replace||{},n.replace[e]=t);return[e,n,r,s]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]},ldapdeletedone:async(e,t)=>{for(const n of Array.isArray(e)?e:[e])await this.removeReferences(n,t)},ldaprenamedone:async(e,t,n)=>{o(this.config.trash_base,e,t)&&await this.removeReferences(e,n)}};async runChecks(e,t,r,s){const i=Object.keys(e.schema.attributes);for(const[a,o]of Object.entries(r)){const c=i.find(e=>spells(a,e));if(!c)continue;const l=e.schema.attributes[c],u=valueList(o);0!==u.length&&(l.unique&&await this.checkUnique(e,c,l,u,t,s),l.mailDomainScope&&await this.checkMailDomains(e,c,l,u,r,s),n(l,"accountExpiry")&&this.checkNotInThePast(c,l,u))}}async runTransformations(e,n,s,i){for(const[t,n]of Object.entries(e.schema.attributes)){if(i&&void 0===s[t]&&void 0!==n.default&&(n.generated||n.normalize)&&(s[t]=Array.isArray(n.default)?n.default.map(e=>String(e)):String(n.default),"pointer"===n.type||"pointer"===n.items?.type))for(const e of valueList(s[t]))if(!await this.readEntry(e))throw new f(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===n.normalize&&void 0!==s[t]){const e=valueList(s[t]);try{s[t]=Array.isArray(s[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new f(r(t,n))}}}const o=t(e.schema,"organizationPath");if(!o)return;if(!e.schema.attributes[o]?.generated&&void 0!==s[o])return;const l=t(e.schema,"organizationLink");if(l){const e=valueList(s[l])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new f(`Organization ${e} has no ${o}, so "${o}" cannot be computed`);return void(s[o]=t)}if(!i)return;const u=valueList(s[t(e.schema,"identifier")||""])[0]||c(n);if(!u)return;const d=a(n),h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!h||d.toLowerCase().endsWith(`,${h}`)?await this.organizationPath(d):void 0,p=this.orgSettings.ldap_organization_path_separator;s[o]=m?`${m}${p}${u}`:u}async checkUnique(e,t,n,r,s,a){const o="object"==typeof n.unique?n.unique:{},c=[t,...o.attributes||[]],u=a?s:null,f=uniquenessBases(e.base,o.branches),h=new Set(c.filter(n=>{if(n===t)return!0;const r=e.schema.attributes[n]?.unique;if(!r)return!1;const s="object"==typeof r?r:{},a=[n,...s.attributes||[]],l=uniquenessBases(e.base,s.branches);return(!s.filter||s.filter===o.filter)&&s.sentinel===o.sentinel&&c.every(e=>a.includes(e))&&f.every(e=>l.some(t=>i(e,t)))}).flatMap(e=>allValues(a,e)));for(const n of r){if(void 0!==o.sentinel&&n===o.sentinel)continue;if(h.has(n))continue;const r=c.map(e=>`(${e}=${l(n)})`).join("");let s=c.length>1?`(|${r})`:r;o.filter&&(s=`(&${o.filter}${s})`);for(const r of f){const i=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:s,attributes:["dn"]},r);for(const r of i.searchEntries||[]){const s=String(r.dn);if(!u||s.toLowerCase()!==u.toLowerCase())throw this.logger.warn(`${t}="${n}" refused for ${u||"a new entry"}: already held by ${s}`),new d(`${e.label}: "${n}" is already used (${t} must be unique)`,{code:"unique",attribute:t,value:n})}}}}async checkMailDomains(e,t,n,r,s,i){const a="directory"===n.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,s,i);if(0!==a.length)for(const e of r)if(!mailInDomains(e,a,n.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${a.join(", ")})`,{code:"mailDomain",attribute:t,value:e,domains:a})}async organizationDomains(e,n,r){const s=t(e.schema,"organizationLink");if(!s)return[];const i=allValues(n,s)[0]||allValues(r,s)[0]||void 0;if(!i)return[];const o=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(o,"domainLink")||this.enterpriseSettings.enterprise_domain_link_attribute;if(!c)return[];const l=[],u=new Set;let f=i;const d=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),h=this.domainNameAttribute();for(;f&&!u.has(f.toLowerCase());){u.add(f.toLowerCase());const e=await this.readEntry(f);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),n=valueList(e?.[h]);0===n.length&&this.logger.warn(`Organization ${f} links to domain ${t}, which has no "${h}": no mail domain restriction comes from it`),l.push(...n)}if(f.toLowerCase()===d)break;const t=a(f);f=t===f?void 0:t}return l.filter((e,t)=>l.findIndex(t=>t.toLowerCase()===e.toLowerCase())===t)}domainNameAttribute(){for(const e of this.bindings()){const n=t(e.schema,"domainName");if(n)return n}return this.enterpriseSettings.enterprise_domain_name_attribute}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),n=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),r=[];for(const e of n.searchEntries||[])r.push(...valueList(e[t]));return r}checkNotInThePast(e,t,n){const s=startOfUtcDay();for(const i of n){const n=parseDirectoryDate(i);if(!n)throw new f(r(e,t));if(n.getTime()<s)throw new f(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const n=this.bindings().find(e=>"organization"===e.label)?.schema,r=t(n,"organizationPath")||this.orgSettings.ldap_organization_path_attribute;if(!r)return;const s=await this.readEntry(e);return s?valueList(s[r])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[n,r]of Object.entries(t.schema.attributes)){if("nonEmpty"!==r.deleteGuard)continue;const s=await this.readEntry(e),i=valueList(s?.[n]).filter(e=>!u(e,this.dummyMember));if(i.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${i.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const n=(await this.searchReferrers(t,e))[0];if(n)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(n.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}async removeReferences(e,t){const n=asDn(e),r=new Set;for(const s of this.referringAttributes(e)){if("remove"!==s.attr.referentialIntegrity)continue;let i;try{i=await this.searchReferrers(s,e)}catch(t){this.logger.error(`Delete of ${e}: could not look for ${s.name} references: ${String(t)}`);continue}for(const a of i){const i=String(a.dn),o=`${asDn(i)} ${s.name.toLowerCase()}`;if(r.has(o))continue;r.add(o);const c=entryValues(a,s.name).filter(e=>asDn(e)===n);if(0!==c.length)try{await this.server.ldap.system.modify(i,{delete:{[s.name]:c}},void 0,{context:t})}catch(t){if(16===h(t))continue;this.logger.error(`Delete of ${e}: could not remove it from ${s.name} of ${i}: ${String(t)}`)}}}}referringAttributes(e){const t=a(e),r=[];for(const e of this.bindings())for(const[s,a]of Object.entries(e.schema.attributes)){const o="pointer"===a.type?a:a.items,c=n(a,"members")||n(a,"owners");if(!o&&!c)continue;const l=o?.branch||[];l.length>0&&!l.some(e=>i(t,e))||r.push({binding:e,name:s,attr:a,pointer:o,multiValued:"array"===a.type})}return r}missingBases=new Set;async searchReferrers(e,t){return(await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${e.name}=${l(t)})`,attributes:["dn",e.name]},e.binding.base).catch(t=>{if(32!==h(t))throw t;const n=e.binding.base;return this.missingBases.has(n)||(this.missingBases.add(n),this.logger.warn(`${this.name}: ${n} is not in the directory: no reference to an entry is looked for there`)),{searchEntries:[]}})).searchEntries||[]}async findReferences(e){const t=[],n=new Set;return await Promise.all(this.referringAttributes(e).map(r=>this.server.ldap.queryLimit(async()=>{for(const s of await this.searchReferrers(r,e)){const e=String(s.dn),i=`${asDn(e)} ${r.name.toLowerCase()}`;n.has(i)||(n.add(i),t.push({dn:e,attribute:r.name,values:entryValues(s,r.name),multiValued:r.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,n,r){const s=asDn(t),i=asDn(n);if(s===i)return{updated:0,failed:[]};const a=r?this.server.ldap.forRequest(r):{modify:(e,t)=>this.server.ldap.system.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const l=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(a,e,s,i,n),c++}catch(r){this.logger.error(`Rename ${t} -> ${n}: could not rewrite ${e.attribute} of ${e.dn}: ${String(r)}`),l.set(e.attribute,(l.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...l].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,n,r,s){const i=this.rewrite(t,n,r,s);if(!i)return;try{return void await e.modify(t.dn,i)}catch(e){const n=e.code;if(20!==n&&16!==n)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${n}); reading it back`)}const a={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(a,n,r,s);o&&await e.modify(a.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const n=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[t]},e),r=n.searchEntries?.[0];return r?entryValues(r,t):[]}rewrite(e,t,n,r){const s=e.attribute;if(t===n)return;const i=e.values.filter(e=>asDn(e)===t);if(0===i.length)return;const a=e.values.some(e=>asDn(e)===n);if(!e.multiValued){const n=[];for(const s of e.values){const e=asDn(s)===t?r:s;n.some(t=>asDn(t)===asDn(e))||n.push(e)}return{replace:{[s]:n}}}return a?{delete:{[s]:i}}:{delete:{[s]:i},add:{[s]:r}}}async readEntry(e){try{const t=await this.server.ldap.system.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,parseDirectoryDate,startOfUtcDay};
1
+ import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as n,invalidValueMessage as r}from"../../config/schema.js";import{normalizeDn as s,isDnInBranch as i,getParentDn as a,movedToTrash as o,rdnValue as c,escapeLdapFilter as l,parseDirectoryDate as u,isDummyMemberDn as f}from"../../lib/utils.js";import{BadRequestError as h,ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as m}from"../../lib/ldapCodes.js";import{enterpriseRulesOptions as p,organizationOptionsOf as g,groupsOptionsOf as b}from"./ldap.options.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){for(const[n,r]of Object.entries(e))if(spells(n,t))return valueList(r);return[]}function spells(e,t){return e.split(";")[0].toLowerCase()===t.toLowerCase()}function allValues(e,t){return Object.entries(e||{}).filter(([e])=>spells(e,t)).flatMap(([,e])=>valueList(e))}function respell(e,t){if(!t)return;const n=Object.keys(e.attributes);for(const e of Object.keys(t)){if(e.includes(";"))continue;const r=n.find(t=>t!==e&&spells(e,t));r&&!(r in t)&&(t[r]=t[e],delete t[e])}}function asDn(e){try{return s(e)}catch{return e}}function uniquenessBases(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),n=[];for(const r of[e,...t||[]]){const e=r.toLowerCase();if(!n.some(t=>contains(t.toLowerCase(),e))){for(let t=n.length-1;t>=0;t--)contains(e,n[t].toLowerCase())&&n.splice(t,1);n.push(r)}}return n}const y={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return assertSafeSize(Math.round(e));const t=String(e).trim(),n=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!n)throw new h(`"${t}" is not a valid size`);const r=(n[2]||"B").toUpperCase();return assertSafeSize(Math.round(parseFloat(n[1])*y[r]))}function assertSafeSize(e){if(!Number.isSafeInteger(e))throw new h("the size is too large to be written");return e}function fillsGeneratedAttribute(e,n,r){return!(void 0===n.default||!n.generated&&!n.normalize)||t(r,"organizationPath")===e}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,n=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const r=e.lastIndexOf("@");if(r<0)return!1;const s=e.slice(r+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return s===t||n&&s.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{static optionDefinitions=p;name="ldapEnterpriseRules";roles=["consistency"];dummyMember;topOrganization;enterpriseSettings;orgSettings;constructor(e){super(e),this.enterpriseSettings=this.options(p),this.orgSettings=g(e.config),this.dummyMember=b(e.config).group_dummy_user,this.topOrganization=this.orgSettings.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,n){return fillsGeneratedAttribute(e,t,n)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const n of t?.instances||[])n.schema&&e.push({base:n.base,subtree:!1,schema:n.schema,label:n.singularName});const n=this.server.loadedPlugins.ldapGroups;n?.base&&n.schema&&e.push({base:n.base,subtree:!0,schema:n.schema,label:"group"});const r=this.server.loadedPlugins.ldapOrganizations;return r?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:r.schema,label:"organization"}),e}resolveEntity(e){const t=s(e);let n,r=-1;for(const o of this.bindings()){const c=s(o.base);if(t===c)continue;if(!i(e,o.base))continue;if(!o.subtree&&s(a(e))!==c)continue;const l=c.split(",").length;l>r&&(n=o,r=l)}return n}hooks={ldapaddrequest:async([e,t,n])=>{const r=this.resolveEntity(e);return r&&(respell(r.schema,t),await this.runChecks(r,e,t,null),await this.runTransformations(r,e,t,!0)),void 0!==n?[e,t,n]:[e,t]},ldapmodifyrequest:async([e,n,r,s])=>{const i=this.resolveEntity(e);if(!i)return[e,n,r,s];respell(i.schema,n.add),respell(i.schema,n.replace);const a={...n.add||{},...n.replace||{}};if(0===Object.keys(a).length)return[e,n,r,s];const o=await this.readEntry(e),c=t(i.schema,"organizationLink"),l={...a},sent=e=>Object.keys(l).some(t=>spells(t,e));if(c&&sent(c)&&o)for(const[e,t]of Object.entries(i.schema.attributes)){if(!t.mailDomainScope)continue;if(sent(e))continue;const n=allValues(o,e);n.length&&(l[e]=n)}await this.runChecks(i,e,l,o),await this.runTransformations(i,e,a,!1);for(const[e,t]of Object.entries(a))n.replace&&e in n.replace?n.replace[e]=t:n.add&&e in n.add?n.add[e]=t:(n.replace=n.replace||{},n.replace[e]=t);return[e,n,r,s]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]},ldapdeletedone:async(e,t)=>{for(const n of Array.isArray(e)?e:[e])await this.removeReferences(n,t)},ldaprenamedone:async(e,t,n)=>{o(this.config.trash_base,e,t)&&await this.removeReferences(e,n)}};async runChecks(e,t,r,s){const i=Object.keys(e.schema.attributes);for(const[a,o]of Object.entries(r)){const c=i.find(e=>spells(a,e));if(!c)continue;const l=e.schema.attributes[c],u=valueList(o);0!==u.length&&(l.unique&&await this.checkUnique(e,c,l,u,t,s),l.mailDomainScope&&await this.checkMailDomains(e,c,l,u,r,s),n(l,"accountExpiry")&&this.checkNotInThePast(c,l,u))}}async runTransformations(e,n,s,i){for(const[t,n]of Object.entries(e.schema.attributes)){if(i&&void 0===s[t]&&void 0!==n.default&&(n.generated||n.normalize)&&(s[t]=Array.isArray(n.default)?n.default.map(e=>String(e)):String(n.default),"pointer"===n.type||"pointer"===n.items?.type))for(const e of valueList(s[t]))if(!await this.readEntry(e))throw new h(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===n.normalize&&void 0!==s[t]){const e=valueList(s[t]);try{s[t]=Array.isArray(s[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new h(r(t,n))}}}const o=t(e.schema,"organizationPath");if(!o)return;if(!e.schema.attributes[o]?.generated&&void 0!==s[o])return;const l=t(e.schema,"organizationLink");if(l){const e=valueList(s[l])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new h(`Organization ${e} has no ${o}, so "${o}" cannot be computed`);return void(s[o]=t)}if(!i)return;const u=valueList(s[t(e.schema,"identifier")||""])[0]||c(n);if(!u)return;const f=a(n),d=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!d||f.toLowerCase().endsWith(`,${d}`)?await this.organizationPath(f):void 0,p=this.orgSettings.ldap_organization_path_separator;s[o]=m?`${m}${p}${u}`:u}async checkUnique(e,t,n,r,s,a){const o="object"==typeof n.unique?n.unique:{},c=[t,...o.attributes||[]],u=a?s:null,f=uniquenessBases(e.base,o.branches),h=new Set(c.filter(n=>{if(n===t)return!0;const r=e.schema.attributes[n]?.unique;if(!r)return!1;const s="object"==typeof r?r:{},a=[n,...s.attributes||[]],l=uniquenessBases(e.base,s.branches);return(!s.filter||s.filter===o.filter)&&s.sentinel===o.sentinel&&c.every(e=>a.includes(e))&&f.every(e=>l.some(t=>i(e,t)))}).flatMap(e=>allValues(a,e)));for(const n of r){if(void 0!==o.sentinel&&n===o.sentinel)continue;if(h.has(n))continue;const r=c.map(e=>`(${e}=${l(n)})`).join("");let s=c.length>1?`(|${r})`:r;o.filter&&(s=`(&${o.filter}${s})`);for(const r of f){const i=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:s,attributes:["dn"]},r);for(const r of i.searchEntries||[]){const s=String(r.dn);if(!u||s.toLowerCase()!==u.toLowerCase())throw this.logger.warn(`${t}="${n}" refused for ${u||"a new entry"}: already held by ${s}`),new d(`${e.label}: "${n}" is already used (${t} must be unique)`,{code:"unique",attribute:t,value:n})}}}}async checkMailDomains(e,t,n,r,s,i){const a="directory"===n.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,s,i);if(0!==a.length)for(const e of r)if(!mailInDomains(e,a,n.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${a.join(", ")})`,{code:"mailDomain",attribute:t,value:e,domains:a})}async organizationDomains(e,n,r){const s=t(e.schema,"organizationLink");if(!s)return[];const i=allValues(n,s)[0]||allValues(r,s)[0]||void 0;if(!i)return[];const o=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(o,"domainLink")||this.enterpriseSettings.enterprise_domain_link_attribute;if(!c)return[];const l=[],u=new Set;let f=i;const h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),d=this.domainNameAttribute();for(;f&&!u.has(f.toLowerCase());){u.add(f.toLowerCase());const e=await this.readEntry(f);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),n=valueList(e?.[d]);0===n.length&&this.logger.warn(`Organization ${f} links to domain ${t}, which has no "${d}": no mail domain restriction comes from it`),l.push(...n)}if(f.toLowerCase()===h)break;const t=a(f);f=t===f?void 0:t}return l.filter((e,t)=>l.findIndex(t=>t.toLowerCase()===e.toLowerCase())===t)}domainNameAttribute(){for(const e of this.bindings()){const n=t(e.schema,"domainName");if(n)return n}return this.enterpriseSettings.enterprise_domain_name_attribute}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),n=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),r=[];for(const e of n.searchEntries||[])r.push(...valueList(e[t]));return r}checkNotInThePast(e,t,n){const s=startOfUtcDay();for(const i of n){const n=u(i);if(!n)throw new h(r(e,t));if(n.getTime()<s)throw new h(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const n=this.bindings().find(e=>"organization"===e.label)?.schema,r=t(n,"organizationPath")||this.orgSettings.ldap_organization_path_attribute;if(!r)return;const s=await this.readEntry(e);return s?valueList(s[r])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[n,r]of Object.entries(t.schema.attributes)){if("nonEmpty"!==r.deleteGuard)continue;const s=await this.readEntry(e),i=valueList(s?.[n]).filter(e=>!f(e,this.dummyMember));if(i.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${i.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const n=(await this.searchReferrers(t,e))[0];if(n)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(n.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}async removeReferences(e,t){const n=asDn(e),r=new Set;for(const s of this.referringAttributes(e)){if("remove"!==s.attr.referentialIntegrity)continue;let i;try{i=await this.searchReferrers(s,e)}catch(t){this.logger.error(`Delete of ${e}: could not look for ${s.name} references: ${String(t)}`);continue}for(const a of i){const i=String(a.dn),o=`${asDn(i)} ${s.name.toLowerCase()}`;if(r.has(o))continue;r.add(o);const c=entryValues(a,s.name).filter(e=>asDn(e)===n);if(0!==c.length)try{await this.server.ldap.system.modify(i,{delete:{[s.name]:c}},void 0,{context:t})}catch(t){if(16===m(t))continue;this.logger.error(`Delete of ${e}: could not remove it from ${s.name} of ${i}: ${String(t)}`)}}}}referringAttributes(e){const t=a(e),r=[];for(const e of this.bindings())for(const[s,a]of Object.entries(e.schema.attributes)){const o="pointer"===a.type?a:a.items,c=n(a,"members")||n(a,"owners");if(!o&&!c)continue;const l=o?.branch||[];l.length>0&&!l.some(e=>i(t,e))||r.push({binding:e,name:s,attr:a,pointer:o,multiValued:"array"===a.type})}return r}missingBases=new Set;async searchReferrers(e,t){return(await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${e.name}=${l(t)})`,attributes:["dn",e.name]},e.binding.base).catch(t=>{if(32!==m(t))throw t;const n=e.binding.base;return this.missingBases.has(n)||(this.missingBases.add(n),this.logger.warn(`${this.name}: ${n} is not in the directory: no reference to an entry is looked for there`)),{searchEntries:[]}})).searchEntries||[]}async findReferences(e){const t=[],n=new Set;return await Promise.all(this.referringAttributes(e).map(r=>this.server.ldap.queryLimit(async()=>{for(const s of await this.searchReferrers(r,e)){const e=String(s.dn),i=`${asDn(e)} ${r.name.toLowerCase()}`;n.has(i)||(n.add(i),t.push({dn:e,attribute:r.name,values:entryValues(s,r.name),multiValued:r.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,n,r){const s=asDn(t),i=asDn(n);if(s===i)return{updated:0,failed:[]};const a=r?this.server.ldap.forRequest(r):{modify:(e,t)=>this.server.ldap.system.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const l=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(a,e,s,i,n),c++}catch(r){this.logger.error(`Rename ${t} -> ${n}: could not rewrite ${e.attribute} of ${e.dn}: ${String(r)}`),l.set(e.attribute,(l.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...l].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,n,r,s){const i=this.rewrite(t,n,r,s);if(!i)return;try{return void await e.modify(t.dn,i)}catch(e){const n=e.code;if(20!==n&&16!==n)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${n}); reading it back`)}const a={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(a,n,r,s);o&&await e.modify(a.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const n=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[t]},e),r=n.searchEntries?.[0];return r?entryValues(r,t):[]}rewrite(e,t,n,r){const s=e.attribute;if(t===n)return;const i=e.values.filter(e=>asDn(e)===t);if(0===i.length)return;const a=e.values.some(e=>asDn(e)===n);if(!e.multiValued){const n=[];for(const s of e.values){const e=asDn(s)===t?r:s;n.some(t=>asDn(t)===asDn(e))||n.push(e)}return{replace:{[s]:n}}}return a?{delete:{[s]:i}}:{delete:{[s]:i},add:{[s]:r}}}async readEntry(e){try{const t=await this.server.ldap.system.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,startOfUtcDay};
@@ -1 +1 @@
1
- import{Type as t}from"@sinclair/typebox";import e from"../../abstract/plugin.js";import{assertModifyRequest as r}from"../../lib/ldapActions.js";import{wantJson as s,jsonBody as i,tryMethod as a,WriteResponse as o,ok as n}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as d}from"../../lib/schemaFile.js";import{staticSchemaUrl as p}from"../../lib/staticFiles.js";import{movedToTrash as c,isDummyMemberDn as u,namesByDn as m,parseDn as l,assertedRdnValue as g,escapeDnValue as b,validateDnValue as f,escapeLdapFilter as w,organizationLink as y,isDnSyntax as $,normalizeDn as v,isDnInBranch as G,getRdn as _,substringSearchFilter as S}from"../../lib/utils.js";import{NotFoundError as D,BadRequestError as j,HttpError as A,isAuthzRefusal as C,ConflictError as q}from"../../lib/errors.js";import{assertClientMaySet as k,modifiedAttributeNames as M,toDirectoryEntry as E}from"../../config/schema.js";import{applyFixedDefaults as N,EntityValidator as O}from"../../lib/entitySchema/validator.js";import{groupsOptions as z,groupsOptionsOf as x,organizationOptionsOf as F}from"./ldap.options.js";import{defineRoute as U}from"../../lib/route.js";const I=t.Object({match:t.Optional(t.String()),attribute:t.Optional(t.String()),attributes:t.Optional(t.String())}),B=t.Object({cn:t.String({minLength:1})}),P=t.Object({cn:t.String({minLength:1}),member:t.String({minLength:1})}),R=t.Object({member:t.Union([t.String(),t.Array(t.String())])}),L=t.Object({targetOrgDn:t.String()}),T=t.Object({newCn:t.String()});class LdapGroups extends e{static optionDefinitions=z;name="ldapGroups";roles=["api","configurable"];base;ldap;cn;schema;get groupSettings(){return x(this.config)}get orgSettings(){return F(this.config)}constructor(t){if(super(t),this.ldap=t.ldap,this.base=this.groupSettings.ldap_group_base,this.cn=this.groupSettings.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.groupSettings.ldap_group_base&&t.ldap.declareObjectClasses(this.base,this.groupSettings.group_class),this.groupSettings.group_schema){const t=this.groupSettings.group_schema;d(t,{config:this.config}).then(t=>{this.schema=this.adaptSchema(t),this.server.ldap.knowAttributeNames(Object.keys(this.schema.attributes)),this.logger.debug("Group schema loaded")}).catch(e=>this.logger.error(`Failed to load group schema from ${t}: ${e}`))}}adaptSchema(t){return t}hooks={ldapdeletedone:async t=>{const e=Array.isArray(t)?t:[t];this.logger.debug(`User deletion detected, removing from groups: ${e.join(", ")}`),await Promise.all(e.map(t=>this.deleteMemberFromAll(t))).catch(t=>{this.logger.error("Failed to process user deletion in groups:",t)})},ldaprenamedone:async(t,e)=>{c(this.config.trash_base,t,e)&&await this.deleteMemberFromAll(t).catch(t=>{this.logger.error("Failed to process a move to the trash in groups:",t)})}};api(t){U(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/groups`,query:I,handler:async({req:t,res:e,query:r})=>{const s={};if(r.match)if(r.attribute)s.filter=S(r.match,r.attribute);else if(/=/.test(r.match)){if(!/^[\w*=()&|, .@'-]+$/.test(r.match))throw new j("Invalid match query: contains forbidden characters");s.filter=r.match}else{const t=w(r.match);s.filter=`(${this.cn}=${t})`}r.attributes&&(s.attributes=r.attributes.split(","));const i=await this.listGroups({...s,req:t});return n(e,this.hideNeverReturn(i))}}),U(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/groups/:cn`,params:B,handler:async({req:t,res:e})=>this.apiGet(t,e)}),U(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups`,responses:{200:o},handler:async({req:t,res:e})=>this.apiAdd(t,e,this.cn)}),U(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/groups/:cn`,responses:{200:o},params:B,handler:async({req:t,res:e})=>this.apiDelete(t,e)}),U(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/groups/:cn`,responses:{200:o},params:B,handler:async({req:t,res:e})=>this.apiModify(t,e)}),U(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,responses:{200:o},params:B,body:R,handler:async({req:t,params:e,res:r,body:s})=>{const i=decodeURIComponent(e.cn),o=this.routeGroupDn(i),n=Array.isArray(s.member)?s.member:[s.member];await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{add:{member:n}},this.opNumber(),t])),await a(r,this.addMember.bind(this),i,s.member,t)}}),U(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,responses:{200:o},params:P,handler:async({req:t,params:e,res:r})=>{const s=decodeURIComponent(e.cn),i=decodeURIComponent(e.member),o=this.routeGroupDn(s);await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{delete:{member:[i]}},this.opNumber(),t])),await a(r,this.deleteMember.bind(this),s,i,t)}}),this.orgSettings.ldap_organization_link_attribute&&this.orgSettings.ldap_organization_path_attribute&&U(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,responses:{200:o},params:B,body:L,handler:async({req:t,params:e,res:r,body:s})=>{const i=decodeURIComponent(e.cn),o=this.routeGroupDn(i),n=this.orgSettings.ldap_organization_link_attribute;await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{replace:{[n]:s.targetOrgDn}},this.opNumber(),t])),await a(r,this.moveGroup.bind(this),i,s.targetOrgDn,t)}}),U(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,responses:{200:o},params:B,body:T,handler:async({req:t,params:e,res:r,body:s})=>{const i=decodeURIComponent(e.cn),o=this.routeGroupDn(i),n=/,/.test(s.newCn)?this.routeGroupDn(s.newCn):this.toGroupDn(s.newCn);await this.judgedGroup(o,()=>this.ldap.authorize("ldaprenamerequest",[o,n,t])),await a(r,this.renameGroup.bind(this),i,s.newCn,t)}})}isDummyMember(t,e=this.groupSettings.group_dummy_user){return u(t,e)}withoutDummyMember(t){const e=t.member;if(void 0===e)return t;const r=Array.isArray(e)?e:[e],s=this.groupSettings.group_dummy_user;return{...t,member:r.filter(t=>!this.isDummyMember(t,s))}}async apiGet(t,e){if(!s(t,e))return;const r=decodeURIComponent(t.params.cn),i=this.routeGroupDn(r);try{const r=await this.ldap.search({paged:!1,scope:"base",filter:this.groupFilter()},i,t);if(0===r.searchEntries.length)throw new D("Group not found");e.json(this.hideNeverReturn(this.withoutDummyMember(r.searchEntries[0])))}catch(t){if(t.code&&32===t.code)throw new D("Group not found");if(34===h(t))throw new j(`${r} is not a valid DN`);throw t}}async apiAdd(t,e,...r){const s=i(t,e,...r);if(!s)return;await this.server.ldap.canonicalAttributes(s),k(this.schema,Object.keys(s),this.cn);const o=s[this.cn];"string"==typeof o&&m(o,this.cn)&&this.routeGroupDn(/,/.test(o)?o:`${o},${this.base}`);const n=s.member?Array.isArray(s.member)?s.member:[s.member]:[],h=Object.fromEntries(Object.entries(s).filter(([t,e])=>t!==this.cn&&"member"!==t&&"objectClass"!==t&&void 0!==e));await a(e,this.addGroup.bind(this),o,n,h,t)}async apiDelete(t,e){if(!s(t,e))return;const r=decodeURIComponent(t.params.cn);if(!r)throw new j("cn is required");const i=this.routeGroupDn(r);await this.judgedGroup(i,()=>this.ldap.authorize("ldapdeleterequest",[i,t])),await a(e,this.deleteGroup.bind(this),r,t)}async apiModify(t,e){const s=i(t,e);if(!s)return;r(s);const o=decodeURIComponent(t.params.cn);if(!o)throw new j("cn is required");const n=this.routeGroupDn(o);await this.server.ldap.canonicalChanges(s),k(this.schema,M(s)),await this.judgedGroup(n,()=>this.ldap.authorize("ldapmodifyrequest",[n,s,this.opNumber(),t])),await a(e,this.modifyGroup.bind(this),n,s,t)}async addGroup(t,e=[],r={},s){let i;if(await this.server.ldap.canonicalAttributes(r),m(t,this.cn)){i=l(t).length>1?t:`${t},${this.base}`;const e=g(t,this.cn);if(null===e)throw new j(`${this.cn} must be a value, or a "${this.cn}=…,${this.base}" DN`);t=e}else i=`${this.cn}=${b(t)},${this.base}`;try{f(t,this.cn)}catch(t){throw new j(t instanceof Error?t.message:"Invalid name")}let a=this.candidateGroup(t,e,r);await this.ldap.authorize("ldapaddrequest",[i,{...a},s]),await this.validateMembers(i,e,s);const o=await this.validateNewGroup(i,{objectClass:a.objectClass,[this.cn]:t,member:e,...r});let n;delete o.member,a={...this.candidateGroup(t,[],{}),member:a.member,...o},E(this.schema,a),[i,a]=await this.hookBus.chain("ldapgroupadd",[i,a]);try{n=await this.ldap.add(i,a,s)}catch(t){if(t instanceof A||C(t))throw t;throw new Error(`Failed to add group ${i}: ${String(t)}`,{cause:t})}return this.hookBus.notify("ldapgroupadddone",i,a),n}candidateGroup(t,e,r){const s=[...this.groupSettings.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");const i={objectClass:s,...this.groupSettings.group_default_attributes,[this.cn]:t,member:[this.groupSettings.group_dummy_user],...r};return e.length&&i.member.push(...e),N(this.schema,i),i}async modifyGroup(t,e,r){await this.server.ldap.canonicalChanges(e);let s=this.toGroupDn(t);const i=this.opNumber();if([s,e]=await this.hookBus.chain("ldapgroupmodify",[s,e,i]),e.add){if(e.add.member)throw new j("Use dedicated API to add members");if(e.add[this.cn])throw new j(`${this.cn} attribute is unique, cannot add`)}if(e.delete){if(e.delete instanceof Object){if(e.delete.member)throw new j("Use dedicated API to delete members");if(e.delete[this.cn])throw new j(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(e.delete)){if(e.delete.includes("member"))throw new j("Use dedicated API to delete members");if(e.delete.includes(this.cn))throw new j(`Cannot delete ${this.cn} attribute`)}}if(e.replace){if(e.replace.member)throw new j("Use dedicated API to replace members");if(e.replace[this.cn])throw new j(`Use dedicated API to change ${this.cn} attribute`)}await this.ldap.authorize("ldapmodifyrequest",[s,e,i,r]),e=await this.validateChanges(s,e);const a=await this.ldap.modify(s,e,r);return this.hookBus.notify("ldapgroupmodifydone",s,e,i),a}async renameGroup(t,e,r){/,/.test(t)||f(t,this.cn),/,/.test(e)||f(e,this.cn);let s=this.toGroupDn(t),i=this.toGroupDn(e);[s,i]=await this.hookBus.chain("ldapgrouprename",[s,i]);const a=await this.ldap.rename(s,i,r);return this.hookBus.notify("ldapgrouprenamedone",s,i),a}async deleteGroup(t,e){let r=this.toGroupDn(t);r=await this.hookBus.chain("ldapgroupdelete",r);const s=await this.ldap.delete(r,e);return this.hookBus.notify("ldapgroupdeletedone",r),s}async addMember(t,e,r){const s=this.toGroupDn(t);return Array.isArray(e)||(e=[e]),[t,e]=await this.hookBus.chain("ldapgroupaddmember",[t,e]),await this.ldap.authorize("ldapmodifyrequest",[s,{add:{member:e}},this.opNumber(),r]),await this.validateMembers(s,e,r),await this.ldap.modify(s,{add:{member:e}},r).catch(t=>{if(t instanceof A||C(t))throw t;throw new Error(`Failed to add member(s) to ${s}: ${String(t)}`,{cause:t})})}async deleteMember(t,e,r){const s=this.toGroupDn(t);if([t,e]=await this.hookBus.chain("ldapgroupdeletemember",[t,e]),this.isDummyMember(e))throw new j("Cannot delete dummy member from group");return await this.removeMember(s,e,r).catch(t=>{if(16===h(t))throw new D(`${e} is not a member of ${s}`);if(t instanceof A||C(t))throw t;throw new Error(`Failed to delete member ${e} from ${s}: ${String(t)}`,{cause:t})})}async removeMember(t,e,r){return await this.ldap.modify(t,{delete:{member:e}},r).catch(s=>{if(65!==h(s))throw s;const i=this.groupSettings.group_dummy_user;if(!i)throw new q(`Cannot remove ${e}, the last member of ${t}: a group needs at least one member`);return this.ldap.modify(t,{add:{member:i},delete:{member:e}},r)})}async deleteMemberFromAll(t){const e=await this.ldap.system.search({filter:`member=${w(t)}`,paged:!1,attributes:[this.cn]},this.base).catch(t=>{throw new Error(`Failed to search groups from ${this.base}: ${String(t)}`,{cause:t})});await Promise.all(e.searchEntries.map(e=>this.removeMember(e.dn,t).catch(r=>{const s=h(r);16!==s&&32!==s&&this.logger.error(`Failed to remove ${t} from group ${e.dn}:`,r)})))}async moveGroup(t,e,r){const s=this.orgSettings.ldap_organization_link_attribute,i=this.orgSettings.ldap_organization_path_attribute,a=this.toGroupDn(t);let o;await this.ldap.authorize("ldapmodifyrequest",[a,{replace:{[s]:e}},this.opNumber(),r]);try{o=await this.ldap.system.search({paged:!1,scope:"base"},a)}catch(t){if(32===h(t))throw new D(`Group ${a} not found`);throw t}if(0===o.searchEntries.length)throw new D(`Group ${a} not found`);const n=o.searchEntries[0],d=y(n[s]);if(!d)throw new j(`Group ${a} does not have ${s} attribute and cannot be moved`);if(d===e)throw new j("Group is already in the target organization");let p;try{p=await this.ldap.system.search({paged:!1,scope:"base",attributes:[i,"ou","o"]},e)}catch(t){const r=h(t);if(32===r)throw new D(`Target organization ${e} not found`);if(34===r)throw new j(`${e} is not a valid DN`);throw new Error(`Target organization ${e} not found: ${t instanceof Error&&t.message?t.message:String(t)}`)}if(0===p.searchEntries.length)throw new D(`Target organization ${e} not found`);const c=p.searchEntries[0][i];if(!c)throw new Error(`Target organization ${e} does not have ${i} attribute`);return await this.ldap.modify(a,{replace:{[s]:e,[i]:c}},r),this.logger.info(`Group ${a} moved from ${d} to ${e}`),{success:!0}}async listGroups({filter:t,attributes:e,req:r}={}){const s=await this.ldap.search({filter:t||"(objectClass=*)",attributes:e||[this.cn,"member"],paged:!0},this.base,r).catch(t=>{throw new Error(`Failed to list groups from ${this.base}: ${String(t)}`,{cause:t})});let i={};for await(const t of s)for(const e of t.searchEntries){const t=e[this.cn];if(Array.isArray(t)&&0===t.length)continue;t&&(i[t]=this.withoutDummyMember(e))}return i=await this.hookBus.chain("_ldapgrouplist",i),i}async searchGroupsByName(t,e=!1,r=[this.cn,"member"],s){const i=w(t),a=e?`(${this.cn}=*${i}*)`:`(${this.cn}=${i})`;return await this.listGroups({filter:a,attributes:r,req:s})}toGroupDn(t){if(!/[,;]/.test(t)&&!m(t,this.cn))return`${this.cn}=${b(t)},${this.base}`;if(!$(t))throw new j(`${t} is not a valid DN`);return t}routeGroupDn(t){const e=this.toGroupDn(t),r=this.base,s=v(`${this.cn}=x`).split("=")[0];if(!G(e,r)||v(e)===v(r)||!v(_(e)).split(/(?<!\\)\+/).some(t=>t.startsWith(`${s}=`)))throw new D("Group not found");return e}groupFilter(){return`(|${[...new Set([...this.groupSettings.group_class,"groupOfNames","groupOfUniqueNames","groupOfURLs","posixGroup"].filter(t=>t&&"top"!==t.toLowerCase()))].map(t=>`(objectClass=${w(t)})`).join("")})`}async judgedGroup(t,e){let r;await e();try{r=await this.ldap.system.search({paged:!1,scope:"base",filter:this.groupFilter(),attributes:["1.1"]},t)}catch(e){const r=h(e);if(32===r)throw new D("Group not found");if(34===r)throw new j(`${t} is not a valid DN`);throw e}if(!r.searchEntries?.length)throw new D("Group not found")}async validateMembers(t,e,r){[t,e]=await this.hookBus.chain("ldapgroupvalidatemembers",[t,e]);const s=this.groupSettings.groups_allow_unexistent_members;if((!s||r)&&e&&e.length)try{await Promise.all(e.map(t=>this.server.ldap.queryLimit(async()=>{let e;try{e=await this.ldap.search({paged:!1,scope:"base"},t,r)}catch(e){if(C(e))throw e;if(s)return;const r=h(e);if(32===r)throw new j(`Member ${t} not found`);if(34===r)throw new j(`Member ${t} is not a valid DN`);throw new Error(`Member ${t} not found: ${e}`)}if(r&&!e.searchEntries?.length)throw new j(`Member ${t} not found`)})))}catch(t){if(t instanceof A||C(t))throw t;throw new Error(`Failed to find member(s): ${String(t)}`,{cause:t})}}async validateNewGroup(t,e){return[t,e]=await this.hookBus.chain("ldapgroupvalidatenew",[t,e]),await this.validator().validateNew(e)}async validateChanges(t,e){return[t,e]=await this.hookBus.chain("ldapgroupvalidatechanges",[t,e]),await this.validator().validateChanges(e),e}validator(){return new O(this.schema,{entryExists:async t=>{try{return(await this.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}},loadedPlugins:this.server.loadedPlugins,singularName:"group"})}getConfigApiData(){const t=this.config.api_prefix||"/api",e=this.groupSettings.group_schema?p(this.server,this.groupSettings.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.groupSettings.group_class,schema:this.schema,schemaUrl:e,endpoints:{list:`${t}/v1/ldap/groups`,get:`${t}/v1/ldap/groups/:id`,create:`${t}/v1/ldap/groups`,update:`${t}/v1/ldap/groups/:id`,delete:`${t}/v1/ldap/groups/:id`,addMember:`${t}/v1/ldap/groups/:id/members`,removeMember:`${t}/v1/ldap/groups/:id/members/:memberId`,rename:`${t}/v1/ldap/groups/:id/rename`,move:`${t}/v1/ldap/groups/:id/move`}}}}export{P as GroupMemberParams,R as GroupMembersBody,L as GroupMoveBody,B as GroupParams,T as GroupRenameBody,I as GroupsQuery,LdapGroups as default};
1
+ import{Type as t}from"@sinclair/typebox";import e from"../../abstract/plugin.js";import{assertModifyRequest as r}from"../../lib/ldapActions.js";import{wantJson as s,jsonBody as i,tryMethod as a,WriteResponse as o,ok as n}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as p}from"../../lib/schemaFile.js";import{staticSchemaUrl as d}from"../../lib/staticFiles.js";import{movedToTrash as u,isDummyMemberDn as c,namesByDn as m,assertRfc4514Escapes as l,parseDn as g,assertedRdnValue as b,escapeDnValue as f,validateDnValue as w,escapeLdapFilter as y,organizationLink as $,isDnSyntax as v,normalizeDn as G,isDnInBranch as _,getRdn as S,substringSearchFilter as D}from"../../lib/utils.js";import{NotFoundError as j,BadRequestError as A,HttpError as q,isAuthzRefusal as k,ConflictError as M}from"../../lib/errors.js";import{assertClientMaySet as C,modifiedAttributeNames as E,toDirectoryEntry as N}from"../../config/schema.js";import{applyFixedDefaults as O,EntityValidator as z}from"../../lib/entitySchema/validator.js";import{groupsOptions as x,groupsOptionsOf as F,organizationOptionsOf as B}from"./ldap.options.js";import{defineRoute as P}from"../../lib/route.js";const L=t.Object({match:t.Optional(t.String()),attribute:t.Optional(t.String()),attributes:t.Optional(t.String())}),U=t.Object({cn:t.String({minLength:1})}),I=t.Object({cn:t.String({minLength:1}),member:t.String({minLength:1})}),T=t.Object({member:t.Union([t.String(),t.Array(t.String())])}),R=t.Object({targetOrgDn:t.String()}),W=t.Object({newCn:t.String()});class LdapGroups extends e{static optionDefinitions=x;name="ldapGroups";roles=["api","configurable"];base;ldap;cn;schema;get groupSettings(){return F(this.config)}get orgSettings(){return B(this.config)}constructor(t){if(super(t),this.ldap=t.ldap,this.base=this.groupSettings.ldap_group_base,this.cn=this.groupSettings.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.groupSettings.ldap_group_base&&t.ldap.declareObjectClasses(this.base,this.groupSettings.group_class),this.groupSettings.group_schema){const t=this.groupSettings.group_schema;p(t,{config:this.config}).then(t=>{this.schema=this.adaptSchema(t),this.server.ldap.knowAttributeNames(Object.keys(this.schema.attributes)),this.logger.debug("Group schema loaded")}).catch(e=>this.logger.error(`Failed to load group schema from ${t}: ${e}`))}}adaptSchema(t){return t}hooks={ldapdeletedone:async t=>{const e=Array.isArray(t)?t:[t];this.logger.debug(`User deletion detected, removing from groups: ${e.join(", ")}`),await Promise.all(e.map(t=>this.deleteMemberFromAll(t))).catch(t=>{this.logger.error("Failed to process user deletion in groups:",t)})},ldaprenamedone:async(t,e)=>{u(this.config.trash_base,t,e)&&await this.deleteMemberFromAll(t).catch(t=>{this.logger.error("Failed to process a move to the trash in groups:",t)})}};api(t){P(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/groups`,query:L,handler:async({req:t,res:e,query:r})=>{const s={};if(r.match)if(r.attribute)s.filter=D(r.match,r.attribute);else if(/=/.test(r.match)){if(!/^[\w*=()&|, .@'-]+$/.test(r.match))throw new A("Invalid match query: contains forbidden characters");s.filter=r.match}else{const t=y(r.match);s.filter=`(${this.cn}=${t})`}r.attributes&&(s.attributes=r.attributes.split(","));const i=await this.listGroups({...s,req:t});return n(e,this.hideNeverReturn(i))}}),P(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/groups/:cn`,params:U,handler:async({req:t,res:e})=>this.apiGet(t,e)}),P(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups`,responses:{200:o},handler:async({req:t,res:e})=>this.apiAdd(t,e,this.cn)}),P(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/groups/:cn`,responses:{200:o},params:U,handler:async({req:t,res:e})=>this.apiDelete(t,e)}),P(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/groups/:cn`,responses:{200:o},params:U,handler:async({req:t,res:e})=>this.apiModify(t,e)}),P(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,responses:{200:o},params:U,body:T,handler:async({req:t,params:e,res:r,body:s})=>{const i=e.cn,o=this.routeGroupDn(i),n=Array.isArray(s.member)?s.member:[s.member];await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{add:{member:n}},this.opNumber(),t])),await a(r,this.addMember.bind(this),i,s.member,t)}}),P(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,responses:{200:o},params:I,handler:async({req:t,params:e,res:r})=>{const s=e.cn,i=e.member,o=this.routeGroupDn(s);await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{delete:{member:[i]}},this.opNumber(),t])),await a(r,this.deleteMember.bind(this),s,i,t)}}),this.orgSettings.ldap_organization_link_attribute&&this.orgSettings.ldap_organization_path_attribute&&P(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,responses:{200:o},params:U,body:R,handler:async({req:t,params:e,res:r,body:s})=>{const i=e.cn,o=this.routeGroupDn(i),n=this.orgSettings.ldap_organization_link_attribute;await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{replace:{[n]:s.targetOrgDn}},this.opNumber(),t])),await a(r,this.moveGroup.bind(this),i,s.targetOrgDn,t)}}),P(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,responses:{200:o},params:U,body:W,handler:async({req:t,params:e,res:r,body:s})=>{const i=e.cn,o=this.routeGroupDn(i),n=/,/.test(s.newCn)?this.routeGroupDn(s.newCn):this.toGroupDn(s.newCn);await this.judgedGroup(o,()=>this.ldap.authorize("ldaprenamerequest",[o,n,t])),await a(r,this.renameGroup.bind(this),i,s.newCn,t)}})}isDummyMember(t,e=this.groupSettings.group_dummy_user){return c(t,e)}withoutDummyMember(t){const e=t.member;if(void 0===e)return t;const r=Array.isArray(e)?e:[e],s=this.groupSettings.group_dummy_user;return{...t,member:r.filter(t=>!this.isDummyMember(t,s))}}async apiGet(t,e){if(!s(t,e))return;const r=t.params.cn,i=this.routeGroupDn(r);try{const r=await this.ldap.search({paged:!1,scope:"base",filter:this.groupFilter()},i,t);if(0===r.searchEntries.length)throw new j("Group not found");e.json(this.hideNeverReturn(this.withoutDummyMember(r.searchEntries[0])))}catch(t){if(t.code&&32===t.code)throw new j("Group not found");if(34===h(t))throw new A(`${r} is not a valid DN`);throw t}}async apiAdd(t,e,...r){const s=i(t,e,...r);if(!s)return;await this.server.ldap.canonicalAttributes(s),C(this.schema,Object.keys(s),this.cn);const o=s[this.cn];"string"==typeof o&&m(o,this.cn)&&this.routeGroupDn(/,/.test(o)?o:`${o},${this.base}`);const n=s.member?Array.isArray(s.member)?s.member:[s.member]:[],h=Object.fromEntries(Object.entries(s).filter(([t,e])=>t!==this.cn&&"member"!==t&&"objectClass"!==t&&void 0!==e));await a(e,this.addGroup.bind(this),o,n,h,t)}async apiDelete(t,e){if(!s(t,e))return;const r=t.params.cn;if(!r)throw new A("cn is required");const i=this.routeGroupDn(r);await this.judgedGroup(i,()=>this.ldap.authorize("ldapdeleterequest",[i,t])),await a(e,this.deleteGroup.bind(this),r,t)}async apiModify(t,e){const s=i(t,e);if(!s)return;r(s);const o=t.params.cn;if(!o)throw new A("cn is required");const n=this.routeGroupDn(o);await this.server.ldap.canonicalChanges(s),C(this.schema,E(s)),await this.judgedGroup(n,()=>this.ldap.authorize("ldapmodifyrequest",[n,s,this.opNumber(),t])),await a(e,this.modifyGroup.bind(this),n,s,t)}async addGroup(t,e=[],r={},s){if("string"!=typeof t)throw new A(`${this.cn} must be a string`);let i;if(await this.server.ldap.canonicalAttributes(r),m(t,this.cn)){l(t,`${this.cn} DN`);const[,...e]=g(t),r=b(t,this.cn);if(null===r)throw new A(`${this.cn} must be a value, or a "${this.cn}=…,${this.base}" DN`);t=r,i=`${this.cn}=${f(t)},${e.length?e.join(","):this.base}`}else i=`${this.cn}=${f(t)},${this.base}`;try{w(t,this.cn)}catch(t){throw new A(t instanceof Error?t.message:"Invalid name")}let a=this.candidateGroup(t,e,r);await this.ldap.authorize("ldapaddrequest",[i,{...a},s]),await this.validateMembers(i,e,s);const o=await this.validateNewGroup(i,{objectClass:a.objectClass,[this.cn]:t,member:e,...r});let n;delete o.member,a={...this.candidateGroup(t,[],{}),member:a.member,...o},N(this.schema,a),[i,a]=await this.hookBus.chain("ldapgroupadd",[i,a]);try{n=await this.ldap.add(i,a,s)}catch(t){if(t instanceof q||k(t))throw t;throw new Error(`Failed to add group ${i}: ${String(t)}`,{cause:t})}return this.hookBus.notify("ldapgroupadddone",i,a),n}candidateGroup(t,e,r){const s=[...this.groupSettings.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||s.includes("twakeGroup")||s.includes("twakeStaticGroup")||s.push("twakeGroup");const i={objectClass:s,...this.groupSettings.group_default_attributes,[this.cn]:t,member:[this.groupSettings.group_dummy_user],...r};return e.length&&i.member.push(...e),O(this.schema,i),i}async modifyGroup(t,e,r){await this.server.ldap.canonicalChanges(e);let s=this.toGroupDn(t);const i=this.opNumber();if([s,e]=await this.hookBus.chain("ldapgroupmodify",[s,e,i]),e.add){if(e.add.member)throw new A("Use dedicated API to add members");if(e.add[this.cn])throw new A(`${this.cn} attribute is unique, cannot add`)}if(e.delete){if(e.delete instanceof Object){if(e.delete.member)throw new A("Use dedicated API to delete members");if(e.delete[this.cn])throw new A(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(e.delete)){if(e.delete.includes("member"))throw new A("Use dedicated API to delete members");if(e.delete.includes(this.cn))throw new A(`Cannot delete ${this.cn} attribute`)}}if(e.replace){if(e.replace.member)throw new A("Use dedicated API to replace members");if(e.replace[this.cn])throw new A(`Use dedicated API to change ${this.cn} attribute`)}await this.ldap.authorize("ldapmodifyrequest",[s,e,i,r]),e=await this.validateChanges(s,e);const a=await this.ldap.modify(s,e,r);return this.hookBus.notify("ldapgroupmodifydone",s,e,i),a}async renameGroup(t,e,r){/,/.test(t)||w(t,this.cn),/,/.test(e)||w(e,this.cn);let s=this.toGroupDn(t),i=this.toGroupDn(e);[s,i]=await this.hookBus.chain("ldapgrouprename",[s,i]);const a=await this.ldap.rename(s,i,r);return this.hookBus.notify("ldapgrouprenamedone",s,i),a}async deleteGroup(t,e){let r=this.toGroupDn(t);r=await this.hookBus.chain("ldapgroupdelete",r);const s=await this.ldap.delete(r,e);return this.hookBus.notify("ldapgroupdeletedone",r),s}async addMember(t,e,r){const s=this.toGroupDn(t);return Array.isArray(e)||(e=[e]),[,e]=await this.hookBus.chain("ldapgroupaddmember",[t,e]),await this.ldap.authorize("ldapmodifyrequest",[s,{add:{member:e}},this.opNumber(),r]),await this.validateMembers(s,e,r),await this.ldap.modify(s,{add:{member:e}},r).catch(t=>{if(t instanceof q||k(t))throw t;throw new Error(`Failed to add member(s) to ${s}: ${String(t)}`,{cause:t})})}async deleteMember(t,e,r){const s=this.toGroupDn(t);if([,e]=await this.hookBus.chain("ldapgroupdeletemember",[t,e]),this.isDummyMember(e))throw new A("Cannot delete dummy member from group");return await this.removeMember(s,e,r).catch(t=>{if(16===h(t))throw new j(`${e} is not a member of ${s}`);if(t instanceof q||k(t))throw t;throw new Error(`Failed to delete member ${e} from ${s}: ${String(t)}`,{cause:t})})}async removeMember(t,e,r){return await this.ldap.modify(t,{delete:{member:e}},r).catch(s=>{if(65!==h(s))throw s;const i=this.groupSettings.group_dummy_user;if(!i)throw new M(`Cannot remove ${e}, the last member of ${t}: a group needs at least one member`);return this.ldap.modify(t,{add:{member:i},delete:{member:e}},r)})}async deleteMemberFromAll(t){const e=await this.ldap.system.search({filter:`member=${y(t)}`,paged:!1,attributes:[this.cn]},this.base).catch(t=>{throw new Error(`Failed to search groups from ${this.base}: ${String(t)}`,{cause:t})});await Promise.all(e.searchEntries.map(e=>this.removeMember(e.dn,t).catch(r=>{const s=h(r);16!==s&&32!==s&&this.logger.error(`Failed to remove ${t} from group ${e.dn}:`,r)})))}async moveGroup(t,e,r){const s=this.orgSettings.ldap_organization_link_attribute,i=this.orgSettings.ldap_organization_path_attribute,a=this.toGroupDn(t);let o;await this.ldap.authorize("ldapmodifyrequest",[a,{replace:{[s]:e}},this.opNumber(),r]);try{o=await this.ldap.system.search({paged:!1,scope:"base"},a)}catch(t){if(32===h(t))throw new j(`Group ${a} not found`);throw t}if(0===o.searchEntries.length)throw new j(`Group ${a} not found`);const n=o.searchEntries[0],p=$(n[s]);if(!p)throw new A(`Group ${a} does not have ${s} attribute and cannot be moved`);if(p===e)throw new A("Group is already in the target organization");let d;try{d=await this.ldap.system.search({paged:!1,scope:"base",attributes:[i,"ou","o"]},e)}catch(t){const r=h(t);if(32===r)throw new j(`Target organization ${e} not found`);if(34===r)throw new A(`${e} is not a valid DN`);throw new Error(`Target organization ${e} not found: ${t instanceof Error&&t.message?t.message:String(t)}`)}if(0===d.searchEntries.length)throw new j(`Target organization ${e} not found`);const u=d.searchEntries[0][i];if(!u)throw new Error(`Target organization ${e} does not have ${i} attribute`);return await this.ldap.modify(a,{replace:{[s]:e,[i]:u}},r),this.logger.info(`Group ${a} moved from ${p} to ${e}`),{success:!0}}async listGroups({filter:t,attributes:e,req:r}={}){const s=await this.ldap.search({filter:t||"(objectClass=*)",attributes:e||[this.cn,"member"],paged:!0},this.base,r).catch(t=>{throw new Error(`Failed to list groups from ${this.base}: ${String(t)}`,{cause:t})});let i={};for await(const t of s)for(const e of t.searchEntries){const t=e[this.cn];if(Array.isArray(t)&&0===t.length)continue;t&&(i[t]=this.withoutDummyMember(e))}return i=await this.hookBus.chain("_ldapgrouplist",i),i}async searchGroupsByName(t,e=!1,r=[this.cn,"member"],s){const i=y(t),a=e?`(${this.cn}=*${i}*)`:`(${this.cn}=${i})`;return await this.listGroups({filter:a,attributes:r,req:s})}toGroupDn(t){if(!/[,;]/.test(t)&&!m(t,this.cn))return`${this.cn}=${f(t)},${this.base}`;if(!v(t))throw new A(`${t} is not a valid DN`);return t}routeGroupDn(t){const e=this.toGroupDn(t),r=this.base,s=G(`${this.cn}=x`).split("=")[0];if(!_(e,r)||G(e)===G(r)||!G(S(e)).split(/(?<!\\)\+/).some(t=>t.startsWith(`${s}=`)))throw new j("Group not found");return e}groupFilter(){return`(|${[...new Set([...this.groupSettings.group_class,"groupOfNames","groupOfUniqueNames","groupOfURLs","posixGroup"].filter(t=>t&&"top"!==t.toLowerCase()))].map(t=>`(objectClass=${y(t)})`).join("")})`}async judgedGroup(t,e){let r;await e();try{r=await this.ldap.system.search({paged:!1,scope:"base",filter:this.groupFilter(),attributes:["1.1"]},t)}catch(e){const r=h(e);if(32===r)throw new j("Group not found");if(34===r)throw new A(`${t} is not a valid DN`);throw e}if(!r.searchEntries?.length)throw new j("Group not found")}async validateMembers(t,e,r){[,e]=await this.hookBus.chain("ldapgroupvalidatemembers",[t,e]);const s=this.groupSettings.groups_allow_unexistent_members;if((!s||r)&&e&&e.length)try{await Promise.all(e.map(t=>this.server.ldap.queryLimit(async()=>{let e;try{e=await this.ldap.search({paged:!1,scope:"base"},t,r)}catch(e){if(k(e))throw e;if(s)return;const r=h(e);if(32===r)throw new A(`Member ${t} not found`);if(34===r)throw new A(`Member ${t} is not a valid DN`);throw new Error(`Member ${t} not found: ${e}`)}if(r&&!e.searchEntries?.length)throw new A(`Member ${t} not found`)})))}catch(t){if(t instanceof q||k(t))throw t;throw new Error(`Failed to find member(s): ${String(t)}`,{cause:t})}}async validateNewGroup(t,e){return[,e]=await this.hookBus.chain("ldapgroupvalidatenew",[t,e]),await this.validator().validateNew(e)}async validateChanges(t,e){return[,e]=await this.hookBus.chain("ldapgroupvalidatechanges",[t,e]),await this.validator().validateChanges(e),e}validator(){return new z(this.schema,{entryExists:async t=>{try{return(await this.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}},loadedPlugins:this.server.loadedPlugins,singularName:"group"})}getConfigApiData(){const t=this.config.api_prefix||"/api",e=this.groupSettings.group_schema?d(this.server,this.groupSettings.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.groupSettings.group_class,schema:this.schema,schemaUrl:e,endpoints:{list:`${t}/v1/ldap/groups`,get:`${t}/v1/ldap/groups/:id`,create:`${t}/v1/ldap/groups`,update:`${t}/v1/ldap/groups/:id`,delete:`${t}/v1/ldap/groups/:id`,addMember:`${t}/v1/ldap/groups/:id/members`,removeMember:`${t}/v1/ldap/groups/:id/members/:memberId`,rename:`${t}/v1/ldap/groups/:id/rename`,move:`${t}/v1/ldap/groups/:id/move`}}}}export{I as GroupMemberParams,T as GroupMembersBody,R as GroupMoveBody,U as GroupParams,W as GroupRenameBody,L as GroupsQuery,LdapGroups as default};
@@ -1 +1 @@
1
- import{Type as t}from"@sinclair/typebox";import i from"../../abstract/plugin.js";import{assertModifyRequest as e}from"../../lib/ldapActions.js";import{jsonBody as a,tryMethod as s,wantJson as n,tryMethodData as r,WriteResponse as o}from"../../lib/expressFormatedResponses.js";import{validateDnValue as h,escapeDnValue as d,getRdn as c,isDnInBranch as l,rdnValue as p,normalizeDn as g,getParentDn as u,escapeLdapFilter as m,isChildOf as w,substringSearchFilter as f}from"../../lib/utils.js";import{BadRequestError as z,NotFoundError as y,ConflictError as b,isAuthzRefusal as $,HttpError as _}from"../../lib/errors.js";import{extractLdapCode as v}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as O}from"../../lib/schemaFile.js";import{staticSchemaUrl as E}from"../../lib/staticFiles.js";import{assertClientMaySet as A,modifiedAttributeNames as C}from"../../config/schema.js";import{applyFixedDefaults as L,EntityValidator as k}from"../../lib/entitySchema/validator.js";import{organizationOptions as j}from"./ldap.options.js";import{defineRoute as S}from"../../lib/route.js";const q=t.Object({dn:t.String({minLength:1})}),x=t.Object({q:t.String({minLength:1})});class LdapOrganizations extends i{static optionDefinitions=j;name="ldapOrganizations";roles=["api","consistency","configurable"];settings;pathAttr;linkAttr;schema;constructor(t){if(super(t),this.settings=this.options(j),!this.settings.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.settings.ldap_organization_path_attribute,this.linkAttr=this.settings.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.settings.ldap_top_organization,this.settings.ldap_organization_class,!0),this.settings.organization_schema){const t=this.settings.organization_schema;O(t,{config:this.config}).then(t=>{this.schema=t,this.server.ldap.knowAttributeNames(Object.keys(t.attributes)),this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){S(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/top`,handler:async({req:t,res:i})=>{await r(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}}),S(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,params:q,handler:async({req:t,params:i,res:e})=>{const a=decodeURIComponent(i.dn);await r(e,async()=>this.hideNeverReturn(await this.getOrganisationByDn(a,t)))}}),S(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,params:q,handler:async({req:t,params:i,res:e})=>{const a=decodeURIComponent(i.dn);await r(e,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(a,t)))}}),S(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,params:q,query:x,handler:async({req:t,params:i,query:e,res:a})=>{const s=decodeURIComponent(i.dn);await r(a,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(s,e.q,t)))}}),S(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,params:q,query:x,handler:async({req:t,params:i,query:e,res:a})=>{const s=decodeURIComponent(i.dn);await r(a,async()=>this.hideNeverReturn(await this.searchOrganisationTree(s,e.q,t)))}}),S(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations`,responses:{200:o},handler:async({req:t,res:i})=>this.apiAdd(t,i)}),S(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:q,handler:async({req:t,res:i})=>this.apiModify(t,i)}),S(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:q,handler:async({req:t,res:i})=>this.apiDelete(t,i)}),S(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,responses:{200:o},params:q,handler:async({req:t,res:i})=>this.apiMove(t,i)})}async apiAdd(t,i){const e=a(t,i,"ou");if(!e)return;A(this.schema,Object.keys(e),"ou"),h(e.ou,"ou");const n=e.parentDn||this.settings.ldap_top_organization,r=`ou=${d(e.ou)},${n}`,o={ou:e.ou,...Object.fromEntries(Object.entries(e).filter(([t])=>"ou"!==t&&"parentDn"!==t)),objectClass:this.settings.ldap_organization_class};await s(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const n=a(t,i);if(!n)return;e(n);const r=decodeURIComponent(t.params.dn);if(!r)throw new z("dn is required");A(this.schema,C(n)),await s(i,this.modifyOrganization.bind(this),r,n,t)}async apiDelete(t,i){if(!n(t,i))return;const e=decodeURIComponent(t.params.dn);if(!e)throw new z("dn is required");await s(i,this.deleteOrganization.bind(this),e,t)}async apiMove(t,i){if(!n(t,i))return;const e=a(t,i,"targetOrgDn");if(!e)return;const s=decodeURIComponent(t.params.dn);if(!s)throw new z("dn is required");const{targetOrgDn:o}=e;if(!o||"string"!=typeof o)throw new z("Missing or invalid targetOrgDn in request body");await r(i,this.moveOrganization.bind(this),s,o,t)}hooks={ldapaddrequest:async([t,i,e])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==e?[t,i,e]:[t,i]),ldapmodifyrequest:async([t,i,e,a])=>{let s,n={},r={};const checkIsOu=async()=>{if(void 0!==s)return s;if(i.replace?.objectClass)s=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)s=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.system.search({paged:!1,scope:"base"},t);s=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return s};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),e=t.includes(this.linkAttr.toLowerCase()),a=t.includes(this.pathAttr.toLowerCase());if(e||a){if(!await checkIsOu()&&e)throw new z("An organization link cannot be deleted");if(a)throw new z("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(n={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(n={...n,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(n).length>0){await checkIsOu()||await this.checkDeptLink(n)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,e,a]},ldapdeleterequest:async([t,i])=>{const e=Array.isArray(t)?t:[t];for(const t of e)/^\s*ou\s*=/i.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,e])=>[t,i,e]};async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],e=Array.isArray(i)?i[0]:i,a=await this.server.ldap.system.search({paged:!1,scope:"base"},e);if(0===a.searchEntries.length)throw new y(`Organization ${e} does not exist`);if(!new RegExp(`(.*,)?${this.settings.ldap_top_organization}`).test(a.searchEntries[0].dn))throw new z(`Entry ${e} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const e=t[this.pathAttr],a=Array.isArray(e)?e[0]:e,s=this.settings.ldap_organization_path_separator,n=this.settings.ldap_top_organization,r=i&&n&&/^ou=/i.test(c(i))&&l(i,n)?p(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let d=a;if((this.isOu(t)||void 0!==r)&&h){const t=n?p(n):"";if(t&&a.startsWith(h+s)&&a.endsWith(s+t))return;if(a===h){const t=n?g(n):"";if(!i||!t||!l(i,n)||g(i)===t||g(u(i))===t)return;throw new z(`Organization path "${a}" names no parent, but ${i} is not directly under ${n}`)}if(!a.endsWith(s+h))throw new z(`Organization path must end with its own name, preceded by separator "${s}"`);d=a.slice(0,a.length-s.length-h.length)}const w=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${m(d)})`,attributes:["dn"]},this.settings.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new z(`Invalid organization path ${a}: no organization holds "${d}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.system.search({paged:!1,filter:`(${this.settings.ldap_organization_link_attribute}=${m(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.settings.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.settings.ldap_top_organization)throw new z("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.settings.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new y("Top organization not found");const e=i.searchEntries[0],[,a]=await this.hookBus.chain("getOrganisationTop",[t,e]);return a}async getOrganisationByDn(t,i){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i)}catch(i){const e=v(i);if(32===e)throw new y(`Organization ${t} not found`);if(34===e)throw new z(`${t} is not a valid DN`);throw i}if(1!==e.searchEntries.length)throw new y(`Organization ${t} not found`);return e.searchEntries[0]}async getOrganisationSubnodes(t,i){const e=[],a=this.settings.ldap_organization_max_subnodes,s=i?.query?.objectClass;s&&"organizationalUnit"!==s||e.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",a,i));const n=this.settings.ldap_top_organization,r=u(n);let o=`(${this.settings.ldap_organization_link_attribute}=${m(t)})`;return s&&(o=`(&${o}(objectClass=${m(s)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),e.push(...await this.linkedEntities(r,o,a,t,i)),e}sizeLimitWarned=new Set;static SIZE_LIMIT_WARNED_MAX=500;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))}static moreIndicator(t,i,e,a){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===a?{}:{_totalCount:a.toString()},_displayedCount:e.toString()}}async childOrganizations(t,i,e,a){try{const e=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,a),s=[];for await(const t of e)s.push(...t.searchEntries);return this.server.logger.debug(`Found ${s.length} sub-OUs for ${t}`),s}catch(i){const e=v(i);if(32===e)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(34===e)throw new z(`${t} is not a valid DN`);if(4!==e)throw i}const s=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${s.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.`),[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",s.length)]}async linkedEntities(t,i,e,a,s){const n=[];let r=0;try{const a=await this.server.ldap.search({paged:!0,filter:i},t,s);for await(const t of a){r+=t.searchEntries.length;const i=e-n.length;i>0&&n.push(...t.searchEntries.slice(0,i))}}catch(n){if(t!==a&&($(n)||403===n?.statusCode))return this.linkedEntities(a,i,e,a,s);if(4!==v(n))throw n;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:e+1},t,s);this.warnSizeLimit(`linked:${a}`,`More entries are attached to ${a} 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.`);const o=r.searchEntries.slice(0,e);return[...o,LdapOrganizations.moreIndicator(`more-${a}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${a}, returning ${n.length}`),r<=e?n:[...n,LdapOrganizations.moreIndicator(`more-${a}`,`... ${r-e} more ${r-e===1?"element":"elements"}`,e,r)]}async addOrganization(t,i,e){return L(this.schema,i),await this.server.ldap.authorize("ldapaddrequest",[t,i,e]),i=await this.validator().validateNew(i),await this.server.ldap.add(t,i,e)}async modifyOrganization(t,i,e){return await this.server.ldap.authorize("ldapmodifyrequest",[t,i,this.opNumber(),e]),await this.validator().validateChanges(i),await this.server.ldap.modify(t,i,e)}validator(){return new k(this.schema,{entryExists:t=>this.entryExists(t),loadedPlugins:this.server.loadedPlugins,singularName:"organization"})}async entryExists(t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,e){const a=`${c(t)},${i}`;let s,n;await this.server.ldap.authorize("ldaprenamerequest",[t,a,e]);try{s=await this.server.ldap.system.search({paged:!1,scope:"base"},i)}catch(t){throw new y(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new y(`Target organization ${i} not found`);if(!this.isOu(s.searchEntries[0]))throw new z(`Target ${i} is not an organizational unit`);try{n=await this.server.ldap.system.search({paged:!1,scope:"base"},t)}catch(t){throw new y(`Source organization not found: ${t}`)}if(!n.searchEntries||0===n.searchEntries.length)throw new y(`Source organization ${t} not found`);if(a===t)throw new z("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||w(i,t))throw new z("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,a,e),this.logger.info(`Moved organization from ${t} to ${a}`)}catch(t){if(t instanceof _||$(t))throw t;throw new Error(`Failed to move organization: ${String(t)}`,{cause:t})}return{newDn:a}}async deleteOrganization(t,i){try{return await this.server.ldap.delete(t,i)}catch(i){if(32===v(i))throw new y(`Organization ${t} not found`);throw i}}async searchOrganisationSubnodes(t,i,e){const a=[],s=this.settings.ldap_organization_max_subnodes;a.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${f(i,"ou,description")})`,s,e));const n=this.settings.ldap_top_organization,r=u(n),o=m(t),h=this.config.display_name_attribute||"displayName",d=`(&(${this.settings.ldap_organization_link_attribute}=${o})${f(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${d} in ${r}`),a.push(...await this.linkedEntities(r,d,s,t,e)),a}async searchOrganisationTree(t,i,e){const a=this.settings.ldap_organization_max_subnodes,s=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",n=`(&(objectClass=organizationalUnit)${f(i,s)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:n},t,e);for await(const t of i){o+=t.searchEntries.length;const i=a-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const s=v(i);if(32===s)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(34===s)throw new z(`${t} is not a valid DN`);if(4!==s)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:n,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=a)return r;const h=o-a;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,a,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.settings.organization_schema?E(this.server,this.settings.organization_schema):void 0;return{enabled:!0,topOrganization:this.settings.ldap_top_organization,organizationClass:this.settings.ldap_organization_class,linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.settings.ldap_organization_path_separator,maxSubnodes:this.settings.ldap_organization_max_subnodes,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`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{q as OrganizationParams,x as OrganizationSearchQuery,LdapOrganizations as default};
1
+ import{Type as t}from"@sinclair/typebox";import i from"../../abstract/plugin.js";import{assertModifyRequest as a}from"../../lib/ldapActions.js";import{jsonBody as e,tryMethod as s,wantJson as n,tryMethodData as r,WriteResponse as o}from"../../lib/expressFormatedResponses.js";import{validateDnValue as h,escapeDnValue as d,getRdn as c,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as u,escapeLdapFilter as m,assertRfc4514Escapes as w,isChildOf as f,substringSearchFilter as z}from"../../lib/utils.js";import{BadRequestError as y,NotFoundError as b,ConflictError as $,isAuthzRefusal as _,HttpError as v}from"../../lib/errors.js";import{extractLdapCode as O}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as E}from"../../lib/schemaFile.js";import{staticSchemaUrl as A}from"../../lib/staticFiles.js";import{assertClientMaySet as L,modifiedAttributeNames as k}from"../../config/schema.js";import{applyFixedDefaults as j,EntityValidator as C}from"../../lib/entitySchema/validator.js";import{organizationOptions as S}from"./ldap.options.js";import{defineRoute as q}from"../../lib/route.js";const x=t.Object({dn:t.String({minLength:1})}),D=t.Object({q:t.String({minLength:1})});class LdapOrganizations extends i{static optionDefinitions=S;name="ldapOrganizations";roles=["api","consistency","configurable"];settings;pathAttr;linkAttr;schema;constructor(t){if(super(t),this.settings=this.options(S),!this.settings.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.settings.ldap_organization_path_attribute,this.linkAttr=this.settings.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.settings.ldap_top_organization,this.settings.ldap_organization_class,!0),this.settings.organization_schema){const t=this.settings.organization_schema;E(t,{config:this.config}).then(t=>{this.schema=t,this.server.ldap.knowAttributeNames(Object.keys(t.attributes)),this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/top`,handler:async({req:t,res:i})=>{await r(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,params:x,handler:async({req:t,params:i,res:a})=>{const e=i.dn;await r(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,params:x,handler:async({req:t,params:i,res:a})=>{const e=i.dn;await r(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,params:x,query:D,handler:async({req:t,params:i,query:a,res:e})=>{const s=i.dn;await r(e,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(s,a.q,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,params:x,query:D,handler:async({req:t,params:i,query:a,res:e})=>{const s=i.dn;await r(e,async()=>this.hideNeverReturn(await this.searchOrganisationTree(s,a.q,t)))}}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations`,responses:{200:o},handler:async({req:t,res:i})=>this.apiAdd(t,i)}),q(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiModify(t,i)}),q(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiDelete(t,i)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiMove(t,i)})}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;L(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const n=a.parentDn||this.settings.ldap_top_organization,r=`ou=${d(a.ou)},${n}`,o={ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t)),objectClass:this.settings.ldap_organization_class};await s(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const n=e(t,i);if(!n)return;a(n);const r=t.params.dn;if(!r)throw new y("dn is required");L(this.schema,k(n)),await s(i,this.modifyOrganization.bind(this),r,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=t.params.dn;if(!a)throw new y("dn is required");await s(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!n(t,i))return;const a=e(t,i,"targetOrgDn");if(!a)return;const s=t.params.dn;if(!s)throw new y("dn is required");const{targetOrgDn:o}=a;if(!o||"string"!=typeof o)throw new y("Missing or invalid targetOrgDn in request body");await r(i,this.moveOrganization.bind(this),s,o,t)}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 s,n={},r={};const checkIsOu=async()=>{if(void 0!==s)return s;if(i.replace?.objectClass)s=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)s=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.system.search({paged:!1,scope:"base"},t);s=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return s};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 y("An organization link cannot be deleted");if(e)throw new y("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(n={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(n={...n,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(n).length>0){await checkIsOu()||await this.checkDeptLink(n)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^\s*ou\s*=/i.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]};async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.system.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new b(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.settings.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new y(`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,s=this.settings.ldap_organization_path_separator,n=this.settings.ldap_top_organization,r=i&&n&&/^ou=/i.test(c(i))&&l(i,n)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let d=e;if((this.isOu(t)||void 0!==r)&&h){const t=n?g(n):"";if(t&&e.startsWith(h+s)&&e.endsWith(s+t))return;if(e===h){const t=n?p(n):"";if(!i||!t||!l(i,n)||p(i)===t||p(u(i))===t)return;throw new y(`Organization path "${e}" names no parent, but ${i} is not directly under ${n}`)}if(!e.endsWith(s+h))throw new y(`Organization path must end with its own name, preceded by separator "${s}"`);d=e.slice(0,e.length-s.length-h.length)}const w=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${m(d)})`,attributes:["dn"]},this.settings.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new y(`Invalid organization path ${e}: no organization holds "${d}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.system.search({paged:!1,filter:`(${this.settings.ldap_organization_link_attribute}=${m(t)})`})).searchEntries.length>0)throw new $(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.settings.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.settings.ldap_top_organization)throw new y("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.settings.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new b("Top organization not found");const a=i.searchEntries[0],[,e]=await this.hookBus.chain("getOrganisationTop",[t,a]);return e}async getOrganisationByDn(t,i){let a;try{a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i)}catch(i){const a=O(i);if(32===a)throw new b(`Organization ${t} not found`);if(34===a)throw new y(`${t} is not a valid DN`);throw i}if(1!==a.searchEntries.length)throw new b(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.settings.ldap_organization_max_subnodes,s=i?.query?.objectClass;s&&"organizationalUnit"!==s||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const n=this.settings.ldap_top_organization,r=u(n);let o=`(${this.settings.ldap_organization_link_attribute}=${m(t)})`;return s&&(o=`(&${o}(objectClass=${m(s)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}sizeLimitWarned=new Set;static SIZE_LIMIT_WARNED_MAX=500;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))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),s=[];for await(const t of a)s.push(...t.searchEntries);return this.server.logger.debug(`Found ${s.length} sub-OUs for ${t}`),s}catch(i){const a=O(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(34===a)throw new y(`${t} is not a valid DN`);if(4!==a)throw i}const s=(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 ${s.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.`),[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",s.length)]}async linkedEntities(t,i,a,e,s){const n=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,s);for await(const t of e){r+=t.searchEntries.length;const i=a-n.length;i>0&&n.push(...t.searchEntries.slice(0,i))}}catch(n){if(t!==e&&(_(n)||403===n?.statusCode))return this.linkedEntities(e,i,a,e,s);if(4!==O(n))throw n;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,s);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.`);const o=r.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${n.length}`),r<=a?n:[...n,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return j(this.schema,i),await this.server.ldap.authorize("ldapaddrequest",[t,i,a]),i=await this.validator().validateNew(i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.server.ldap.authorize("ldapmodifyrequest",[t,i,this.opNumber(),a]),await this.validator().validateChanges(i),await this.server.ldap.modify(t,i,a)}validator(){return new C(this.schema,{entryExists:t=>this.entryExists(t),loadedPlugins:this.server.loadedPlugins,singularName:"organization"})}async entryExists(t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){w(i,"targetOrgDn");const e=`${c(t)},${i}`;let s,n;await this.server.ldap.authorize("ldaprenamerequest",[t,e,a]);try{s=await this.server.ldap.system.search({paged:!1,scope:"base"},i)}catch(t){throw new b(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new b(`Target organization ${i} not found`);if(!this.isOu(s.searchEntries[0]))throw new y(`Target ${i} is not an organizational unit`);try{n=await this.server.ldap.system.search({paged:!1,scope:"base"},t)}catch(t){throw new b(`Source organization not found: ${t}`)}if(!n.searchEntries||0===n.searchEntries.length)throw new b(`Source organization ${t} not found`);if(e===t)throw new y("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new y("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,e,a),this.logger.info(`Moved organization from ${t} to ${e}`)}catch(t){if(t instanceof v||_(t))throw t;throw new Error(`Failed to move organization: ${String(t)}`,{cause:t})}return{newDn:e}}async deleteOrganization(t,i){try{return await this.server.ldap.delete(t,i)}catch(i){if(32===O(i))throw new b(`Organization ${t} not found`);throw i}}async searchOrganisationSubnodes(t,i,a){const e=[],s=this.settings.ldap_organization_max_subnodes;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${z(i,"ou,description")})`,s,a));const n=this.settings.ldap_top_organization,r=u(n),o=m(t),h=this.config.display_name_attribute||"displayName",d=`(&(${this.settings.ldap_organization_link_attribute}=${o})${z(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${d} in ${r}`),e.push(...await this.linkedEntities(r,d,s,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.settings.ldap_organization_max_subnodes,s=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",n=`(&(objectClass=organizationalUnit)${z(i,s)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:n},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const s=O(i);if(32===s)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(34===s)throw new y(`${t} is not a valid DN`);if(4!==s)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:n,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=e)return r;const h=o-e;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.settings.organization_schema?A(this.server,this.settings.organization_schema):void 0;return{enabled:!0,topOrganization:this.settings.ldap_top_organization,organizationClass:this.settings.ldap_organization_class,linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.settings.ldap_organization_path_separator,maxSubnodes:this.settings.ldap_organization_max_subnodes,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`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{x as OrganizationParams,D as OrganizationSearchQuery,LdapOrganizations as default};
@@ -1 +1 @@
1
- import{isDeepStrictEqual as e}from"node:util";import{Type as t}from"@sinclair/typebox";import i from"../../abstract/plugin.js";import{normalizeDn as s,escapeLdapFilter as a,validateDnValue as r,escapeDnValue as n}from"../../lib/utils.js";import{jsonBody as o}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as c}from"../../lib/ldapCodes.js";import{passwordPolicyOptions as d}from"./ldap.options.js";import{defineRoute as l}from"../../lib/route.js";import{NotFoundError as p,ConflictError as h,BadRequestError as u}from"../../lib/errors.js";const w=["pwdChangedTime","pwdAccountLockedTime","pwdFailureTime","pwdGraceUseTime","pwdReset","pwdPolicySubentry"],y={pwdMinAge:"integer",pwdMaxAge:"integer",pwdInHistory:"integer",pwdCheckQuality:"integer",pwdMinLength:"integer",pwdMaxLength:"integer",pwdExpireWarning:"integer",pwdGraceAuthNLimit:"integer",pwdGraceExpiry:"integer",pwdLockout:"boolean",pwdLockoutDuration:"integer",pwdMaxFailure:"integer",pwdFailureCountInterval:"integer",pwdMustChange:"boolean",pwdAllowUserChange:"boolean",pwdSafeModify:"boolean",pwdMinDelay:"integer",pwdMaxDelay:"integer",pwdMaxIdle:"integer",pwdMaxRecordedFailure:"integer",pwdCheckModuleArg:"text",pwdUseCheckModule:"boolean"},g=["pwdCheckModuleArg","pwdUseCheckModule"],m=[...Object.keys(y),"pwdAttribute","pwdCheckModule"],isWritablePolicyAttribute=e=>Object.hasOwn(y,e),values=e=>(Array.isArray(e)?e:void 0===e?[]:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>""!==e),first=e=>values(e)[0],userEntry=e=>({dn:e.dn,userPassword:first(e.userPassword),uid:first(e.uid),cn:first(e.cn),displayName:first(e.displayName),mail:first(e.mail),pwdChangedTime:first(e.pwdChangedTime),pwdAccountLockedTime:first(e.pwdAccountLockedTime),pwdFailureTime:values(e.pwdFailureTime),pwdGraceUseTime:values(e.pwdGraceUseTime),pwdReset:first(e.pwdReset),pwdPolicySubentry:first(e.pwdPolicySubentry)}),typedPolicy=e=>{const t={};for(const i of m){const s=values(e[i]);if(0!==s.length)switch(y[i]){case"integer":{const e=parseInt(s[0],10);isNaN(e)||(t[i]=e);break}case"boolean":t[i]="TRUE"===s[0];break;default:t[i]=1===s.length?s[0]:s}}return t},f=/[!@#$%^&*()_+\-=[\]{}|;:'",.<>?/]/,b=t.Object({cn:t.String({minLength:1})}),P=t.Object({id:t.String({minLength:1})}),A=t.Object({days:t.Optional(t.Integer({minimum:1}))}),C=t.Object({policy:t.Union([t.String({minLength:1}),t.Null()])}),$=t.Object({success:t.Boolean(),message:t.String()}),_=t.Object({password:t.String({minLength:1})});class PasswordPolicy extends i{static optionDefinitions=d;name="ldapPasswordPolicy";roles=["api","configurable"];policyCache=new Map;discoveredDefault=null;warnedAmbiguousDefault=!1;warnedMissingBase=!1;warnedUnreadableSchema=!1;POLICY_CACHE_TTL=6e4;get settings(){return this.options(d)}api(e){const t=`${this.config.api_prefix}/v1`;l(e,{method:"get",path:`${t}/password-policy`,handler:async({req:e,res:t})=>{const i=await this.readPolicyConfig();if(i.dn)try{await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["1.1"],paged:!1},i.dn,e)}catch(e){if(32!==c(e))throw e;return this.forgetPolicies(),void t.json({})}t.json(i)}}),l(e,{method:"get",path:`${t}/users/:id/password-status`,params:P,handler:async({params:e,req:t,res:i})=>{const s=await this.getPasswordStatus(e.id,t);i.json(s)}}),l(e,{method:"post",path:`${t}/users/:id/unlock`,params:P,responses:{200:$},handler:async({params:e,req:t,res:i})=>{await this.unlockAccount(e.id,t),i.json({success:!0,message:"Account unlocked"})}}),l(e,{method:"get",path:`${t}/password-policy/expiring-soon`,query:A,handler:async({req:e,res:t,query:i})=>{const s=i.days||this.settings.ppolicy_warn_days,a=await this.findExpiringPasswords(s,e);t.json({warningDays:s,users:a})}}),l(e,{method:"get",path:`${t}/password-policy/locked-accounts`,handler:async({req:e,res:t})=>{const i=await this.findLockedAccounts(e);t.json({accounts:i})}}),l(e,{method:"get",path:`${t}/password-policies`,handler:async({req:e,res:t})=>{t.json({policies:await this.listPolicies(e),attributes:await this.definedAttributes()})}}),l(e,{method:"post",path:`${t}/password-policies`,handler:async({req:e,res:t})=>{const i=o(e,t);if(!i)return;const s=await this.createPolicy(i,e);t.status(201).json(s)}}),l(e,{method:"get",path:`${t}/password-policies/:cn`,params:b,handler:async({params:e,req:t,res:i})=>{const s=await this.findPolicy(e.cn,t);i.json(await this.toPolicyRecord(s))}}),l(e,{method:"patch",path:`${t}/password-policies/:cn`,params:b,handler:async({params:e,req:t,res:i})=>{const s=o(t,i);if(!s)return;const a=await this.updatePolicy(e.cn,s,t);i.json(a)}}),l(e,{method:"delete",path:`${t}/password-policies/:cn`,params:b,handler:async({params:e,req:t,res:i})=>{await this.deletePolicy(e.cn,t),i.json({success:!0})}}),l(e,{method:"put",path:`${t}/users/:id/password-policy`,params:P,body:C,handler:async({params:e,req:t,res:i,body:s})=>{i.json(await this.assignPolicy(e.id,s.policy,t))}}),this.settings.ppolicy_validate_complexity&&l(e,{method:"post",path:`${t}/password/validate`,body:_,handler:({res:e,body:t})=>{e.json(this.validateComplexity(t.password))}})}async getPasswordStatus(e,t){const i=this.resolveUserDn(e),s=await this.readUser(i,e,["*",...w],t),a=await this.policyFor(s);return this.calculateStatus(i,s,a)}async readUser(e,t,i,s){try{const a=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:i,paged:!1},e,s);if(!a.searchEntries||0===a.searchEntries.length)throw new p(`User not found: ${t}`);return userEntry(a.searchEntries[0])}catch(e){if(e instanceof Error&&("NoSuchObjectError"===e.name||e.message.includes("0x20")||e.message.includes("Code: 32")))throw new p(`User not found: ${t}`);throw e}}calculateStatus(e,t,i){const s=Date.now(),a=this.parseGeneralizedTime(t.pwdChangedTime);let r=null,n=!1,o=null;i.pwdMaxAge&&a&&(r=new Date(a.getTime()+1e3*i.pwdMaxAge),n=s>r.getTime(),o=Math.ceil((r.getTime()-s)/864e5));let c=!1;const d=this.settings.ppolicy_warn_days;null!==o&&o>0&&(c=o<=d);const l=t.pwdAccountLockedTime;let p=!!l;if(p&&!("000001010000Z"===l)&&i.pwdLockoutDuration){const e=this.parseGeneralizedTime(l);if(e){s>new Date(e.getTime()+1e3*i.pwdLockoutDuration).getTime()&&(p=!1)}}const h=t.pwdFailureTime,u=Array.isArray(h)?h.length:0,w=t.pwdGraceUseTime,y=Array.isArray(w)?w.length:0,g="TRUE"===t.pwdReset;return{dn:e,passwordSet:!!t.userPassword,lastChanged:a?.toISOString()||null,expiresAt:r?.toISOString()||null,daysUntilExpiration:o,isExpired:n,isExpiringSoon:c,mustChange:g,isLocked:p,lockedAt:l&&this.parseGeneralizedTime(l)?.toISOString()||null,failureCount:u,graceLoginsUsed:y,policy:i.dn??null}}async unlockAccount(e,t){const i=this.resolveUserDn(e),s=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["pwdAccountLockedTime","pwdFailureTime"],paged:!1},i,t).catch(t=>{if(32===c(t))throw new p(`User not found: ${e}`);throw t});if(!s.searchEntries||0===s.searchEntries.length)throw new p(`User not found: ${e}`);const a=userEntry(s.searchEntries[0]),r={};a.pwdAccountLockedTime&&(r.pwdAccountLockedTime=[]),a.pwdFailureTime?.length&&(r.pwdFailureTime=[]),Object.keys(r).length>0?(await this.server.ldap.modify(i,{delete:r},t),this.logger.info(`Account unlocked: ${i}`)):this.logger.info(`Account was not locked: ${i}`)}async findExpiringPasswords(e,t){const i=this.settings.ldap_users_base||this.config.ldap_base,s=await this.server.ldap.search({filter:"(pwdChangedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdChangedTime","pwdPolicySubentry"],paged:!1},i,t),a=Date.now(),r=24*e*60*60*1e3,n=[];for(const e of s.searchEntries){const t=userEntry(e),i=this.parseGeneralizedTime(t.pwdChangedTime);if(!i)continue;const s=await this.policyFor(t);if(!s.pwdMaxAge)continue;const o=new Date(i.getTime()+1e3*s.pwdMaxAge),c=o.getTime()-a;c>0&&c<r&&n.push({dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,expiresAt:o.toISOString(),daysUntilExpiration:Math.ceil(c/864e5)})}return n.sort((e,t)=>e.daysUntilExpiration-t.daysUntilExpiration)}async findLockedAccounts(e){const t=this.settings.ldap_users_base||this.config.ldap_base;return(await this.server.ldap.search({filter:"(pwdAccountLockedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdAccountLockedTime","pwdFailureTime"],paged:!1},t,e)).searchEntries.map(e=>{const t=userEntry(e);return{dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,lockedAt:this.parseGeneralizedTime(t.pwdAccountLockedTime)?.toISOString(),failureCount:Array.isArray(t.pwdFailureTime)?t.pwdFailureTime.length:0}})}async policyFor(e){if(e.pwdPolicySubentry){const t=await this.readPolicyAt(e.pwdPolicySubentry);if(t)return t}return this.readPolicyConfig()}async readPolicyConfig(){const e=this.settings.ppolicy_default_dn||await this.discoverDefaultDn();return(e?await this.readPolicyAt(e):null)??{}}async discoverDefaultDn(){if(this.discoveredDefault&&Date.now()-this.discoveredDefault.time<this.POLICY_CACHE_TTL)return this.discoveredDefault.dn;let e=null;try{const t=(await this.server.ldap.system.search({filter:"(objectClass=pwdPolicy)",scope:"sub",attributes:["1.1"],paged:!1},this.policyBase())).searchEntries.map(e=>e.dn);1===t.length?e=t[0]:t.length>1&&!this.warnedAmbiguousDefault&&(this.warnedAmbiguousDefault=!0,this.logger.warn(`${t.length} password policies found and no --ppolicy-default-dn: accounts without pwdPolicySubentry are reported without a policy`))}catch(e){this.logger.debug(`No ppolicy found: ${e}`)}return this.discoveredDefault={dn:e,time:Date.now()},e}async readPolicyAt(e){const t=s(e),i=this.policyCache.get(t);if(i&&Date.now()-i.time<this.POLICY_CACHE_TTL)return i.policy;let a,r=null;try{const t=await this.server.ldap.system.search({filter:"(objectClass=pwdPolicy)",scope:"base",attributes:m,paged:!1},e);t.searchEntries.length>0&&(r=((e,t)=>{const i=typedPolicy(e);return{dn:t,pwdMaxAge:i.pwdMaxAge,pwdMinAge:i.pwdMinAge,pwdInHistory:i.pwdInHistory,pwdCheckQuality:i.pwdCheckQuality,pwdMinLength:i.pwdMinLength,pwdMaxFailure:i.pwdMaxFailure,pwdLockout:!0===i.pwdLockout,pwdLockoutDuration:i.pwdLockoutDuration,pwdGraceAuthNLimit:i.pwdGraceAuthNLimit,pwdExpireWarning:i.pwdExpireWarning,pwdMustChange:!0===i.pwdMustChange,pwdAllowUserChange:!1!==i.pwdAllowUserChange}})(t.searchEntries[0],e))}catch(e){a=String(e)}const n=this.settings.ppolicy_default_dn;return!r&&n&&s(n)===t?this.logger.warn(`--ppolicy-default-dn ${e} holds no password policy${a?`: ${a}`:""}`):a&&this.logger.debug(`Failed to read ppolicy from ${e}: ${a}`),this.policyCache.set(t,{policy:r,time:Date.now()}),r}forgetPolicies(){this.policyCache.clear(),this.discoveredDefault=null}policyBase(){return this.settings.ppolicy_base||this.config.ldap_base||""}async searchPolicies(e,t){try{return(await this.server.ldap.search({filter:`(&(objectClass=pwdPolicy)${e})`,scope:"sub",attributes:["objectClass","cn",...m],paged:!1},this.policyBase(),t)).searchEntries}catch(e){if(32!==c(e))throw e;return this.warnedMissingBase||(this.warnedMissingBase=!0,this.logger.warn(`${this.policyBase()} does not exist: no password policy is found there, check --ppolicy-base`)),[]}}async findPolicy(e,t){const i=await this.searchPolicies(`(cn=${a(e)})`,t);if(0===i.length)throw new p(`No password policy named ${e}`);if(i.length>1)throw new h(`Several password policies are named ${e}`);return i[0]}async listPolicies(e){const t=[];for(const i of await this.searchPolicies("",e))t.push(await this.toPolicyRecord(i));return t.sort((e,t)=>e.cn.localeCompare(t.cn))}async definedAttributes(){const e=Object.keys(y);try{const t=await this.server.ldap.schemaIndex();return e.filter(e=>t.getAttributeType(e))}catch(t){return this.warnedUnreadableSchema||(this.warnedUnreadableSchema=!0,this.logger.warn(`Unable to read the schema, every policy attribute is listed: ${String(t)}`)),e}}async toPolicyRecord(e){const t=(await this.readPolicyConfig()).dn;return{...{dn:e.dn,cn:values(e.cn)[0]??"",isDefault:!!t&&s(t)===s(e.dn)},...typedPolicy(e)}}policyValue(e,t){switch(y[e]){case"integer":if("number"!=typeof t||!Number.isInteger(t)||t<0||"pwdCheckQuality"===e&&t>2)throw new u("pwdCheckQuality"===e?"pwdCheckQuality must be 0, 1 or 2":`${e} must be a non-negative integer`);return String(t);case"boolean":if("boolean"!=typeof t)throw new u(`${e} must be a boolean`);return t?"TRUE":"FALSE";default:if("string"!=typeof t||""===t)throw new u(`${e} must be a non-empty string`);return t}}async createPolicy(e,t){const{cn:i,...s}=e;if("string"!=typeof i)throw new u("cn required");try{r(i,"cn")}catch(e){throw new u(e.message)}const a=await this.findPolicy(i,t).catch(e=>{if(e instanceof p)return null;throw e});if(a)throw new h(`A password policy is named ${i}: ${a.dn}`);const o=["top","person","pwdPolicy"],d={objectClass:o,cn:i,sn:i,pwdAttribute:"userPassword"};for(const[e,t]of Object.entries(s)){if(!isWritablePolicyAttribute(e))throw new u(`${e} cannot be written`);null!==t&&(d[e]=this.policyValue(e,t))}g.some(e=>e in d)&&o.push("pwdPolicyChecker");try{await this.server.ldap.add(`cn=${n(i)},${this.policyBase()}`,d,t)}catch(e){if(32!==c(e))throw e;throw new p(`${this.policyBase()} does not exist: create it or change --ppolicy-base`)}return this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(i,t))}async updatePolicy(t,i,a){const r=await this.findPolicy(t,a),n=await this.toPolicyRecord(r),o={},c=[];for(const[t,a]of Object.entries(i)){if(!isWritablePolicyAttribute(t)){if("dn"===t?"string"==typeof a&&s(a)===s(n.dn):Object.hasOwn(n,t)&&e(n[t],a))continue;throw new u(`${t} cannot be written`)}null!==a?o[t]=this.policyValue(t,a):void 0!==n[t]&&c.push(t)}const d={};if(Object.keys(o).length>0&&(d.replace=o),c.length>0&&(d.delete=c),!d.replace&&!d.delete)return n;const l=values(r.objectClass).map(e=>e.toLowerCase());return g.some(e=>e in o)&&!l.includes("pwdpolicychecker")&&(d.add={objectClass:"pwdPolicyChecker"}),await this.server.ldap.modify(r.dn,d,a),this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(t,a))}async deletePolicy(e,t){const i=await this.findPolicy(e,t);await this.server.ldap.authorize("ldapdeleterequest",[i.dn,t]);const r=(await this.readPolicyConfig()).dn;if(r&&s(r)===s(i.dn))throw new h(`${i.dn} is the default password policy`);if((await this.server.ldap.system.search({filter:`(pwdPolicySubentry=${a(i.dn)})`,scope:"sub",attributes:["1.1"],sizeLimit:1,paged:!1},this.config.ldap_base||"")).searchEntries.length>0)throw new h(`${i.dn} is still applied to at least one entry`);await this.server.ldap.delete(i.dn,t),this.forgetPolicies()}async assignPolicy(e,t,i){const s=this.resolveUserDn(e);if(null===t){try{await this.server.ldap.modify(s,{delete:["pwdPolicySubentry"]},i)}catch(e){if(16!==c(e))throw e}return{dn:s,policy:null}}const a=await this.findPolicy(t,i);return await this.server.ldap.modify(s,{replace:{pwdPolicySubentry:a.dn}},i),{dn:s,policy:a.dn}}parseGeneralizedTime(e){if(!e)return null;const t=e.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/);if(!t)return null;const[,i,s,a,r,n,o]=t;return new Date(Date.UTC(+i,+s-1,+a,+r,+n,+o))}validateComplexity(e){const t=[],i=this.settings,s=i.ppolicy_min_length;return e.length<s&&t.push(`Minimum ${s} characters required`),i.ppolicy_require_uppercase&&!/[A-Z]/.test(e)&&t.push("At least one uppercase letter required"),i.ppolicy_require_lowercase&&!/[a-z]/.test(e)&&t.push("At least one lowercase letter required"),i.ppolicy_require_digit&&!/\d/.test(e)&&t.push("At least one digit required"),i.ppolicy_require_special&&!f.test(e)&&t.push("At least one special character required"),{valid:0===t.length,errors:t}}resolveUserDn(e){if(e.includes("="))return e;const t=this.settings.ldap_users_base||this.config.ldap_base;return`${this.config.ldap_user_main_attribute||"uid"}=${n(e)},${t}`}getConfigApiData(){const e=this.config.api_prefix||"/api",t=!!this.settings.ppolicy_validate_complexity;return{name:this.name,enabled:!0,endpoints:{getPolicy:`${e}/v1/password-policy`,getUserStatus:`${e}/v1/users/:id/password-status`,unlockUser:`${e}/v1/users/:id/unlock`,getExpiringSoon:`${e}/v1/password-policy/expiring-soon`,getLockedAccounts:`${e}/v1/password-policy/locked-accounts`,listPolicies:`${e}/v1/password-policies`,policy:`${e}/v1/password-policies/:cn`,assignPolicy:`${e}/v1/users/:id/password-policy`,validatePassword:t?`${e}/v1/password/validate`:void 0},config:{warnDays:this.settings.ppolicy_warn_days,validateComplexity:t,...t&&{complexityRules:{minLength:this.settings.ppolicy_min_length,requireUppercase:this.settings.ppolicy_require_uppercase,requireLowercase:this.settings.ppolicy_require_lowercase,requireDigit:this.settings.ppolicy_require_digit,requireSpecial:this.settings.ppolicy_require_special}}}}}}export{C as AssignPolicyBody,A as ExpiringQuery,b as PolicyParams,P as PolicyUserParams,$ as UnlockResponse,_ as ValidatePasswordBody,PasswordPolicy as default};
1
+ import{isDeepStrictEqual as e}from"node:util";import{Type as t}from"@sinclair/typebox";import i from"../../abstract/plugin.js";import{normalizeDn as s,escapeLdapFilter as a,validateDnValue as r,escapeDnValue as o,parseDirectoryDate as n}from"../../lib/utils.js";import{jsonBody as c}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as d}from"../../lib/ldapCodes.js";import{passwordPolicyOptions as l}from"./ldap.options.js";import{defineRoute as p}from"../../lib/route.js";import{NotFoundError as h,ConflictError as u,BadRequestError as w}from"../../lib/errors.js";const y=["pwdChangedTime","pwdAccountLockedTime","pwdFailureTime","pwdGraceUseTime","pwdReset","pwdPolicySubentry"],g={pwdMinAge:"integer",pwdMaxAge:"integer",pwdInHistory:"integer",pwdCheckQuality:"integer",pwdMinLength:"integer",pwdMaxLength:"integer",pwdExpireWarning:"integer",pwdGraceAuthNLimit:"integer",pwdGraceExpiry:"integer",pwdLockout:"boolean",pwdLockoutDuration:"integer",pwdMaxFailure:"integer",pwdFailureCountInterval:"integer",pwdMustChange:"boolean",pwdAllowUserChange:"boolean",pwdSafeModify:"boolean",pwdMinDelay:"integer",pwdMaxDelay:"integer",pwdMaxIdle:"integer",pwdMaxRecordedFailure:"integer",pwdCheckModuleArg:"text",pwdUseCheckModule:"boolean"},m=Number.MAX_SAFE_INTEGER,f=["pwdCheckModuleArg","pwdUseCheckModule"],b=[...Object.keys(g),"pwdAttribute","pwdCheckModule"],isWritablePolicyAttribute=e=>Object.hasOwn(g,e),values=e=>(Array.isArray(e)?e:void 0===e?[]:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>""!==e),first=e=>values(e)[0],userEntry=e=>({dn:e.dn,userPassword:first(e.userPassword),uid:first(e.uid),cn:first(e.cn),displayName:first(e.displayName),mail:first(e.mail),pwdChangedTime:first(e.pwdChangedTime),pwdAccountLockedTime:first(e.pwdAccountLockedTime),pwdFailureTime:values(e.pwdFailureTime),pwdGraceUseTime:values(e.pwdGraceUseTime),pwdReset:first(e.pwdReset),pwdPolicySubentry:first(e.pwdPolicySubentry)}),typedPolicy=e=>{const t={};for(const i of b){const s=values(e[i]);if(0!==s.length)switch(g[i]){case"integer":{const e=parseInt(s[0],10);isNaN(e)||(t[i]=e);break}case"boolean":t[i]="TRUE"===s[0];break;default:t[i]=1===s.length?s[0]:s}}return t},P=/[!@#$%^&*()_+\-=[\]{}|;:'",.<>?/]/,A="000001010000Z",C=t.Object({cn:t.String({minLength:1})}),$=t.Object({id:t.String({minLength:1})}),_=t.Object({days:t.Optional(t.Integer({minimum:1}))}),v=t.Object({policy:t.Union([t.String({minLength:1}),t.Null()])}),T=t.Object({success:t.Boolean(),message:t.String()}),k=t.Object({password:t.String({minLength:1})});class PasswordPolicy extends i{static optionDefinitions=l;name="ldapPasswordPolicy";roles=["api","configurable"];policyCache=new Map;discoveredDefault=null;warnedAmbiguousDefault=!1;warnedMissingBase=!1;warnedUnreadableSchema=!1;POLICY_CACHE_TTL=6e4;get settings(){return this.options(l)}api(e){const t=`${this.config.api_prefix}/v1`;p(e,{method:"get",path:`${t}/password-policy`,handler:async({req:e,res:t})=>{const i=await this.readPolicyConfig();if(i.dn)try{await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["1.1"],paged:!1},i.dn,e)}catch(e){if(32!==d(e))throw e;return this.forgetPolicies(),void t.json({})}t.json(i)}}),p(e,{method:"get",path:`${t}/users/:id/password-status`,params:$,handler:async({params:e,req:t,res:i})=>{const s=await this.getPasswordStatus(e.id,t);i.json(s)}}),p(e,{method:"post",path:`${t}/users/:id/unlock`,params:$,responses:{200:T},handler:async({params:e,req:t,res:i})=>{await this.unlockAccount(e.id,t),i.json({success:!0,message:"Account unlocked"})}}),p(e,{method:"get",path:`${t}/password-policy/expiring-soon`,query:_,handler:async({req:e,res:t,query:i})=>{const s=i.days||this.settings.ppolicy_warn_days,a=await this.findExpiringPasswords(s,e);t.json({warningDays:s,users:a})}}),p(e,{method:"get",path:`${t}/password-policy/locked-accounts`,handler:async({req:e,res:t})=>{const i=await this.findLockedAccounts(e);t.json({accounts:i})}}),p(e,{method:"get",path:`${t}/password-policies`,handler:async({req:e,res:t})=>{t.json({policies:await this.listPolicies(e),attributes:await this.definedAttributes()})}}),p(e,{method:"post",path:`${t}/password-policies`,handler:async({req:e,res:t})=>{const i=c(e,t);if(!i)return;const s=await this.createPolicy(i,e);t.status(201).json(s)}}),p(e,{method:"get",path:`${t}/password-policies/:cn`,params:C,handler:async({params:e,req:t,res:i})=>{const s=await this.findPolicy(e.cn,t);i.json(await this.toPolicyRecord(s))}}),p(e,{method:"patch",path:`${t}/password-policies/:cn`,params:C,handler:async({params:e,req:t,res:i})=>{const s=c(t,i);if(!s)return;const a=await this.updatePolicy(e.cn,s,t);i.json(a)}}),p(e,{method:"delete",path:`${t}/password-policies/:cn`,params:C,handler:async({params:e,req:t,res:i})=>{await this.deletePolicy(e.cn,t),i.json({success:!0})}}),p(e,{method:"put",path:`${t}/users/:id/password-policy`,params:$,body:v,handler:async({params:e,req:t,res:i,body:s})=>{i.json(await this.assignPolicy(e.id,s.policy,t))}}),this.settings.ppolicy_validate_complexity&&p(e,{method:"post",path:`${t}/password/validate`,body:k,handler:({res:e,body:t})=>{e.json(this.validateComplexity(t.password))}})}async getPasswordStatus(e,t){const i=this.resolveUserDn(e),s=await this.readUser(i,e,["*",...y],t),a=await this.policyFor(s);return this.calculateStatus(i,s,a)}async readUser(e,t,i,s){try{const a=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:i,paged:!1},e,s);if(!a.searchEntries||0===a.searchEntries.length)throw new h(`User not found: ${t}`);return userEntry(a.searchEntries[0])}catch(e){if(e instanceof Error&&("NoSuchObjectError"===e.name||e.message.includes("0x20")||e.message.includes("Code: 32")))throw new h(`User not found: ${t}`);throw e}}calculateStatus(e,t,i){const s=Date.now(),a=this.parsePolicyTime(t.pwdChangedTime);let r=null,o=!1,n=null;if(i.pwdMaxAge&&a){const e=new Date(a.getTime()+1e3*i.pwdMaxAge);Number.isNaN(e.getTime())||(r=e,o=s>e.getTime(),n=Math.ceil((e.getTime()-s)/864e5))}let c=!1;const d=this.settings.ppolicy_warn_days;null!==n&&n>0&&(c=n<=d);const l=t.pwdAccountLockedTime;let p=!!l;const h=l===A;if(p&&!h&&i.pwdLockoutDuration){const e=this.parsePolicyTime(l);if(e){s>new Date(e.getTime()+1e3*i.pwdLockoutDuration).getTime()&&(p=!1)}}const u=t.pwdFailureTime,w=Array.isArray(u)?u.length:0,y=t.pwdGraceUseTime,g=Array.isArray(y)?y.length:0,m="TRUE"===t.pwdReset;return{dn:e,passwordSet:!!t.userPassword,lastChanged:a?.toISOString()||null,expiresAt:r?.toISOString()||null,daysUntilExpiration:n,isExpired:o,isExpiringSoon:c,mustChange:m,isLocked:p,lockedAt:l&&!h&&this.parsePolicyTime(l)?.toISOString()||null,failureCount:w,graceLoginsUsed:g,policy:i.dn??null}}async unlockAccount(e,t){const i=this.resolveUserDn(e),s=await this.server.ldap.search({filter:"(objectClass=*)",scope:"base",attributes:["pwdAccountLockedTime","pwdFailureTime"],paged:!1},i,t).catch(t=>{if(32===d(t))throw new h(`User not found: ${e}`);throw t});if(!s.searchEntries||0===s.searchEntries.length)throw new h(`User not found: ${e}`);const a=userEntry(s.searchEntries[0]),r={};a.pwdAccountLockedTime&&(r.pwdAccountLockedTime=[]),a.pwdFailureTime?.length&&(r.pwdFailureTime=[]),Object.keys(r).length>0?(await this.server.ldap.modify(i,{delete:r},t),this.logger.info(`Account unlocked: ${i}`)):this.logger.info(`Account was not locked: ${i}`)}async findExpiringPasswords(e,t){const i=this.settings.ldap_users_base||this.config.ldap_base,s=await this.server.ldap.search({filter:"(pwdChangedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdChangedTime","pwdPolicySubentry"],paged:!1},i,t),a=Date.now(),r=24*e*60*60*1e3,o=[];for(const e of s.searchEntries){const t=userEntry(e),i=this.parsePolicyTime(t.pwdChangedTime);if(!i)continue;const s=await this.policyFor(t);if(!s.pwdMaxAge)continue;const n=new Date(i.getTime()+1e3*s.pwdMaxAge),c=n.getTime()-a;c>0&&c<r&&o.push({dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,expiresAt:n.toISOString(),daysUntilExpiration:Math.ceil(c/864e5)})}return o.sort((e,t)=>e.daysUntilExpiration-t.daysUntilExpiration)}async findLockedAccounts(e){const t=this.settings.ldap_users_base||this.config.ldap_base;return(await this.server.ldap.search({filter:"(pwdAccountLockedTime=*)",scope:"sub",attributes:["uid","cn","displayName","mail","pwdAccountLockedTime","pwdFailureTime"],paged:!1},t,e)).searchEntries.map(e=>{const t=userEntry(e);return{dn:t.dn,uid:t.uid,displayName:t.displayName||t.cn,mail:t.mail,lockedAt:t.pwdAccountLockedTime===A?void 0:this.parsePolicyTime(t.pwdAccountLockedTime)?.toISOString(),failureCount:Array.isArray(t.pwdFailureTime)?t.pwdFailureTime.length:0}})}async policyFor(e){if(e.pwdPolicySubentry){const t=await this.readPolicyAt(e.pwdPolicySubentry);if(t)return t}return this.readPolicyConfig()}async readPolicyConfig(){const e=this.settings.ppolicy_default_dn||await this.discoverDefaultDn();return(e?await this.readPolicyAt(e):null)??{}}async discoverDefaultDn(){if(this.discoveredDefault&&Date.now()-this.discoveredDefault.time<this.POLICY_CACHE_TTL)return this.discoveredDefault.dn;let e=null;try{const t=(await this.server.ldap.system.search({filter:"(objectClass=pwdPolicy)",scope:"sub",attributes:["1.1"],paged:!1},this.policyBase())).searchEntries.map(e=>e.dn);1===t.length?e=t[0]:t.length>1&&!this.warnedAmbiguousDefault&&(this.warnedAmbiguousDefault=!0,this.logger.warn(`${t.length} password policies found and no --ppolicy-default-dn: accounts without pwdPolicySubentry are reported without a policy`))}catch(e){this.logger.debug(`No ppolicy found: ${e}`)}return this.discoveredDefault={dn:e,time:Date.now()},e}async readPolicyAt(e){const t=s(e),i=this.policyCache.get(t);if(i&&Date.now()-i.time<this.POLICY_CACHE_TTL)return i.policy;let a,r=null;try{const t=await this.server.ldap.system.search({filter:"(objectClass=pwdPolicy)",scope:"base",attributes:b,paged:!1},e);t.searchEntries.length>0&&(r=((e,t)=>{const i=typedPolicy(e);return{dn:t,pwdMaxAge:i.pwdMaxAge,pwdMinAge:i.pwdMinAge,pwdInHistory:i.pwdInHistory,pwdCheckQuality:i.pwdCheckQuality,pwdMinLength:i.pwdMinLength,pwdMaxFailure:i.pwdMaxFailure,pwdLockout:!0===i.pwdLockout,pwdLockoutDuration:i.pwdLockoutDuration,pwdGraceAuthNLimit:i.pwdGraceAuthNLimit,pwdExpireWarning:i.pwdExpireWarning,pwdMustChange:!0===i.pwdMustChange,pwdAllowUserChange:!1!==i.pwdAllowUserChange}})(t.searchEntries[0],e))}catch(e){a=String(e)}const o=this.settings.ppolicy_default_dn;return!r&&o&&s(o)===t?this.logger.warn(`--ppolicy-default-dn ${e} holds no password policy${a?`: ${a}`:""}`):a&&this.logger.debug(`Failed to read ppolicy from ${e}: ${a}`),this.policyCache.set(t,{policy:r,time:Date.now()}),r}forgetPolicies(){this.policyCache.clear(),this.discoveredDefault=null}policyBase(){return this.settings.ppolicy_base||this.config.ldap_base||""}async searchPolicies(e,t){try{return(await this.server.ldap.search({filter:`(&(objectClass=pwdPolicy)${e})`,scope:"sub",attributes:["objectClass","cn",...b],paged:!1},this.policyBase(),t)).searchEntries}catch(e){if(32!==d(e))throw e;return this.warnedMissingBase||(this.warnedMissingBase=!0,this.logger.warn(`${this.policyBase()} does not exist: no password policy is found there, check --ppolicy-base`)),[]}}async findPolicy(e,t){const i=await this.searchPolicies(`(cn=${a(e)})`,t);if(0===i.length)throw new h(`No password policy named ${e}`);if(i.length>1)throw new u(`Several password policies are named ${e}`);return i[0]}async listPolicies(e){const t=[];for(const i of await this.searchPolicies("",e))t.push(await this.toPolicyRecord(i));return t.sort((e,t)=>e.cn.localeCompare(t.cn))}async definedAttributes(){const e=Object.keys(g);try{const t=await this.server.ldap.schemaIndex();return e.filter(e=>t.getAttributeType(e))}catch(t){return this.warnedUnreadableSchema||(this.warnedUnreadableSchema=!0,this.logger.warn(`Unable to read the schema, every policy attribute is listed: ${String(t)}`)),e}}async toPolicyRecord(e){const t=(await this.readPolicyConfig()).dn;return{...{dn:e.dn,cn:values(e.cn)[0]??"",isDefault:!!t&&s(t)===s(e.dn)},...typedPolicy(e)}}policyValue(e,t){switch(g[e]){case"integer":if("number"!=typeof t||!Number.isSafeInteger(t)||t<0||"pwdCheckQuality"===e&&t>2)throw new w("pwdCheckQuality"===e?"pwdCheckQuality must be 0, 1 or 2":`${e} must be an integer between 0 and ${m}`);return String(t);case"boolean":if("boolean"!=typeof t)throw new w(`${e} must be a boolean`);return t?"TRUE":"FALSE";default:if("string"!=typeof t||""===t)throw new w(`${e} must be a non-empty string`);return t}}async createPolicy(e,t){const{cn:i,...s}=e;if("string"!=typeof i)throw new w("cn required");try{r(i,"cn")}catch(e){throw new w(e.message)}const a=await this.findPolicy(i,t).catch(e=>{if(e instanceof h)return null;throw e});if(a)throw new u(`A password policy is named ${i}: ${a.dn}`);const n=["top","person","pwdPolicy"],c={objectClass:n,cn:i,sn:i,pwdAttribute:"userPassword"};for(const[e,t]of Object.entries(s)){if(!isWritablePolicyAttribute(e))throw new w(`${e} cannot be written`);null!==t&&(c[e]=this.policyValue(e,t))}f.some(e=>e in c)&&n.push("pwdPolicyChecker");try{await this.server.ldap.add(`cn=${o(i)},${this.policyBase()}`,c,t)}catch(e){if(32!==d(e))throw e;throw new h(`${this.policyBase()} does not exist: create it or change --ppolicy-base`)}return this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(i,t))}async updatePolicy(t,i,a){const r=await this.findPolicy(t,a),o=await this.toPolicyRecord(r),n={},c=[];for(const[t,a]of Object.entries(i)){if(!isWritablePolicyAttribute(t)){if("dn"===t?"string"==typeof a&&s(a)===s(o.dn):Object.hasOwn(o,t)&&e(o[t],a))continue;throw new w(`${t} cannot be written`)}null!==a?n[t]=this.policyValue(t,a):void 0!==o[t]&&c.push(t)}const d={};if(Object.keys(n).length>0&&(d.replace=n),c.length>0&&(d.delete=c),!d.replace&&!d.delete)return o;const l=values(r.objectClass).map(e=>e.toLowerCase());return f.some(e=>e in n)&&!l.includes("pwdpolicychecker")&&(d.add={objectClass:"pwdPolicyChecker"}),await this.server.ldap.modify(r.dn,d,a),this.forgetPolicies(),this.toPolicyRecord(await this.findPolicy(t,a))}async deletePolicy(e,t){const i=await this.findPolicy(e,t);await this.server.ldap.authorize("ldapdeleterequest",[i.dn,t]);const r=(await this.readPolicyConfig()).dn;if(r&&s(r)===s(i.dn))throw new u(`${i.dn} is the default password policy`);if((await this.server.ldap.system.search({filter:`(pwdPolicySubentry=${a(i.dn)})`,scope:"sub",attributes:["1.1"],sizeLimit:1,paged:!1},this.config.ldap_base||"")).searchEntries.length>0)throw new u(`${i.dn} is still applied to at least one entry`);await this.server.ldap.delete(i.dn,t),this.forgetPolicies()}async assignPolicy(e,t,i){const s=this.resolveUserDn(e);if(null===t){try{await this.server.ldap.modify(s,{delete:["pwdPolicySubentry"]},i)}catch(e){if(16!==d(e))throw e}return{dn:s,policy:null}}const a=await this.findPolicy(t,i);return await this.server.ldap.modify(s,{replace:{pwdPolicySubentry:a.dn}},i),{dn:s,policy:a.dn}}parsePolicyTime(e){return void 0===e?null:n(e)}validateComplexity(e){const t=[],i=this.settings,s=i.ppolicy_min_length;return e.length<s&&t.push(`Minimum ${s} characters required`),i.ppolicy_require_uppercase&&!/[A-Z]/.test(e)&&t.push("At least one uppercase letter required"),i.ppolicy_require_lowercase&&!/[a-z]/.test(e)&&t.push("At least one lowercase letter required"),i.ppolicy_require_digit&&!/\d/.test(e)&&t.push("At least one digit required"),i.ppolicy_require_special&&!P.test(e)&&t.push("At least one special character required"),{valid:0===t.length,errors:t}}resolveUserDn(e){if(e.includes("="))return e;const t=this.settings.ldap_users_base||this.config.ldap_base;return`${this.config.ldap_user_main_attribute||"uid"}=${o(e)},${t}`}getConfigApiData(){const e=this.config.api_prefix||"/api",t=!!this.settings.ppolicy_validate_complexity;return{name:this.name,enabled:!0,endpoints:{getPolicy:`${e}/v1/password-policy`,getUserStatus:`${e}/v1/users/:id/password-status`,unlockUser:`${e}/v1/users/:id/unlock`,getExpiringSoon:`${e}/v1/password-policy/expiring-soon`,getLockedAccounts:`${e}/v1/password-policy/locked-accounts`,listPolicies:`${e}/v1/password-policies`,policy:`${e}/v1/password-policies/:cn`,assignPolicy:`${e}/v1/users/:id/password-policy`,validatePassword:t?`${e}/v1/password/validate`:void 0},config:{warnDays:this.settings.ppolicy_warn_days,validateComplexity:t,...t&&{complexityRules:{minLength:this.settings.ppolicy_min_length,requireUppercase:this.settings.ppolicy_require_uppercase,requireLowercase:this.settings.ppolicy_require_lowercase,requireDigit:this.settings.ppolicy_require_digit,requireSpecial:this.settings.ppolicy_require_special}}}}}}export{v as AssignPolicyBody,_ as ExpiringQuery,C as PolicyParams,$ as PolicyUserParams,T as UnlockResponse,k as ValidatePasswordBody,PasswordPolicy as default};
@@ -1 +1 @@
1
- import{Type as t}from"@sinclair/typebox";import{FilterParser as e}from"ldapts";import s from"../../abstract/plugin.js";import{tryMethodData as r}from"../../lib/expressFormatedResponses.js";import{isDnInBranch as i,getRdn as a,getParentDn as n}from"../../lib/utils.js";import{BadRequestError as o,ForbiddenError as h,ServiceUnavailableError as d,NotFoundError as c,isAuthzRefusal as l}from"../../lib/errors.js";import{outsideRequest as p}from"../../lib/requestContext.js";import{rawOptions as u}from"./ldap.options.js";import{defineRoute as f}from"../../lib/route.js";const m=["base","one","sub"],b=["userpassword","pwdhistory","sambantpassword","sambalmpassword","sambapasswordhistory","krbprincipalkey","krbextradata","krbpwdhistory","unicodepwd","dbcspwd","lmpwdhistory","ntpwdhistory","supplementalcredentials","userpkcs12","authpassword"],w=t.Object({base:t.Optional(t.String()),scope:t.Optional(t.String()),filter:t.Optional(t.String()),attributes:t.Optional(t.String()),limit:t.Optional(t.Integer({minimum:1}))}),y=t.Object({dn:t.String({minLength:1})}),g=t.Object({children:t.Optional(t.String())});class LdapRaw extends s{static optionDefinitions=u;name="ldapRaw";roles=["api"];bases;hiddenAttributes;hiddenTypeNames;hiddenFilterNames;showSecrets;maxResults;constructor(t){super(t);const e=this.options(u),s=e.ldap_raw_base.filter(t=>t&&t.length>0);if(0===s.length){if(!this.config.ldap_base)throw new Error("Missing --ldap-base or --ldap-raw-base for plugin core/ldap/raw");s.push(this.config.ldap_base)}this.bases=s,this.showSecrets=e.ldap_raw_show_secrets,this.hiddenAttributes=new Set([...this.showSecrets?[]:b,...e.ldap_raw_hidden_attribute.filter(t=>t&&t.length>0).map(t=>t.toLowerCase())]),this.maxResults=e.ldap_raw_max_results,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=>i(t,e)||t.toLowerCase()===e.toLowerCase()))throw new h(`DN ${t} is outside the exposed bases`)}async hiddenNames(){if(0===this.hiddenAttributes.size)return this.hiddenAttributes;if(this.hiddenTypeNames)return this.hiddenTypeNames;const t=new Set(this.hiddenAttributes);let e;try{e=await this.getSchemaIndex()}catch(t){throw new d(`The directory schema is needed to hide attributes by type: ${t instanceof Error?t.message:String(t)}`)}const s=new Set(t);for(const r of this.hiddenAttributes){const i=e.getAttributeType(r.replace(/^oid\./i,""));if(!i)continue;let a=i;const n=new Set;for(;a&&!n.has(a.oid);){n.add(a.oid);for(const e of[t,s]){e.add(a.oid.toLowerCase());for(const t of a.names)e.add(t.toLowerCase())}a=a.sup?e.getAttributeType(a.sup):void 0}}return this.hiddenTypeNames=t,this.hiddenFilterNames=s,t}isHidden(t,e){const s=t.replace(/^oid\./i,"").split(";")[0].trim().toLowerCase();return this.hiddenAttributes.has(s)||e.has(s)}checkFilter(t,s){let r;try{r=e.parseString(t)}catch(e){const s=e instanceof Error?e.message:String(e);throw new o(`Invalid LDAP filter ${JSON.stringify(t)}: ${s}. A filter must be parenthesised, e.g. (cn=foo) or (|(cn=*foo*)(ou=*foo*))`)}for(const t of function(t){const e=[],s=[t],r=new Set;for(;s.length>0;){const t=s.pop();if(!t||"object"!=typeof t||r.has(t))continue;r.add(t);const i=t;"string"==typeof i.attribute&&e.push(i.attribute),"string"==typeof i.matchType&&e.push(i.matchType),Array.isArray(i.filters)&&s.push(...i.filters),i.filter&&s.push(i.filter)}return e}(r))if(""!==t){if(this.isHidden(t,s))throw new o("The filter tests an attribute the raw API does not expose")}else if(s.size>0)throw new o("The filter matches on every attribute, which the raw API does not expose while attributes are hidden")}formatEntry(t){const e={},s=this.hiddenTypeNames??this.hiddenAttributes;for(const[r,i]of Object.entries(t)){if("dn"===r)continue;if(this.isHidden(r,s))continue;const t=Array.isArray(i)?i:[i],a=t.some(t=>Buffer.isBuffer(t));e[r]={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(){try{await this.hiddenNames()}catch(t){this.logger.warn(`LDAP raw API: root DSE served without resolving hidden attribute types: ${t instanceof Error?t.message:String(t)}`)}const t=(await p(()=>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),await this.hiddenNames();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||l(e)))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){if(this.checkDn(t),!await this.server.ldap.readableBy(t,s))throw new c(`Entry ${t} not found`);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){if(this.checkDn(t.base),!await this.server.ldap.readableBy(t.base,e))throw new c(`Entry ${t.base} not found`);await this.hiddenNames(),this.checkFilter(t.filter,this.hiddenFilterNames??this.hiddenAttributes);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=n(t);return e&&e!==t?e:null}api(t){const e=`${this.config.api_prefix}/v1/ldap/raw`;f(t,{method:"get",path:`${e}/bases`,handler:async({res:t})=>{await r(t,()=>({bases:this.bases}))}}),f(t,{method:"get",path:`${e}/rootdse`,handler:async({res:t})=>{await r(t,this.getRootDse.bind(this))}}),f(t,{method:"get",path:`${e}/schema`,handler:async({res:t})=>{await r(t,this.getSchema.bind(this))}}),f(t,{method:"get",path:`${e}/search`,query:w,handler:async({req:t,res:e,query:s})=>{const i=s.scope??"sub";if(!m.includes(i))throw new o(`Invalid scope "${i}", expected one of ${m.join(", ")}`);const a=s.attributes?.split(",").map(t=>t.trim()).filter(Boolean);await r(e,this.search.bind(this),{base:s.base||this.bases[0],scope:i,filter:s.filter||"(objectClass=*)",attributes:a,limit:s.limit},t)}}),f(t,{method:"get",path:`${e}/children/:dn`,params:y,query:g,handler:async({req:t,params:e,query:s,res:i})=>{const a=decodeURIComponent(e.dn),n=void 0!==s.children&&"false"!==s.children;await r(i,this.getChildren.bind(this),a,n,t)}}),f(t,{method:"get",path:`${e}/entry/:dn`,params:y,handler:async({req:t,params:e,res:s})=>{const i=decodeURIComponent(e.dn);await r(s,this.getEntry.bind(this),i,t)}})}getConfigApiData(){return{bases:this.bases,readOnly:!0,maxResults:this.maxResults,hiddenAttributes:[...this.hiddenAttributes],showSecrets:this.showSecrets}}}export{g as RawChildrenQuery,y as RawDnParams,w as RawSearchQuery,LdapRaw as default};
1
+ import{Type as t}from"@sinclair/typebox";import{FilterParser as e}from"ldapts";import s from"../../abstract/plugin.js";import{tryMethodData as r}from"../../lib/expressFormatedResponses.js";import{isDnInBranch as i,getRdn as a,getParentDn as n}from"../../lib/utils.js";import{BadRequestError as o,ForbiddenError as h,ServiceUnavailableError as d,NotFoundError as c,isAuthzRefusal as l}from"../../lib/errors.js";import{outsideRequest as p}from"../../lib/requestContext.js";import{rawOptions as u}from"./ldap.options.js";import{defineRoute as f}from"../../lib/route.js";const m=["base","one","sub"],b=["userpassword","pwdhistory","sambantpassword","sambalmpassword","sambapasswordhistory","krbprincipalkey","krbextradata","krbpwdhistory","unicodepwd","dbcspwd","lmpwdhistory","ntpwdhistory","supplementalcredentials","userpkcs12","authpassword"],w=t.Object({base:t.Optional(t.String()),scope:t.Optional(t.String()),filter:t.Optional(t.String()),attributes:t.Optional(t.String()),limit:t.Optional(t.Integer({minimum:1}))}),y=t.Object({dn:t.String({minLength:1})}),g=t.Object({children:t.Optional(t.String())});class LdapRaw extends s{static optionDefinitions=u;name="ldapRaw";roles=["api"];bases;hiddenAttributes;hiddenTypeNames;hiddenFilterNames;showSecrets;maxResults;constructor(t){super(t);const e=this.options(u),s=e.ldap_raw_base.filter(t=>t&&t.length>0);if(0===s.length){if(!this.config.ldap_base)throw new Error("Missing --ldap-base or --ldap-raw-base for plugin core/ldap/raw");s.push(this.config.ldap_base)}this.bases=s,this.showSecrets=e.ldap_raw_show_secrets,this.hiddenAttributes=new Set([...this.showSecrets?[]:b,...e.ldap_raw_hidden_attribute.filter(t=>t&&t.length>0).map(t=>t.toLowerCase())]),this.maxResults=e.ldap_raw_max_results,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=>i(t,e)||t.toLowerCase()===e.toLowerCase()))throw new h(`DN ${t} is outside the exposed bases`)}async hiddenNames(){if(0===this.hiddenAttributes.size)return this.hiddenAttributes;if(this.hiddenTypeNames)return this.hiddenTypeNames;const t=new Set(this.hiddenAttributes);let e;try{e=await this.getSchemaIndex()}catch(t){throw new d(`The directory schema is needed to hide attributes by type: ${t instanceof Error?t.message:String(t)}`)}const s=new Set(t);for(const r of this.hiddenAttributes){const i=e.getAttributeType(r.replace(/^oid\./i,""));if(!i)continue;let a=i;const n=new Set;for(;a&&!n.has(a.oid);){n.add(a.oid);for(const e of[t,s]){e.add(a.oid.toLowerCase());for(const t of a.names)e.add(t.toLowerCase())}a=a.sup?e.getAttributeType(a.sup):void 0}}return this.hiddenTypeNames=t,this.hiddenFilterNames=s,t}isHidden(t,e){const s=t.replace(/^oid\./i,"").split(";")[0].trim().toLowerCase();return this.hiddenAttributes.has(s)||e.has(s)}checkFilter(t,s){let r;try{r=e.parseString(t)}catch(e){const s=e instanceof Error?e.message:String(e);throw new o(`Invalid LDAP filter ${JSON.stringify(t)}: ${s}. A filter must be parenthesised, e.g. (cn=foo) or (|(cn=*foo*)(ou=*foo*))`)}for(const t of function(t){const e=[],s=[t],r=new Set;for(;s.length>0;){const t=s.pop();if(!t||"object"!=typeof t||r.has(t))continue;r.add(t);const i=t;"string"==typeof i.attribute&&e.push(i.attribute),"string"==typeof i.matchType&&e.push(i.matchType),Array.isArray(i.filters)&&s.push(...i.filters),i.filter&&s.push(i.filter)}return e}(r))if(""!==t){if(this.isHidden(t,s))throw new o("The filter tests an attribute the raw API does not expose")}else if(s.size>0)throw new o("The filter matches on every attribute, which the raw API does not expose while attributes are hidden")}formatEntry(t){const e={},s=this.hiddenTypeNames??this.hiddenAttributes;for(const[r,i]of Object.entries(t)){if("dn"===r)continue;if(this.isHidden(r,s))continue;const t=Array.isArray(i)?i:[i],a=t.some(t=>Buffer.isBuffer(t));e[r]={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(){try{await this.hiddenNames()}catch(t){this.logger.warn(`LDAP raw API: root DSE served without resolving hidden attribute types: ${t instanceof Error?t.message:String(t)}`)}const t=(await p(()=>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),await this.hiddenNames();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||l(e)))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){if(this.checkDn(t),!await this.server.ldap.readableBy(t,s))throw new c(`Entry ${t} not found`);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){if(this.checkDn(t.base),!await this.server.ldap.readableBy(t.base,e))throw new c(`Entry ${t.base} not found`);await this.hiddenNames(),this.checkFilter(t.filter,this.hiddenFilterNames??this.hiddenAttributes);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=n(t);return e&&e!==t?e:null}api(t){const e=`${this.config.api_prefix}/v1/ldap/raw`;f(t,{method:"get",path:`${e}/bases`,handler:async({res:t})=>{await r(t,()=>({bases:this.bases}))}}),f(t,{method:"get",path:`${e}/rootdse`,handler:async({res:t})=>{await r(t,this.getRootDse.bind(this))}}),f(t,{method:"get",path:`${e}/schema`,handler:async({res:t})=>{await r(t,this.getSchema.bind(this))}}),f(t,{method:"get",path:`${e}/search`,query:w,handler:async({req:t,res:e,query:s})=>{const i=s.scope??"sub";if(!m.includes(i))throw new o(`Invalid scope "${i}", expected one of ${m.join(", ")}`);const a=s.attributes?.split(",").map(t=>t.trim()).filter(Boolean);await r(e,this.search.bind(this),{base:s.base||this.bases[0],scope:i,filter:s.filter||"(objectClass=*)",attributes:a,limit:s.limit},t)}}),f(t,{method:"get",path:`${e}/children/:dn`,params:y,query:g,handler:async({req:t,params:e,query:s,res:i})=>{const a=e.dn,n=void 0!==s.children&&"false"!==s.children;await r(i,this.getChildren.bind(this),a,n,t)}}),f(t,{method:"get",path:`${e}/entry/:dn`,params:y,handler:async({req:t,params:e,res:s})=>{const i=e.dn;await r(s,this.getEntry.bind(this),i,t)}})}getConfigApiData(){return{bases:this.bases,readOnly:!0,maxResults:this.maxResults,hiddenAttributes:[...this.hiddenAttributes],showSecrets:this.showSecrets}}}export{g as RawChildrenQuery,y as RawDnParams,w as RawSearchQuery,LdapRaw as default};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{parseDn as s,normalizeDn as a,isDnInBranch as e}from"../../lib/utils.js";import{trashOptions as r}from"./ldap.options.js";class TrashPlugin extends t{static optionDefinitions=r;name="trash";roles=["consistency"];watchedBases=[];trashBase="";addMetadata=!0;autoCreateTrash=!0;trashInitialized=!1;constructor(t){super(t);const i=this.options(r);this.trashBase=i.trash_base;const h=i.trash_watched_bases;"string"==typeof h&&h&&(this.watchedBases=h.split(";").map(t=>t.trim()).filter(t=>t.length>0));const o=this.config.ldap_base;if(o){const t=s(a(o)),holdsBase=s=>s.some((a,e)=>t.every((t,a)=>s[e+a]===t));for(const r of this.watchedBases)e(r,o)&&!holdsBase(s(a(r)).slice(0,-t.length))||this.logger.warn(`Trash watched base "${r}" is not one DN under ${o}: --trash-watched-bases takes full DNs separated by ";", and as written the trash may catch nothing under it`)}this.addMetadata="false"!==i.trash_add_metadata,this.autoCreateTrash="false"!==i.trash_auto_create,this.logger.info(`Trash plugin initialized: base=${this.trashBase}, watched=${this.watchedBases.join(";")}, metadata=${this.addMetadata}`)}isWatched(t){return!e(t,this.trashBase)&&(0===this.watchedBases.length||this.watchedBases.some(s=>e(t,s)))}extractRDN(t){return t.split(",")[0]}buildTrashDN(t){return`${this.extractRDN(t)},${this.trashBase}`}async ensureTrashExists(){if(!this.trashInitialized)if(this.autoCreateTrash){try{if((await this.server.ldap.system.search({paged:!1},this.trashBase)).searchEntries.length>0)return this.logger.debug(`Trash branch ${this.trashBase} already exists`),void(this.trashInitialized=!0)}catch{this.logger.info(`Creating trash branch ${this.trashBase}`)}try{const t=this.extractRDN(this.trashBase).split("=")[1];await this.server.ldap.system.add(this.trashBase,{objectClass:["top","organizationalUnit"],ou:t,description:"LDAP Trash - Deleted entries are moved here"}),this.logger.info(`Trash branch ${this.trashBase} created successfully`),this.trashInitialized=!0}catch(t){throw this.logger.error(`Failed to create trash branch ${this.trashBase}: ${String(t)}`),new Error(`Trash plugin: Unable to create trash branch ${this.trashBase}. Please create it manually or set DM_TRASH_AUTO_CREATE=false`)}}else this.trashInitialized=!0}async removeOldTrashEntry(t){try{(await this.server.ldap.system.search({paged:!1},t)).searchEntries.length>0&&(this.logger.info(`Removing old trash entry ${t} before overwrite`),await this.server.ldap.system.delete(t),this.logger.debug(`Old trash entry ${t} deleted`))}catch(s){this.logger.debug(`No old trash entry to remove at ${t}: ${String(s)}`)}}async addMetadataToTrash(t,s){if(this.addMetadata)try{const a=`Deleted on ${(new Date).toISOString()} from ${s}`;await this.server.ldap.system.modify(t,{replace:{description:a}}),this.logger.debug(`Metadata added to ${t}`)}catch(s){this.logger.warn(`Failed to add metadata to ${t}: ${String(s)}. Entry moved to trash but without metadata.`)}}hooks={ldapdeleterequest:async([t,s])=>{const a=Array.isArray(t)?t:[t],e=[];for(const t of a){if(!this.isWatched(t)){this.logger.debug(`Trash plugin: ${t} not in watched branches, allowing native delete`),e.push(t);continue}this.logger.info(`Trash plugin: Intercepting delete of ${t}`),await this.ensureTrashExists();const a=this.buildTrashDN(t);await this.removeOldTrashEntry(a);try{this.logger.debug(`Moving ${t} to ${a} using LDAP move (atomic)`),await this.server.ldap.move(t,a,s),this.logger.info(`Entry ${t} moved to trash at ${a}`),await this.addMetadataToTrash(a,t)}catch(s){if(this.logger.error(`Trash plugin: Failed to move ${t} to trash: ${String(s)}`),s instanceof Error&&(s.message.includes("Insufficient access")||s.message.includes("permissions")||s.message.includes("access denied")))throw new Error(`Trash plugin: Insufficient LDAP permissions to move ${t} to trash. Please grant modifyDN permissions or disable trash plugin. Original error: ${s.message}`);throw new Error(`Trash plugin: Failed to move ${t} to trash: ${String(s)}`)}}return[Array.isArray(t)?e:e[0]??void 0,s]}}}export{TrashPlugin as default};
1
+ import t from"../../abstract/plugin.js";import{BadRequestError as s}from"../../lib/errors.js";import{parseDn as e,normalizeDn as a,isDnInBranch as r}from"../../lib/utils.js";import{trashOptions as i}from"./ldap.options.js";class TrashPlugin extends t{static optionDefinitions=i;name="trash";roles=["consistency"];watchedBases=[];trashBase="";addMetadata=!0;autoCreateTrash=!0;trashInitialized=!1;constructor(t){super(t);const s=this.options(i);this.trashBase=s.trash_base;const h=s.trash_watched_bases;"string"==typeof h&&h&&(this.watchedBases=h.split(";").map(t=>t.trim()).filter(t=>t.length>0));const o=this.config.ldap_base;if(o){const t=e(a(o)),holdsBase=s=>s.some((e,a)=>t.every((t,e)=>s[a+e]===t));for(const s of this.watchedBases)r(s,o)&&!holdsBase(e(a(s)).slice(0,-t.length))||this.logger.warn(`Trash watched base "${s}" is not one DN under ${o}: --trash-watched-bases takes full DNs separated by ";", and as written the trash may catch nothing under it`)}this.addMetadata="false"!==s.trash_add_metadata,this.autoCreateTrash="false"!==s.trash_auto_create,this.logger.info(`Trash plugin initialized: base=${this.trashBase}, watched=${this.watchedBases.join(";")}, metadata=${this.addMetadata}`)}isWatched(t){return!r(t,this.trashBase)&&(0===this.watchedBases.length||this.watchedBases.some(s=>r(t,s)))}extractRDN(t){return t.split(",")[0]}buildTrashDN(t){return`${this.extractRDN(t)},${this.trashBase}`}async ensureTrashExists(){if(!this.trashInitialized)if(this.autoCreateTrash){try{if((await this.server.ldap.system.search({paged:!1},this.trashBase)).searchEntries.length>0)return this.logger.debug(`Trash branch ${this.trashBase} already exists`),void(this.trashInitialized=!0)}catch{this.logger.info(`Creating trash branch ${this.trashBase}`)}try{const t=this.extractRDN(this.trashBase).split("=")[1];await this.server.ldap.system.add(this.trashBase,{objectClass:["top","organizationalUnit"],ou:t,description:"LDAP Trash - Deleted entries are moved here"}),this.logger.info(`Trash branch ${this.trashBase} created successfully`),this.trashInitialized=!0}catch(t){throw this.logger.error(`Failed to create trash branch ${this.trashBase}: ${String(t)}`),new Error(`Trash plugin: Unable to create trash branch ${this.trashBase}. Please create it manually or set DM_TRASH_AUTO_CREATE=false`)}}else this.trashInitialized=!0}async removeOldTrashEntry(t){try{(await this.server.ldap.system.search({paged:!1},t)).searchEntries.length>0&&(this.logger.info(`Removing old trash entry ${t} before overwrite`),await this.server.ldap.system.delete(t),this.logger.debug(`Old trash entry ${t} deleted`))}catch(s){this.logger.debug(`No old trash entry to remove at ${t}: ${String(s)}`)}}async addMetadataToTrash(t,s){if(this.addMetadata)try{const e=`Deleted on ${(new Date).toISOString()} from ${s}`;await this.server.ldap.system.modify(t,{replace:{description:e}}),this.logger.debug(`Metadata added to ${t}`)}catch(s){this.logger.warn(`Failed to add metadata to ${t}: ${String(s)}. Entry moved to trash but without metadata.`)}}hooks={ldapdeleterequest:async([t,e])=>{const a=Array.isArray(t)?t:[t],r=[];for(const t of a){if(!this.isWatched(t)){this.logger.debug(`Trash plugin: ${t} not in watched branches, allowing native delete`),r.push(t);continue}this.logger.info(`Trash plugin: Intercepting delete of ${t}`),await this.ensureTrashExists();const a=this.buildTrashDN(t);await this.removeOldTrashEntry(a);try{this.logger.debug(`Moving ${t} to ${a} using LDAP move (atomic)`),await this.server.ldap.move(t,a,e),this.logger.info(`Entry ${t} moved to trash at ${a}`),await this.addMetadataToTrash(a,t)}catch(e){if(this.logger.error(`Trash plugin: Failed to move ${t} to trash: ${String(e)}`),e instanceof s)throw e;if(e instanceof Error&&(e.message.includes("Insufficient access")||e.message.includes("permissions")||e.message.includes("access denied")))throw new Error(`Trash plugin: Insufficient LDAP permissions to move ${t} to trash. Please grant modifyDN permissions or disable trash plugin. Original error: ${e.message}`);throw new Error(`Trash plugin: Failed to move ${t} to trash: ${String(e)}`)}}return[Array.isArray(t)?r:r[0]??void 0,e]}}}export{TrashPlugin as default};
@@ -1 +1 @@
1
- import e from"fs";import{scimInvalidValue as r,kindOf as t}from"./errors.js";import{SCHEMA_GROUP as a,SCHEMA_USER as i}from"./types.js";const n=["createTimestamp","modifyTimestamp","entryUUID"],o={resourceType:"User",schemas:[i],entries:[{scim:"userName",ldap:"uid"},{scim:"externalId",ldap:"employeeNumber"},{scim:"name",sub:{familyName:"sn",givenName:"givenName",formatted:"cn",middleName:"initials"}},{scim:"displayName",ldap:"displayName"},{scim:"nickName",ldap:"displayName"},{scim:"title",ldap:"title"},{scim:"preferredLanguage",ldap:"preferredLanguage"},{scim:"emails",ldapPrimary:"mail",ldapSecondary:"mailAlternateAddress",multi:"array"},{scim:"phoneNumbers",ldapPrimary:"telephoneNumber",ldapSecondary:"mobile",multi:"array"}]},s={resourceType:"Group",schemas:[a],entries:[{scim:"displayName",ldap:"cn"}]};function withExternalId(e,r){const t=(r||"").trim();return t?e.entries.some(e=>"externalId"===e.scim)?e:{...e,entries:[...e.entries,{scim:"externalId",ldap:t}]}:e}function asString(e){if(null!=e){if(Array.isArray(e)){const r=e[0];return null==r?void 0:String(r)}return Buffer.isBuffer(e)?e.toString():String(e)}}function ldapTimeToIso(e){if(!e)return;const r=/^(\d{4})(\d{2})(\d{2})(\d{2})(?:(\d{2})(?:(\d{2}))?)?(?:[.,](\d+))?(Z|[+-]\d{2}(?:\d{2})?)?$/.exec(e);if(!r)return e;const[,t,a,i,n,o,s,l,c]=r;let u=0;if(l){const e=s?1e3:o?6e4:36e5;u=Math.round(Number(`0.${l}`)*e)}let d=1e3*(60*(60*Number(n)+Number(o||0))+Number(s||0))+u;const m=Math.floor(d/36e5);d-=36e5*m;const f=Math.floor(d/6e4);d-=6e4*f;const p=Math.floor(d/1e3),y=d-1e3*p;let b="Z";c&&"Z"!==c&&(b=`${c[0]}${c.slice(1,3)}:${c.slice(3,5)||"00"}`);const pad=e=>String(e).padStart(2,"0"),g=`${t}-${a}-${i}T${pad(m)}:${pad(f)}:${pad(p)}${y?`.${String(y).padStart(3,"0")}`:""}${b}`;return Number.isNaN(Date.parse(g))?e:g}function asArray(e){return null==e?[]:Array.isArray(e)?e.map(e=>Buffer.isBuffer(e)?e.toString():String(e)):Buffer.isBuffer(e)?[e.toString()]:[String(e)]}function loadMappingFile(r){if(!r)return;const t=e.readFileSync(r,"utf8");return JSON.parse(t)}function mergeMapping(e,r){if(!r)return e;const t=new Map;for(const r of e.entries)t.set(r.scim,r);for(const e of r.entries)t.set(e.scim,e);return{...e,entries:Array.from(t.values())}}const l="pwdAccountLockedTime",c="000001010000Z",u=/^\d{10,14}(\.\d+)?(Z|[+-]\d{2,4})$/;let warn=e=>{console.warn(`WARNING: ${e}`)};function setLockConfigWarn(e){const r=warn;return warn=e,r}const d=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)$/;function resolveLockConfig(e,r,t={attribute:"--scim-user-lock-attribute",value:"--scim-user-lock-value"}){const a=(e||"").trim()||l;if(!d.test(a))throw new Error(`${t.attribute} must be an LDAP attribute name, got '${a}'`);const i=a.toLowerCase()===l.toLowerCase(),n=(r||"").trim();if(!n){if(i)return{attribute:a,value:c};throw new Error(`${t.attribute} is '${a}', so ${t.value} must say what marks an account locked (for nsAccountLock on 389-ds, 'TRUE'). The default '${c}' only means anything to ${l}.`)}return i||n!==c||warn(`${t.value} is the ppolicy convention '${c}' but ${t.attribute} is '${a}', not ${l}. Unless '${a}' is a GeneralizedTime your directory reads as a lock, every deactivation will answer 200 and read back as active:false while the account keeps binding. Verify a deactivation actually prevents a bind.`),i&&!u.test(n)&&warn(`${t.value} '${n}' is not a GeneralizedTime, and ${l} holds one. The directory will refuse the write, or — where the attribute was redefined locally — store a value it does not read as a lock. The ppolicy convention is '${c}'.`),{attribute:a,value:n}}function readActive(e){if(void 0!==e){if("boolean"==typeof e)return e;if("string"==typeof e){const r=e.trim().toLowerCase();if("true"===r)return!0;if("false"===r)return!1}throw r(`Cannot read ${t(e)} as active`)}}function isLocked(e,r){return holdsValue(e,r.lockAttribute,r.lockValue)}function holdsValue(e,r,t){if(!r)return!1;const a=r.toLowerCase(),i=Object.keys(e).find(e=>e.toLowerCase()===a),n=void 0===i?void 0:e[i];if(null==n)return!1;const o=t.toLowerCase();return(Array.isArray(n)?n:[n]).some(e=>null!=e&&e.toString().toLowerCase()===o)}function resolveScimId(e,r){if("rdn"===r.idAttribute)return asString(e[r.rdnAttribute]);return asString(e[r.idAttribute])??asString(e[r.rdnAttribute])}function buildLocation(e,r){if(!r.baseUrl)return;const t="User"===r.resourceType?"Users":"Groups";return`${r.baseUrl.replace(/\/$/,"")}${r.scimPrefix}/${t}/${encodeURIComponent(e)}`}function ldapToScimUser(e,r,t){const a={schemas:[i]},n=resolveScimId(e,t);n&&(a.id=n);for(const t of r.entries){if(t.sub){const r={};for(const[a,i]of Object.entries(t.sub)){const t=asString(e[i]);null!=t&&(r[a]=t)}Object.keys(r).length>0&&(a[t.scim]=r);continue}if(t.ldapPrimary||t.ldapSecondary){const r=[];if(t.ldapPrimary){const a=asString(e[t.ldapPrimary]);null!=a&&r.push({value:a,primary:!0})}if(t.ldapSecondary)for(const a of asArray(e[t.ldapSecondary]))r.push({value:a});r.length>0&&(a[t.scim]=r);continue}if(t.ldap)if("array"===t.multi){const r=asArray(e[t.ldap]);r.length>0&&(a[t.scim]=r)}else{const r=asString(e[t.ldap]);null!=r&&(a[t.scim]=r)}}return a.active=!isLocked(e,t),n&&(a.meta={resourceType:"User",created:ldapTimeToIso(asString(e.createTimestamp)),lastModified:ldapTimeToIso(asString(e.modifyTimestamp)),location:buildLocation(n,t)}),a}function scimUserToLdap(e,a,i,n){const o={objectClass:n},s=namingValue(e,"userName")||namingValue(e,"id");for(const i of a.entries){if(i.readOnly||i.operational)continue;const a=e[i.scim];if(null!=a)if(i.sub){if(!a||"object"!=typeof a||Array.isArray(a))throw r(`${i.scim} must be an object, got ${t(a)}`);for(const[e,r]of Object.entries(i.sub)){const t=scalarOrValue(a[e],`${i.scim}.${e}`);void 0!==t&&""!==t&&(o[r]=t)}}else{if(i.ldapPrimary||i.ldapSecondary){if(!Array.isArray(a))throw r(`${i.scim} must be a list, got ${t(a)}`);const e=a.map(e=>multiValued(e,i.scim)).filter(e=>null!=e.value),n=e.find(e=>!0===e.primary)||e[0],s=e.filter(e=>e!==n);i.ldapPrimary&&n&&n.value&&(o[i.ldapPrimary]=n.value),i.ldapSecondary&&s.length>0&&(o[i.ldapSecondary]=s.map(e=>e.value).filter(Boolean));continue}i.ldap&&(o[i.ldap]=scalarValues(a,i.scim))}}const l=readActive(e.active);if(!1===l&&(o[i.lockAttribute]=i.lockValue||c),!o.cn&&s&&(o.cn=s),!o.sn){const r=[e.name?.familyName,e.displayName,s].find(e=>"string"==typeof e&&e.length>0);r&&(o.sn=r)}return{rdn:s,attributes:o,active:l}}function ldapToScimGroup(e,r,t,i){const n={schemas:[a]},o=resolveScimId(e,t);o&&(n.id=o);for(const t of r.entries){if(!t.ldap)continue;const r=e[t.ldap];if(null!=r)if("array"===t.multi){const e=asArray(r);e.length>0&&(n[t.scim]=e)}else{const e=asString(r);null!=e&&(n[t.scim]=e)}}const s=asArray(e.member),l=[];for(const e of s)if(e)if(i){const r=i(e);r&&l.push(r)}else l.push({value:e,type:"User"});return l.length>0&&(n.members=l),o&&(n.meta={resourceType:"Group",created:ldapTimeToIso(asString(e.createTimestamp)),lastModified:ldapTimeToIso(asString(e.modifyTimestamp)),location:buildLocation(o,t)}),n}function scimGroupToLdap(e,r,t){const a={objectClass:t},i=namingValue(e,"displayName")||namingValue(e,"id");for(const t of r.entries){if(t.readOnly||t.operational)continue;if(!t.ldap)continue;const r=e[t.scim];null!=r&&(a[t.ldap]=scalarValues(r,t.scim))}return{rdn:i,attributes:a}}function namingValue(e,a){const i=e[a];if(null==i)return"";if("string"!=typeof i)throw r(`${a} must be a string, got ${t(i)}`);return i}function scalarOrValue(e,a){const i=e&&"object"==typeof e&&!Array.isArray(e)&&"value"in e?e.value:e;if(null!=i){if(isScalar(i))return String(i);throw r(`${a} must be a string, got ${t(i)}`)}}const isScalar=e=>"string"==typeof e||"number"==typeof e||"boolean"==typeof e;function scalarValues(e,a){if(isScalar(e))return String(e);if(Array.isArray(e)&&e.every(isScalar))return e.map(String);throw r(`${a} must be a string, a number, a boolean or a list of them, got ${t(e)}`)}function multiValued(e,a){if(e&&"object"==typeof e&&!Array.isArray(e)){const{value:r}=e;if(null==r)return e;if(isScalar(r))return{...e,value:String(r)}}throw r(`Each element of ${a} must be an object whose value is a scalar, got ${t(e)}`)}function requiredLdapAttributes(e,r=[]){const t=new Set(["objectClass",...n,...r]);for(const r of e.entries)if(r.ldap&&t.add(r.ldap),r.ldapPrimary&&t.add(r.ldapPrimary),r.ldapSecondary&&t.add(r.ldapSecondary),r.sub)for(const e of Object.values(r.sub))t.add(e);return Array.from(t)}function scimPathToLdapAttribute(e,r){if("string"!=typeof e)return;const t=e.split(".")[0],a=e.includes(".")?e.split(".").slice(1).join("."):"";for(const e of r.entries)if(e.scim===t){if(e.sub&&a)return Object.hasOwn(e.sub,a)?e.sub[a]:void 0;if((e.ldapPrimary||e.ldapSecondary)&&("value"===a||!a))return e.ldapPrimary||e.ldapSecondary;if(e.ldap)return e.ldap}}export{s as DEFAULT_GROUP_MAPPING,l as DEFAULT_LOCK_ATTRIBUTE,c as DEFAULT_LOCK_VALUE,o as DEFAULT_USER_MAPPING,buildLocation,holdsValue,isLocked,ldapTimeToIso,ldapToScimGroup,ldapToScimUser,loadMappingFile,mergeMapping,readActive,requiredLdapAttributes,resolveLockConfig,resolveScimId,scimGroupToLdap,scimPathToLdapAttribute,scimUserToLdap,setLockConfigWarn,warn,withExternalId};
1
+ import e from"fs";import{hasOnlyRfc4514Escapes as r,parseDirectoryDate as t}from"../../lib/utils.js";import{scimInvalidValue as i,kindOf as a}from"./errors.js";import{SCHEMA_GROUP as n,SCHEMA_USER as s}from"./types.js";const o=["createTimestamp","modifyTimestamp","entryUUID"],l={resourceType:"User",schemas:[s],entries:[{scim:"userName",ldap:"uid"},{scim:"externalId",ldap:"employeeNumber"},{scim:"name",sub:{familyName:"sn",givenName:"givenName",formatted:"cn",middleName:"initials"}},{scim:"displayName",ldap:"displayName"},{scim:"nickName",ldap:"displayName"},{scim:"title",ldap:"title"},{scim:"preferredLanguage",ldap:"preferredLanguage"},{scim:"emails",ldapPrimary:"mail",ldapSecondary:"mailAlternateAddress",multi:"array"},{scim:"phoneNumbers",ldapPrimary:"telephoneNumber",ldapSecondary:"mobile",multi:"array"}]},c={resourceType:"Group",schemas:[n],entries:[{scim:"displayName",ldap:"cn"}]};function withExternalId(e,r){const t=(r||"").trim();return t?e.entries.some(e=>"externalId"===e.scim)?e:{...e,entries:[...e.entries,{scim:"externalId",ldap:t}]}:e}function asString(e){if(null!=e){if(Array.isArray(e)){const r=e[0];return null==r?void 0:String(r)}return Buffer.isBuffer(e)?e.toString():String(e)}}function ldapTimeToIso(e){if(!e)return;if(!/^\d{4}\d{2}\d{2}\d{2}/.test(e))return e;const r=t(e);return r?r.toISOString().replace(/\.000Z$/,"Z"):e}function asArray(e){return null==e?[]:Array.isArray(e)?e.map(e=>Buffer.isBuffer(e)?e.toString():String(e)):Buffer.isBuffer(e)?[e.toString()]:[String(e)]}function loadMappingFile(r){if(!r)return;const t=e.readFileSync(r,"utf8");return JSON.parse(t)}function mergeMapping(e,r){if(!r)return e;const t=new Map;for(const r of e.entries)t.set(r.scim,r);for(const e of r.entries)t.set(e.scim,e);return{...e,entries:Array.from(t.values())}}const u="pwdAccountLockedTime",d="000001010000Z",m=/^\d{10,14}(\.\d+)?(Z|[+-]\d{2,4})$/;let warn=e=>{console.warn(`WARNING: ${e}`)};function setLockConfigWarn(e){const r=warn;return warn=e,r}const f=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)$/;function resolveLockConfig(e,r,t={attribute:"--scim-user-lock-attribute",value:"--scim-user-lock-value"}){const i=(e||"").trim()||u;if(!f.test(i))throw new Error(`${t.attribute} must be an LDAP attribute name, got '${i}'`);const a=i.toLowerCase()===u.toLowerCase(),n=(r||"").trim();if(!n){if(a)return{attribute:i,value:d};throw new Error(`${t.attribute} is '${i}', so ${t.value} must say what marks an account locked (for nsAccountLock on 389-ds, 'TRUE'). The default '${d}' only means anything to ${u}.`)}return a||n!==d||warn(`${t.value} is the ppolicy convention '${d}' but ${t.attribute} is '${i}', not ${u}. Unless '${i}' is a GeneralizedTime your directory reads as a lock, every deactivation will answer 200 and read back as active:false while the account keeps binding. Verify a deactivation actually prevents a bind.`),a&&!m.test(n)&&warn(`${t.value} '${n}' is not a GeneralizedTime, and ${u} holds one. The directory will refuse the write, or — where the attribute was redefined locally — store a value it does not read as a lock. The ppolicy convention is '${d}'.`),{attribute:i,value:n}}function readActive(e){if(void 0!==e){if("boolean"==typeof e)return e;if("string"==typeof e){const r=e.trim().toLowerCase();if("true"===r)return!0;if("false"===r)return!1}throw i(`Cannot read ${a(e)} as active`)}}function isLocked(e,r){return holdsValue(e,r.lockAttribute,r.lockValue)}function holdsValue(e,r,t){if(!r)return!1;const i=r.toLowerCase(),a=Object.keys(e).find(e=>e.toLowerCase()===i),n=void 0===a?void 0:e[a];if(null==n)return!1;const s=t.toLowerCase();return(Array.isArray(n)?n:[n]).some(e=>null!=e&&e.toString().toLowerCase()===s)}function resolveScimId(e,r){if("rdn"===r.idAttribute)return asString(e[r.rdnAttribute]);return asString(e[r.idAttribute])??asString(e[r.rdnAttribute])}function buildLocation(e,r){if(!r.baseUrl)return;const t="User"===r.resourceType?"Users":"Groups";return`${r.baseUrl.replace(/\/$/,"")}${r.scimPrefix}/${t}/${encodeURIComponent(e)}`}function ldapToScimUser(e,r,t){const i={schemas:[s]},a=resolveScimId(e,t);a&&(i.id=a);for(const t of r.entries){if(t.sub){const r={};for(const[i,a]of Object.entries(t.sub)){const t=asString(e[a]);null!=t&&(r[i]=t)}Object.keys(r).length>0&&(i[t.scim]=r);continue}if(t.ldapPrimary||t.ldapSecondary){const r=[];if(t.ldapPrimary){const i=asString(e[t.ldapPrimary]);null!=i&&r.push({value:i,primary:!0})}if(t.ldapSecondary)for(const i of asArray(e[t.ldapSecondary]))r.push({value:i});r.length>0&&(i[t.scim]=r);continue}if(t.ldap)if("array"===t.multi){const r=asArray(e[t.ldap]);r.length>0&&(i[t.scim]=r)}else{const r=asString(e[t.ldap]);null!=r&&(i[t.scim]=r)}}return i.active=!isLocked(e,t),a&&(i.meta={resourceType:"User",created:ldapTimeToIso(asString(e.createTimestamp)),lastModified:ldapTimeToIso(asString(e.modifyTimestamp)),location:buildLocation(a,t)}),i}function scimUserToLdap(e,r,t,n){const s={objectClass:n},o=namingValue(e,"userName")||namingValue(e,"id");for(const t of r.entries){if(t.readOnly||t.operational)continue;const r=e[t.scim];if(null!=r)if(t.sub){if(!r||"object"!=typeof r||Array.isArray(r))throw i(`${t.scim} must be an object, got ${a(r)}`);for(const[e,i]of Object.entries(t.sub)){const a=scalarOrValue(r[e],`${t.scim}.${e}`);void 0!==a&&""!==a&&(t.subDn?.includes(e)&&assertReferenceDn(a,`${t.scim}.${e}`),s[i]=a)}}else{if(t.ldapPrimary||t.ldapSecondary){if(!Array.isArray(r))throw i(`${t.scim} must be a list, got ${a(r)}`);const e=r.map(e=>multiValued(e,t.scim)).filter(e=>null!=e.value),n=e.find(e=>!0===e.primary)||e[0],o=e.filter(e=>e!==n);t.ldapPrimary&&n&&n.value&&(s[t.ldapPrimary]=n.value),t.ldapSecondary&&o.length>0&&(s[t.ldapSecondary]=o.map(e=>e.value).filter(Boolean));continue}t.ldap&&(s[t.ldap]=scalarValues(r,t.scim))}}const l=readActive(e.active);if(!1===l&&(s[t.lockAttribute]=t.lockValue||d),!s.cn&&o&&(s.cn=o),!s.sn){const r=[e.name?.familyName,e.displayName,o].find(e=>"string"==typeof e&&e.length>0);r&&(s.sn=r)}return{rdn:o,attributes:s,active:l}}function ldapToScimGroup(e,r,t,i){const a={schemas:[n]},s=resolveScimId(e,t);s&&(a.id=s);for(const t of r.entries){if(!t.ldap)continue;const r=e[t.ldap];if(null!=r)if("array"===t.multi){const e=asArray(r);e.length>0&&(a[t.scim]=e)}else{const e=asString(r);null!=e&&(a[t.scim]=e)}}const o=asArray(e.member),l=[];for(const e of o)if(e)if(i){const r=i(e);r&&l.push(r)}else l.push({value:e,type:"User"});return l.length>0&&(a.members=l),s&&(a.meta={resourceType:"Group",created:ldapTimeToIso(asString(e.createTimestamp)),lastModified:ldapTimeToIso(asString(e.modifyTimestamp)),location:buildLocation(s,t)}),a}function scimGroupToLdap(e,r,t){const i={objectClass:t},a=namingValue(e,"displayName")||namingValue(e,"id");for(const t of r.entries){if(t.readOnly||t.operational)continue;if(!t.ldap)continue;const r=e[t.scim];null!=r&&(i[t.ldap]=scalarValues(r,t.scim))}return{rdn:a,attributes:i}}function namingValue(e,r){const t=e[r];if(null==t)return"";if("string"!=typeof t)throw i(`${r} must be a string, got ${a(t)}`);return t}function scalarOrValue(e,r){const t=e&&"object"==typeof e&&!Array.isArray(e)&&"value"in e?e.value:e;if(null!=t){if(isScalar(t))return String(t);throw i(`${r} must be a string, got ${a(t)}`)}}const isScalar=e=>"string"==typeof e||"number"==typeof e||"boolean"==typeof e;function scalarValues(e,r){if(isScalar(e))return String(e);if(Array.isArray(e)&&e.every(isScalar))return e.map(String);throw i(`${r} must be a string, a number, a boolean or a list of them, got ${a(e)}`)}function multiValued(e,r){if(e&&"object"==typeof e&&!Array.isArray(e)){const{value:r}=e;if(null==r)return e;if(isScalar(r))return{...e,value:String(r)}}throw i(`Each element of ${r} must be an object whose value is a scalar, got ${a(e)}`)}function requiredLdapAttributes(e,r=[]){const t=new Set(["objectClass",...o,...r]);for(const r of e.entries)if(r.ldap&&t.add(r.ldap),r.ldapPrimary&&t.add(r.ldapPrimary),r.ldapSecondary&&t.add(r.ldapSecondary),r.sub)for(const e of Object.values(r.sub))t.add(e);return Array.from(t)}function assertReferenceDn(e,t){if(!r(e))throw i(`${t} uses an escape RFC 4514 does not define`)}function isDnSubAttribute(e,r,t){return!0===e.entries.find(e=>e.scim===r)?.subDn?.includes(t)}function scimPathToLdapAttribute(e,r){if("string"!=typeof e)return;const t=e.split(".")[0],i=e.includes(".")?e.split(".").slice(1).join("."):"";for(const e of r.entries)if(e.scim===t){if(e.sub&&i)return Object.hasOwn(e.sub,i)?e.sub[i]:void 0;if((e.ldapPrimary||e.ldapSecondary)&&("value"===i||!i))return e.ldapPrimary||e.ldapSecondary;if(e.ldap)return e.ldap}}export{c as DEFAULT_GROUP_MAPPING,u as DEFAULT_LOCK_ATTRIBUTE,d as DEFAULT_LOCK_VALUE,l as DEFAULT_USER_MAPPING,assertReferenceDn,buildLocation,holdsValue,isDnSubAttribute,isLocked,ldapTimeToIso,ldapToScimGroup,ldapToScimUser,loadMappingFile,mergeMapping,readActive,requiredLdapAttributes,resolveLockConfig,resolveScimId,scimGroupToLdap,scimPathToLdapAttribute,scimUserToLdap,setLockConfigWarn,warn,withExternalId};
@@ -1 +1 @@
1
- import{extractLdapCode as e}from"../../lib/ldapCodes.js";import{scimInvalidValue as t,kindOf as r,ScimError as o,scimInvalidPath as n,scimNoTarget as a}from"./errors.js";import{DEFAULT_LOCK_ATTRIBUTE as i,DEFAULT_LOCK_VALUE as s,holdsValue as l,scimPathToLdapAttribute as u,readActive as f}from"./mapping.js";function asValues(e){if(null==e)return[];return(Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>e.length>0)}class WorkingEntry{current;values=new Map;touched=new Set;whole=new Set;constructor(e){this.current=e}get(e){return this.values.has(e)||this.values.set(e,asValues(this.current[e])),this.values.get(e)}set(e,t){this.whole.add(e),this.write(e,t)}append(e,t){const r=this.get(e);this.write(e,[...r,...t.filter(e=>!r.includes(e))])}drop(e,t){if(!t)return this.set(e,[]);this.write(e,this.get(e).filter(e=>!t.includes(e)))}write(e,t){this.touched.add(e),this.values.set(e,t)}diff(){const e={};for(const t of this.touched){const r=asValues(this.current[t]),o=this.get(t),n=o.filter(e=>!r.includes(e)),a=r.filter(e=>!o.includes(e));if(0!==n.length||0!==a.length)if(this.whole.has(t))if(0!==o.length)0===a.length&&r.length>0?(e.add||(e.add={}),e.add[t]=1===n.length?n[0]:n):(e.replace||(e.replace={}),e.replace[t]=1===o.length?o[0]:o);else{if(0===r.length)continue;e.delete||(e.delete={}),e.delete[t]=""}else n.length>0&&(e.add||(e.add={}),e.add[t]=1===n.length?n[0]:n),a.length>0&&(e.delete||(e.delete={}),e.delete[t]=1===a.length?a[0]:a)}return e}}function normalizeOp(e){if("string"!=typeof e)throw t(`Patch op must be a string, got ${r(e)}`);const o=e.toLowerCase();if("add"===o||"remove"===o||"replace"===o)return o;throw t(`Unknown patch op '${e}'`)}const c=new Set(["__proto__","constructor","prototype"]),p=/^[A-Za-z_$:][\w$:]*$/;function assertSafeKey(e){if(c.has(e))throw n(`Forbidden attribute name '${e}'`)}function parsePath(e){if("string"!=typeof e||0===e.length||e.length>512)throw n(`Malformed path '${String(e)}'`);let t,r,o;const a=e.indexOf("[");if(a>=0){const i=e.lastIndexOf("]");if(i<=a)throw n(`Malformed path '${e}'`);t=e.slice(0,a),r=e.slice(a+1,i);const s=e.slice(i+1);if(s.length>0){if("."!==s[0])throw n(`Malformed path '${e}'`);o=s.slice(1)}}else{const r=e.indexOf(".");r>=0?(t=e.slice(0,r),o=e.slice(r+1)):t=e}if(!p.test(t))throw n(`Malformed path '${e}'`);if(assertSafeKey(t),null!=o){if(!p.test(o))throw n(`Malformed sub-attribute in '${e}'`);assertSafeKey(o)}return{top:t,filter:r,sub:o}}function memberRef(e){const o=e&&"object"==typeof e&&!Array.isArray(e)?e.value:e;if(null==o)return"";if("string"==typeof o||"number"==typeof o)return String(o);throw t(`Each member must be a string or an object whose value is one, got ${r(o)}`)}async function applyOperation(e,o,c){const p=normalizeOp(e.op),h=c.memberAttribute||"member";if(!e.path){if("remove"===p)throw a("PATCH 'remove' requires a path");if(null==e.value||"object"!=typeof e.value||Array.isArray(e.value))throw a("PATCH without path requires an object value");const t=e.value;for(const[r,a]of Object.entries(t)){if(!r)throw n("Empty attribute name");await applyOperation({op:e.op,path:r,value:a},o,c)}return}if("string"!=typeof e.path)throw n(`Path must be a string, got ${r(e.path)}`);const{top:d,sub:y,filter:w}=parsePath(e.path);if("members"===d){const t=c.resolveMemberRef;if(!t)throw a("Member operations require a member resolver");const asMemberValues=async e=>{const r=Array.isArray(e)?e:[e],o=[];for(const e of r){const r=memberRef(e);if(!r)continue;const n=await t(r);n&&o.push(n)}return o};if("add"===p)return void o.append(h,await asMemberValues(e.value));if("replace"===p){const t=await asMemberValues(e.value);if(0===t.length)return;return void o.set(h,t)}if(!w&&null==e.value)return void o.drop(h);const r=w?/value\s+eq\s+"([^"]+)"/i.exec(w)?.[1]??"":"",n=[...r?await asMemberValues(r):[],...null!=e.value?await asMemberValues(e.value):[]];return void o.drop(h,n)}if("active"===d&&c.supportsActive){if(y||w)throw n(`'active' has no sub-attribute (got '${e.path}')`);const r=c.lockAttribute||i,a=Object.keys(c.current).find(e=>e.toLowerCase()===r.toLowerCase())??r,u="remove"===p||function(e){const r=f(e);if(void 0===r)throw t("PATCH on 'active' requires a value");return r}(e.value),h=c.lockValue||s;return void(u?l({[a]:o.get(a)},a,h)&&o.drop(a):o.set(a,[h]))}if(w)throw n(`Complex multi-valued filters are only supported on 'members' (got '${e.path}')`);const m=u(y?`${d}.${y}`:d,c.mapping);if(!m)throw n(`Unknown SCIM attribute path '${e.path}'`);if("remove"===p)return void o.drop(m);const v=function(e){if(null!=e)return"string"==typeof e?e:"number"==typeof e||"boolean"==typeof e?String(e):Array.isArray(e)?e.map(e=>{if("string"==typeof e)return e;if("number"==typeof e||"boolean"==typeof e)return String(e);if(e&&"object"==typeof e&&!Array.isArray(e)&&"value"in e&&("string"==typeof e.value||"number"==typeof e.value||"boolean"==typeof e.value))return String(e.value);throw t(`Unsupported array element in PATCH value: ${r(e)}`)}):void 0}(e.value);if(null==v)throw t(`PATCH ${e.op} ${e.path} missing value`);const g=Array.isArray(v)?v:[v];"add"===p?o.append(m,g):o.set(m,g)}async function patchToModifyRequest(e,o){if(!e.Operations||!Array.isArray(e.Operations))throw t("Missing Operations array");const n=new WorkingEntry(o.current);for(const a of e.Operations){if(!a||"object"!=typeof a||Array.isArray(a))throw t(`Each operation must be an object, got ${r(a)}`);await applyOperation(a,n,o)}return n.diff()}async function writePatch(t,r){for(let n=1;;n++)try{return await r()}catch(r){if(16!==e(r))throw r;if(2===n)throw new o(409,`${t} changed while this PATCH was being applied; nothing was written. Retry the request.`)}}function applyPatchToResource(e,t){const r={...e};for(const e of t.Operations){const t=normalizeOp(e.op);if(!e.path){if(e.value&&"object"==typeof e.value&&!Array.isArray(e.value))for(const[t,o]of Object.entries(e.value))assertSafeKey(t),r[t]=o;continue}const{top:o,sub:n}=parsePath(e.path);if("remove"!==t)if(n){const t=r[o],a=t&&"object"==typeof t&&!Array.isArray(t)?{...t}:Object.create(null);a[n]=e.value,r[o]=a}else r[o]=e.value;else if(n){const e=r[o];e&&"object"==typeof e&&delete e[n]}else delete r[o]}return r}export{applyPatchToResource,patchToModifyRequest,writePatch};
1
+ import{extractLdapCode as e}from"../../lib/ldapCodes.js";import{scimInvalidValue as t,kindOf as r,ScimError as o,scimInvalidPath as n,scimNoTarget as a}from"./errors.js";import{DEFAULT_LOCK_ATTRIBUTE as i,DEFAULT_LOCK_VALUE as s,holdsValue as l,scimPathToLdapAttribute as u,isDnSubAttribute as f,assertReferenceDn as c,readActive as p}from"./mapping.js";function asValues(e){if(null==e)return[];return(Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString():String(e)).filter(e=>e.length>0)}class WorkingEntry{current;values=new Map;touched=new Set;whole=new Set;constructor(e){this.current=e}get(e){return this.values.has(e)||this.values.set(e,asValues(this.current[e])),this.values.get(e)}set(e,t){this.whole.add(e),this.write(e,t)}append(e,t){const r=this.get(e);this.write(e,[...r,...t.filter(e=>!r.includes(e))])}drop(e,t){if(!t)return this.set(e,[]);this.write(e,this.get(e).filter(e=>!t.includes(e)))}write(e,t){this.touched.add(e),this.values.set(e,t)}diff(){const e={};for(const t of this.touched){const r=asValues(this.current[t]),o=this.get(t),n=o.filter(e=>!r.includes(e)),a=r.filter(e=>!o.includes(e));if(0!==n.length||0!==a.length)if(this.whole.has(t))if(0!==o.length)0===a.length&&r.length>0?(e.add||(e.add={}),e.add[t]=1===n.length?n[0]:n):(e.replace||(e.replace={}),e.replace[t]=1===o.length?o[0]:o);else{if(0===r.length)continue;e.delete||(e.delete={}),e.delete[t]=""}else n.length>0&&(e.add||(e.add={}),e.add[t]=1===n.length?n[0]:n),a.length>0&&(e.delete||(e.delete={}),e.delete[t]=1===a.length?a[0]:a)}return e}}function normalizeOp(e){if("string"!=typeof e)throw t(`Patch op must be a string, got ${r(e)}`);const o=e.toLowerCase();if("add"===o||"remove"===o||"replace"===o)return o;throw t(`Unknown patch op '${e}'`)}const h=new Set(["__proto__","constructor","prototype"]),d=/^[A-Za-z_$:][\w$:]*$/;function assertSafeKey(e){if(h.has(e))throw n(`Forbidden attribute name '${e}'`)}function parsePath(e){if("string"!=typeof e||0===e.length||e.length>512)throw n(`Malformed path '${String(e)}'`);let t,r,o;const a=e.indexOf("[");if(a>=0){const i=e.lastIndexOf("]");if(i<=a)throw n(`Malformed path '${e}'`);t=e.slice(0,a),r=e.slice(a+1,i);const s=e.slice(i+1);if(s.length>0){if("."!==s[0])throw n(`Malformed path '${e}'`);o=s.slice(1)}}else{const r=e.indexOf(".");r>=0?(t=e.slice(0,r),o=e.slice(r+1)):t=e}if(!d.test(t))throw n(`Malformed path '${e}'`);if(assertSafeKey(t),null!=o){if(!d.test(o))throw n(`Malformed sub-attribute in '${e}'`);assertSafeKey(o)}return{top:t,filter:r,sub:o}}function memberRef(e){const o=e&&"object"==typeof e&&!Array.isArray(e)?e.value:e;if(null==o)return"";if("string"==typeof o||"number"==typeof o)return String(o);throw t(`Each member must be a string or an object whose value is one, got ${r(o)}`)}async function applyOperation(e,o,h){const d=normalizeOp(e.op),y=h.memberAttribute||"member";if(!e.path){if("remove"===d)throw a("PATCH 'remove' requires a path");if(null==e.value||"object"!=typeof e.value||Array.isArray(e.value))throw a("PATCH without path requires an object value");const t=e.value;for(const[r,a]of Object.entries(t)){if(!r)throw n("Empty attribute name");await applyOperation({op:e.op,path:r,value:a},o,h)}return}if("string"!=typeof e.path)throw n(`Path must be a string, got ${r(e.path)}`);const{top:w,sub:m,filter:g}=parsePath(e.path);if("members"===w){const t=h.resolveMemberRef;if(!t)throw a("Member operations require a member resolver");const asMemberValues=async e=>{const r=Array.isArray(e)?e:[e],o=[];for(const e of r){const r=memberRef(e);if(!r)continue;const n=await t(r);n&&o.push(n)}return o};if("add"===d)return void o.append(y,await asMemberValues(e.value));if("replace"===d){const t=await asMemberValues(e.value);if(0===t.length)return;return void o.set(y,t)}if(!g&&null==e.value)return void o.drop(y);const r=g?/value\s+eq\s+"([^"]+)"/i.exec(g)?.[1]??"":"",n=[...r?await asMemberValues(r):[],...null!=e.value?await asMemberValues(e.value):[]];return void o.drop(y,n)}if("active"===w&&h.supportsActive){if(m||g)throw n(`'active' has no sub-attribute (got '${e.path}')`);const r=h.lockAttribute||i,a=Object.keys(h.current).find(e=>e.toLowerCase()===r.toLowerCase())??r,u="remove"===d||function(e){const r=p(e);if(void 0===r)throw t("PATCH on 'active' requires a value");return r}(e.value),f=h.lockValue||s;return void(u?l({[a]:o.get(a)},a,f)&&o.drop(a):o.set(a,[f]))}if(g)throw n(`Complex multi-valued filters are only supported on 'members' (got '${e.path}')`);const v=u(m?`${w}.${m}`:w,h.mapping);if(!v)throw n(`Unknown SCIM attribute path '${e.path}'`);if("remove"===d)return void o.drop(v);const b=function(e){if(null!=e)return"string"==typeof e?e:"number"==typeof e||"boolean"==typeof e?String(e):Array.isArray(e)?e.map(e=>{if("string"==typeof e)return e;if("number"==typeof e||"boolean"==typeof e)return String(e);if(e&&"object"==typeof e&&!Array.isArray(e)&&"value"in e&&("string"==typeof e.value||"number"==typeof e.value||"boolean"==typeof e.value))return String(e.value);throw t(`Unsupported array element in PATCH value: ${r(e)}`)}):void 0}(e.value);if(null==b)throw t(`PATCH ${e.op} ${e.path} missing value`);const A=Array.isArray(b)?b:[b];if(m&&f(h.mapping,w,m))for(const e of A)c(e,`${w}.${m}`);"add"===d?o.append(v,A):o.set(v,A)}async function patchToModifyRequest(e,o){if(!e.Operations||!Array.isArray(e.Operations))throw t("Missing Operations array");const n=new WorkingEntry(o.current);for(const a of e.Operations){if(!a||"object"!=typeof a||Array.isArray(a))throw t(`Each operation must be an object, got ${r(a)}`);await applyOperation(a,n,o)}return n.diff()}async function writePatch(t,r){for(let n=1;;n++)try{return await r()}catch(r){if(16!==e(r))throw r;if(2===n)throw new o(409,`${t} changed while this PATCH was being applied; nothing was written. Retry the request.`)}}function applyPatchToResource(e,t){const r={...e};for(const e of t.Operations){const t=normalizeOp(e.op);if(!e.path){if(e.value&&"object"==typeof e.value&&!Array.isArray(e.value))for(const[t,o]of Object.entries(e.value))assertSafeKey(t),r[t]=o;continue}const{top:o,sub:n}=parsePath(e.path);if("remove"!==t)if(n){const t=r[o],a=t&&"object"==typeof t&&!Array.isArray(t)?{...t}:Object.create(null);a[n]=e.value,r[o]=a}else r[o]=e.value;else if(n){const e=r[o];e&&"object"==typeof e&&delete e[n]}else delete r[o]}return r}export{applyPatchToResource,patchToModifyRequest,writePatch};
@@ -1 +1 @@
1
- import{createHash as t}from"node:crypto";import{promises as e,constants as a}from"node:fs";import{join as r}from"node:path";import i from"../../lib/storage/store.js";const errorText=t=>t instanceof Error?t.message:JSON.stringify(t);class FileStore extends i{name="storage/file";dir;abandonedAfter;constructor(t,e,a){super(t),this.dir=e,this.abandonedAfter=1e3*Math.max(a,60)}async ready(){await e.mkdir(this.dir,{recursive:!0,mode:448})}async open(){await this.ready()}async health(){if(!(await e.stat(this.dir)).isDirectory())throw new Error(`${this.dir} is not a directory`);return await e.access(this.dir,a.W_OK|a.X_OK),"ok"}path(e){return r(this.dir,t("sha256").update(e).digest("hex"))}static encode(t,e){return`${e.deadline} ${FileStore.readable(t)}\n${e.value}\n`}static decode(t){const e=t.indexOf("\n");if(e<0)return null;const a=parseInt(t.slice(0,e).split(" ")[0],10);return Number.isFinite(a)?{deadline:a,value:t.slice(e+1).replace(/\n$/,"")}:null}async load(t){try{return FileStore.decode(await e.readFile(this.path(t),"utf8"))}catch(t){return"ENOENT"!==t?.code&&this.logger.warn(`${this.name}: cannot read a record, the answer is a guess: ${errorText(t)}`),null}}async save(t,a){await this.ready();const r=this.path(t),i=`${r}.${process.pid}.tmp`;try{await e.writeFile(i,FileStore.encode(t,a),{mode:384}),await e.rename(i,r)}catch(t){throw this.logger.warn(`${this.name}: cannot write a record: ${errorText(t)}`),await e.unlink(i).catch(()=>{}),t}}async drop(t){await e.unlink(this.path(t)).catch(t=>{"ENOENT"!==t?.code&&this.logger.warn(`${this.name}: cannot drop a record, it keeps counting: ${errorText(t)}`)})}async sweep(){const t=Date.now();let a,i=0;try{a=await e.readdir(this.dir)}catch{return 0}for(const n of a){const a=r(this.dir,n);if(n.endsWith(".tmp")){await e.stat(a).then(e=>t-e.mtimeMs).catch(()=>0)>this.abandonedAfter&&(await e.unlink(a).catch(()=>{}),i++);continue}let s=null;try{s=FileStore.decode(await e.readFile(a,"utf8"))}catch{continue}null!==s&&s.deadline>t||(await e.unlink(a).catch(()=>{}),i++)}return i}}export{FileStore as default};
1
+ import{createHash as t}from"node:crypto";import{promises as e,constants as a}from"node:fs";import{join as r}from"node:path";import i from"../../lib/storage/store.js";const errorText=t=>t instanceof Error?t.message:JSON.stringify(t);class FileStore extends i{name="storage/file";dir;abandonedAfter;constructor(t,e,a){super(t),this.dir=e,this.abandonedAfter=1e3*Math.max(a,60)}async ready(){await e.mkdir(this.dir,{recursive:!0,mode:448})}async open(){await this.ready()}async health(){if(!(await e.stat(this.dir)).isDirectory())throw new Error(`${this.dir} is not a directory`);return await e.access(this.dir,a.W_OK|a.X_OK),"ok"}path(e){return r(this.dir,t("sha256").update(e).digest("hex"))}static encode(t,e){return`${e.deadline} ${FileStore.readable(t)}\n${e.value}\n`}static decode(t){const e=t.indexOf("\n");if(e<0)return null;const a=parseInt(t.slice(0,e).split(" ")[0],10);return Number.isFinite(a)?{deadline:a,value:t.slice(e+1).replace(/\n$/,"")}:null}async load(t){try{return FileStore.decode(await e.readFile(this.path(t),"utf8"))}catch(t){return"ENOENT"!==t?.code&&this.logger.warn(`${this.name}: cannot read a record, the answer is a guess: ${errorText(t)}`),null}}async save(t,a){await this.ready();const r=this.path(t),i=`${r}.${process.pid}.tmp`;try{await e.writeFile(i,FileStore.encode(t,a),{mode:384}),await e.rename(i,r)}catch(t){throw this.logger.warn(`${this.name}: cannot write a record: ${errorText(t)}`),await e.unlink(i).catch(()=>{}),t}}async drop(t){await e.unlink(this.path(t)).catch(t=>{"ENOENT"!==t?.code&&this.logger.warn(`${this.name}: cannot drop a record, it keeps counting: ${errorText(t)}`)})}async sweep(){const t=Date.now();let a,i=0;try{a=await e.readdir(this.dir)}catch{return 0}for(const n of a){const a=r(this.dir,n);if(n.endsWith(".tmp")){await e.stat(a).then(e=>t-e.mtimeMs).catch(()=>0)>this.abandonedAfter&&(await e.unlink(a).catch(()=>{}),i++);continue}let s;try{s=FileStore.decode(await e.readFile(a,"utf8"))}catch{continue}null!==s&&s.deadline>t||(await e.unlink(a).catch(()=>{}),i++)}return i}}export{FileStore as default};