ldap-rest 2.1.0 → 2.2.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 (52) hide show
  1. package/dist/lib/authz/base.js +1 -1
  2. package/dist/lib/ldapFilter.js +1 -0
  3. package/dist/plugins/auth/auth.options.js +1 -1
  4. package/dist/plugins/auth/authzLinid1.js +1 -1
  5. package/dist/plugins/auth/authzScope.js +1 -1
  6. package/dist/plugins/auth/crowdsec.js +1 -1
  7. package/dist/plugins/auth/rateLimit.js +1 -1
  8. package/dist/plugins/auth/totp.js +1 -1
  9. package/dist/plugins/auth/trustedProxy.js +1 -1
  10. package/dist/plugins/ldap/bulkImport.js +1 -1
  11. package/dist/plugins/ldap/raw.js +1 -1
  12. package/dist/plugins/weblogs.js +1 -1
  13. package/dist/src/bin/index.d.ts +2 -2
  14. package/dist/src/browser/shared/utils/totp.d.ts +5 -1
  15. package/dist/src/lib/authz/base.d.ts +23 -9
  16. package/dist/src/lib/ldapFilter.d.ts +19 -0
  17. package/dist/src/plugins/auth/auth.options.d.ts +1 -0
  18. package/dist/src/plugins/auth/authzLinid1.d.ts +103 -9
  19. package/dist/src/plugins/auth/authzScope.d.ts +4 -0
  20. package/dist/src/plugins/auth/crowdsec.d.ts +7 -0
  21. package/dist/src/plugins/auth/rateLimit.d.ts +7 -0
  22. package/dist/src/plugins/auth/totp.d.ts +11 -0
  23. package/dist/src/plugins/auth/trustedProxy.d.ts +23 -0
  24. package/dist/src/plugins/ldap/bulkImport.d.ts +7 -0
  25. package/docs/client-development/schemas/README.md +8 -7
  26. package/docs/usage/configuration.md +8 -7
  27. package/docs/usage/plugins/auth/README.md +12 -12
  28. package/docs/usage/plugins/auth/authz-linid1.md +69 -0
  29. package/docs/usage/plugins/auth/authz-scope.md +14 -0
  30. package/docs/usage/plugins/auth/rate-limit.md +11 -6
  31. package/docs/usage/plugins/auth/totp.md +37 -17
  32. package/docs/usage/plugins/auth/trusted-proxy.md +9 -1
  33. package/docs/usage/upgrading.md +26 -1
  34. package/examples/web/totp-client.html +16 -9
  35. package/package.json +1 -1
  36. package/static/browser/ldap-unit-editor/LdapUnitEditor.js +2 -1
  37. package/static/browser/ldap-unit-editor/LdapUnitEditor.js.map +1 -1
  38. package/static/browser/ldap-unit-editor.esm.js +23 -1
  39. package/static/browser/ldap-unit-editor.esm.js.map +1 -1
  40. package/static/browser/ldap-unit-editor.js +23 -1
  41. package/static/browser/ldap-unit-editor.js.map +1 -1
  42. package/static/browser/ldap-unit-editor.min.js +1 -1
  43. package/static/browser/ldap-unit-editor.min.js.map +1 -1
  44. package/static/browser/shared/utils/totp.js +2 -1
  45. package/static/browser/shared/utils/totp.js.map +1 -1
  46. package/static/browser/shared.esm.js +2 -1
  47. package/static/browser/shared.esm.js.map +1 -1
  48. package/static/browser/shared.js +2 -1
  49. package/static/browser/shared.js.map +1 -1
  50. package/static/browser/shared.min.js +1 -1
  51. package/static/browser/shared.min.js.map +1 -1
  52. package/static/schemas/twake/groups.json +15 -0
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{assertIdentityMode as r,identityFor as s}from"../auth/base.js";import{BadRequestError as i,ServiceUnavailableError as n,AmbiguousIdentityError as a,AuthzRefusedError as o}from"../errors.js";import{getParentDn as h,isDnInBranch as c,getRdn as u,organizationLink as l,normalizeDn as d,escapeLdapFilter as f,parseDn as w}from"../utils.js";import{authzFor as g,servesRequest as m}from"./composition.js";function mostSpecificBranchRule(t,e){let r=null,s=-1;for(const[i,n]of t){if(!c(e,i))continue;const t=w(i).length;t>s&&([r,s]=[n,t])}return r}class AuthzBase extends t{static widenedForLink=Symbol("widened for the link");static widenedForObjectClass=Symbol("widened for object classes");roles=["authz"];cacheTTL;static UNRESOLVED_POLICIES=["deny","allow"];constructor(t){super(t),r(this.config,this.constructor.name),g(this.config,this.constructor.name);const e=this.config.authz_unresolved_user??"deny";if(!AuthzBase.UNRESOLVED_POLICIES.includes(e))throw new Error(`${this.constructor.name}: unknown --authz-unresolved-user "${e}". Known: ${AuthzBase.UNRESOLVED_POLICIES.join(", ")}.`);this.config.authz_transit_branch&&!this.filtersAttachedEntries()&&this.logger.warn(`${this.constructor.name}: --authz-transit-branch is ignored, entries are not judged by their attachment here (--authz-filter-attached-entries)`)}static wantsAttribute(t,e){const r=t?.attributes;if(void 0===r)return!0;const s=(Array.isArray(r)?r:[r]).map(t=>String(t).toLowerCase());return 0===s.length||(!!s.includes("*")||s.includes(e.toLowerCase()))}extractBranchDn(t){return h(t)}filtersAttachedEntries(){return Boolean(this.config.authz_filter_attached_entries)}judgesByAttachment(){return this.filtersAttachedEntries()}globalBranches(){return[]}async searchRefusal(t,e){if(this.filtersAttachedEntries()){if(0===(await this.getAuthorizedBranches(t)).length)return"administers no branch";if(!this.globalBranches().some(t=>c(e,t)))return}return(await this.getUserPermissions(t,e)).read?void 0:`does not have read permission for branch ${e}`}sourcePermission(t){return"read"}async storedDn(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["1.1"]},t),r=e.searchEntries?.[0]?.dn;if(r)return String(r)}catch(e){if(34===e?.code)throw new i(`Invalid DN: ${t}`);if(32!==e?.code)throw this.logger.error(`${this.name}: ${t} could not be read to be authorized: ${String(e)}`),new n("Directory unavailable")}return t}async storedParent(t){const e=h(t);return e===t?t:`${u(t)},${await this.storedDn(e)}`}async effectiveBranch(t){const e=this.config.ldap_organization_link_attribute;if(!e||!this.filtersAttachedEntries())return this.extractBranchDn(t);try{const r=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t),s=l(r.searchEntries?.[0]?.[e]);if(s)return s}catch{}return this.extractBranchDn(t)}transitBranch(){if(!this.filtersAttachedEntries())return;const t=String(this.config.authz_transit_branch??"").trim();return t.length>0?t:void 0}inTransit(t,e=this.transitKey()){return void 0===t||void 0!==e&&d(t)===e}transitOriginAttribute(){if(void 0===this.transitBranch())return;const t=String(this.config.authz_transit_origin_attribute??"").trim();return t.length>0?t:void 0}async recordTransitOrigin(t,e){const r=this.transitOriginAttribute(),s=this.config.ldap_organization_link_attribute;if(!r||!s)return e;const same=(t,e)=>t.split(";")[0].toLowerCase()===e.toLowerCase(),named=(t,e)=>void 0!==t&&Object.keys(t).some(t=>same(t,e)),deleted=t=>Array.isArray(e.delete)?e.delete.some(e=>same(e,t)):named(e.delete,t);let i,n;if(named(e.replace,s))i=l(AuthzBase.valuesOf(e.replace,s));else if(named(e.add,s))i=l(AuthzBase.valuesOf(e.add,s));else if(!deleted(s))return e;if(named(e.replace,r)||named(e.add,r)||deleted(r))return e;try{n=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[s,r]},t)).searchEntries[0]}catch{}if(!n)return e;const a=l(AuthzBase.valuesOf(n,s)),o=this.inTransit(a),h=this.inTransit(i);return!o&&h&&a?{...e,replace:{...e.replace,[r]:a}}:o&&!h&&AuthzBase.valuesOf(n,r).length>0?{...e,delete:Array.isArray(e.delete)?[...e.delete,r]:{...e.delete,[r]:[]}}:e}transitKey(){const t=this.transitBranch();return void 0===t?void 0:d(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${f(t)})`,r=(await this.server.ldap.system.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(r.length>1)throw new a(t,r.length);if(0===r.length)return null;const s=r[0].dn;return"string"==typeof s?s:String(s)}administeredBranches(t){return Promise.resolve([])}async guardAdministratorAccount(t,e){for(const r of await this.administeredBranches(e))if(!(await this.getUserPermissions(t,r)).write)throw new o(`User ${t} may not write administrator account ${e}: no write permission for branch ${r}`)}onlyRelinks(t){const e=[this.config.ldap_organization_link_attribute,this.config.ldap_organization_path_attribute].filter(t=>"string"==typeof t).map(t=>t.toLowerCase());return[...Object.keys(t.add??{}),...Object.keys(t.replace??{}),...Array.isArray(t.delete)?t.delete:Object.keys(t.delete??{})].every(t=>e.includes(String(t).split(";")[0].toLowerCase()))}async refusingRule(t,e,r,s){const i=d(e);for(const n of await this.ruleBranches(t)){if(!(d(n)===i||s&&c(n,e)))continue;const a=await this.getUserPermissions(t,n);if(r.some(t=>!a[t]))return n}}shouldSkipAuthorization(t){return!t?.user||!m(t,this.config)}resolutionCache=new Map;static RESOLUTION_CACHE_MAX=1e4;resolutionCacheFullWarned=!1;fallbackWarned=!1;async resolveCaller(t){const{value:e,fellBack:r}=s(t,this.config);r&&!this.fallbackWarned&&(this.fallbackWarned=!0,this.logger.warn(`${this.name}: --authz-identity asks for req.userName and the authenticator published none, so permissions are read for req.user instead. Rules written on logins will not match`));const i=e,n=Date.now(),a=this.resolutionCache.get(i);let h;if(a&&n-a.at<this.cacheTTL)h=a.user;else{if(h=await this.resolveUser(i),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)n-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(i,{user:h,at:n}):this.resolutionCacheFullWarned||(this.resolutionCacheFullWarned=!0,this.logger.warn(`${this.name}: more than ${AuthzBase.RESOLUTION_CACHE_MAX} identities resolved within one cache window, so resolutions are no longer cached and each request costs a lookup`))}if(h)return h;if("allow"===this.config.authz_unresolved_user)return this.logger.warn(`User ${i} could not be resolved by ${this.name}; allowed by --authz-unresolved-user allow`),null;throw new o(`User ${i} could not be resolved by ${this.name}, so no permission can be read for them`)}domainLinkAttribute(){const t=this.server.loadedPlugins.ldapOrganizations;return e(t?.schema,"domainLink")||this.config.enterprise_domain_link_attribute||void 0}static organizationLinks(t,e){if(e.some(t=>""===t.trim()))throw new i(`${t} cannot hold an empty value`);const r=[...new Map(e.map(t=>[d(t),t])).values()];if(r.length>1)throw new i(`${t} attaches an entry to one organization, not ${r.length}`);return r}static valuesOf(t,e){if(!t)return[];const r=e.toLowerCase(),s=[];for(const[e,i]of Object.entries(t))if(e.split(";")[0].toLowerCase()===r&&null!=i)for(const t of Array.isArray(i)?i:[i])s.push(String(t));return s}async storedValues(t,e){try{const r=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t);return AuthzBase.valuesOf(r.searchEntries?.[0],e)}catch{return[]}}async guardDomainLinks(t,e,r,s,i,n){if(0===s.length)return;const a=new Set((n?[]:await this.storedValues(r,i)).map(d)),u=s.filter(t=>!a.has(d(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,f=d(l||this.server.ldap.base),w=new Set;let g=h(r);const m=new Set;for(;g&&!m.has(d(g));){const t=d(g);if(m.add(t),t!==f&&!c(g,f))break;for(const t of await this.storedValues(g,i))w.add(d(t));if(t===f)break;const e=h(g);if(e===g)break;g=e}const p=u.filter(t=>!w.has(d(t)));if(0!==p.length&&!await this.mayLinkAnyDomain(t))throw new o(`User ${e.user} may not link ${r} to domain ${p[0]}: no organization above it is linked to it`)}async mayLinkAnyDomain(t){const e=this.config.ldap_top_organization;return!!e&&!0===(await this.getUserPermissions(t,e)).write}clearResolutionCache(){this.resolutionCache.clear()}hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},ldapmodifyrequest:async([t,e,r,s])=>{if(this.shouldSkipAuthorization(s))return[t,await this.recordTransitOrigin(t,e),r,s];const i=await this.resolveCaller(s);if(!i)return[t,await this.recordTransitOrigin(t,e),r,s];const n=await this.storedDn(t);await this.guardAdministratorAccount(i,n);const a=this.config.ldap_organization_link_attribute,h=a?AuthzBase.organizationLinks(a,[...AuthzBase.valuesOf(e.replace,a),...AuthzBase.valuesOf(e.add,a)]):[];if(a&&h.length>0){let r,c=this.extractBranchDn(n),u=!1;try{const e=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[a]},t)).searchEntries[0];e&&(u=!0,r=l(e[a]),r&&(c=r))}catch{}const d=await this.storedDn(h[0]),f=this.filtersAttachedEntries(),w=f&&u&&this.inTransit(r),g=f&&this.inTransit(d);if(!w){const t=g?"write":this.sourcePermission(c);if(!(await this.getUserPermissions(i,c))[t])throw new o(`User ${s.user} does not have ${t} permission for source branch ${c}`)}if(g){if(w){if(0===(await this.getAuthorizedBranches(i)).length)throw new o(`User ${s.user} administers no branch`)}}else{if(!(await this.getUserPermissions(i,d)).write)throw new o(`User ${s.user} does not have write permission for destination branch ${d}`)}if(!w&&!this.onlyRelinks(e)){const t=f?c:this.extractBranchDn(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${s.user} does not have write permission for branch ${t}`)}}else{const t=await this.effectiveBranch(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${s.user} does not have write permission for branch ${t}`)}const c=await this.refusingRule(i,n,["write"],!1);if(c)throw new o(`User ${s.user} does not have write permission for branch ${c}`);const u=this.domainLinkAttribute();return u&&await this.guardDomainLinks(i,s,t,[...AuthzBase.valuesOf(e.add,u),...AuthzBase.valuesOf(e.replace,u)],u,!1),[t,await this.recordTransitOrigin(t,e),r,s]},ldapaddrequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const s=await this.resolveCaller(r);if(!s)return[t,e,r];let i;await this.guardAdministratorAccount(s,t);const n=this.config.ldap_organization_link_attribute,a=n?AuthzBase.organizationLinks(n,AuthzBase.valuesOf(e,n)):[],h=await this.storedParent(t);i=a.length>0?await this.storedDn(a[0]):this.extractBranchDn(h);if(!(await this.getUserPermissions(s,i)).write)throw new o(`User ${r.user} does not have write permission for branch ${i}`);const c=await this.refusingRule(s,h,["write"],!1);if(c)throw new o(`User ${r.user} does not have write permission for branch ${c}`);const u=this.domainLinkAttribute();return u&&await this.guardDomainLinks(s,r,t,AuthzBase.valuesOf(e,u),u,!0),[t,e,r]},ldapsearchrequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const s=await this.resolveCaller(r);if(!s)return[t,e,r];const i=await this.searchRefusal(s,t);if(i)throw new o(`User ${r.user} ${i}`);if(this.filtersAttachedEntries()){const s=this.config.ldap_organization_link_attribute;if(s&&!AuthzBase.wantsAttribute(e,s)){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,s],[AuthzBase.widenedForLink]:!0}}if(s&&!AuthzBase.wantsAttribute(e,"objectClass")){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,"objectClass"],[AuthzBase.widenedForObjectClass]:!0}}return[t,e,r]}return[t,e,r]},ldapsearchfilter:async([t,e,r])=>{const s=[t,e,r];if(this.shouldSkipAuthorization(e))return s;if(!t?.searchEntries?.length)return s;const i=await this.resolveCaller(e);if(!i)return s;if(!this.filtersAttachedEntries()){const r=await this.ruleBranches(i),n=[],a=new Map,o=new Set(r.map(d));for(const r of t.searchEntries){const t=String(r.dn),s=d(h(t)),c=o.has(d(t));let u=c?void 0:a.get(s);void 0===u&&(u=!0===(await this.getUserPermissions(i,t)).read,c||a.set(s,u)),u?n.push(r):this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, refused by a narrower branch rule`)}return t.searchEntries=n,s}const n=this.globalBranches();if(n.length){const r=new Map,s=[];for(const a of t.searchEntries){const t=n.find(t=>c(a.dn,t));void 0===t||(r.has(t)||r.set(t,!0===(await this.getUserPermissions(i,t)).read),r.get(t))?s.push(a):this.logger.debug(`${this.name}: hiding ${String(a.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=s}const a=this.config.ldap_organization_link_attribute;if(!a)return s;const o=await this.getAuthorizedBranches(i);if(0===o.length)return s;const u=this.transitKey(),l=new Map,reads=async t=>{if(e=t,!o.some(t=>c(e,t)))return!1;var e;const r=d(t);return l.has(r)||l.set(r,!0===(await this.getUserPermissions(i,t)).read),l.get(r)},readsOrganisation=async t=>{const e=d(t);return l.has(e)||l.set(e,!0===(await this.getUserPermissions(i,t)).read),l.get(e)},f=this.config.ldap_top_organization,w=(this.config.ldap_organization_class??[]).filter(t=>"top"!==t.toLowerCase()).map(t=>t.toLowerCase()),organization=(t,e)=>{if(u&&d(t)===u)return!1;if(!f||!c(t,f))return!1;const r=[].concat(e.objectClass??[]).map(t=>String(t).toLowerCase());return w.some(t=>r.includes(t))},g=[];for(const r of t.searchEntries){const t=r[a],s=null==t?[]:(Array.isArray(t)?t:[t]).map(String);if(0===s.length){const t=String(r.dn);if(organization(t,r)&&!await readsOrganisation(t)){this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, outside the branches they hold`);continue}g.push(r);continue}let i=!1;for(const t of s)if(await reads(t)){i=!0;break}i||s.some(t=>this.inTransit(t,u))?g.push(r):this.logger.debug(`${this.name}: hiding ${String(r.dn)} from ${e.user}, attached to ${s[0]}`)}if(t.searchEntries=g,r?.[AuthzBase.widenedForLink])for(const e of t.searchEntries)delete e[a];if(r?.[AuthzBase.widenedForObjectClass])for(const e of t.searchEntries)delete e.objectClass;return[t,e,r]},getOrganisationTop:async([t,e])=>{if(this.shouldSkipAuthorization(t))return[t,e];const r=await this.resolveCaller(t);if(!r)return[t,e];const s=await this.getAuthorizedBranches(r);if(s.length>0){const e=[];for(const r of s)try{const s=await this.server.ldap.search({paged:!1,scope:"base"},r,t);1===s.searchEntries.length&&e.push(s.searchEntries[0])}catch(t){this.logger.warn(`Failed to fetch authorized branch ${r}: ${t}`)}if(1===e.length)return[t,e[0]];if(e.length>1)return[t,e[0]]}return[t,e]},ldaprenamerequest:async([t,e,r])=>{if(this.shouldSkipAuthorization(r))return[t,e,r];const s=await this.resolveCaller(r);if(!s)return[t,e,r];const i=await this.storedDn(t);await this.guardAdministratorAccount(s,i);const n=await this.storedParent(e);await this.guardAdministratorAccount(s,n);const a=this.extractBranchDn(i),h=this.extractBranchDn(n),c=await this.getUserPermissions(s,a),u=this.sourcePermission(a);if(!c[u])throw new o(`User ${r.user} does not have ${u} permission for source branch ${a}`);const l=await this.refusingRule(s,i,["read","write"],!0);if(l)throw new o(`User ${r.user} does not have read and write permission for branch ${l}`);if(!(await this.getUserPermissions(s,h)).write)throw new o(`User ${r.user} does not have write permission for destination branch ${h}`);const d=await this.refusingRule(s,n,["write"],!1);if(d)throw new o(`User ${r.user} does not have write permission for branch ${d}`);return[t,e,r]},ldapdeleterequest:async([t,e])=>{if(this.shouldSkipAuthorization(e))return[t,e];const r=await this.resolveCaller(e);if(!r)return[t,e];const s=Array.isArray(t)?t:[t];for(const t of s){const s=await this.storedDn(t);await this.guardAdministratorAccount(r,s);const i=await this.effectiveBranch(s);if(!(await this.getUserPermissions(r,i)).delete)throw new o(`User ${e.user} does not have delete permission for branch ${i}`);const n=await this.refusingRule(r,s,["delete"],!0);if(n)throw new o(`User ${e.user} does not have delete permission for branch ${n}`)}return[t,e]}}}export{AuthzBase as default,mostSpecificBranchRule};
1
+ import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{assertIdentityMode as s,identityFor as r}from"../auth/base.js";import{BadRequestError as i,ServiceUnavailableError as n,AmbiguousIdentityError as a,AuthzRefusedError as o}from"../errors.js";import{getParentDn as h,isDnInBranch as c,getRdn as u,organizationLink as l,normalizeDn as d,escapeLdapFilter as f,parseDn as g}from"../utils.js";import{authzFor as w,servesRequest as p}from"./composition.js";function mostSpecificBranchRule(t,e){let s=null,r=-1;for(const[i,n]of t){if(!c(e,i))continue;const t=g(i).length;t>r&&([s,r]=[n,t])}return s}class AuthzBase extends t{static widenedForLink=Symbol("widened for the link");static widenedForObjectClass=Symbol("widened for object classes");roles=["authz"];cacheTTL;static UNRESOLVED_POLICIES=["deny","allow"];constructor(t){super(t),s(this.config,this.constructor.name),w(this.config,this.constructor.name);const e=this.config.authz_unresolved_user??"deny";if(!AuthzBase.UNRESOLVED_POLICIES.includes(e))throw new Error(`${this.constructor.name}: unknown --authz-unresolved-user "${e}". Known: ${AuthzBase.UNRESOLVED_POLICIES.join(", ")}.`);this.config.authz_transit_branch&&!this.filtersAttachedEntries()&&this.logger.warn(`${this.constructor.name}: --authz-transit-branch is ignored, entries are not judged by their attachment here (--authz-filter-attached-entries)`)}static wantsAttribute(t,e){const s=t?.attributes;if(void 0===s)return!0;const r=(Array.isArray(s)?s:[s]).map(t=>String(t).toLowerCase());return 0===r.length||(!!r.includes("*")||r.includes(e.toLowerCase()))}extractBranchDn(t){return h(t)}filtersAttachedEntries(){return Boolean(this.config.authz_filter_attached_entries)}judgesByAttachment(){return this.filtersAttachedEntries()}globalBranches(){return[]}async searchRefusal(t,e){if(this.filtersAttachedEntries()){if(0===(await this.getAuthorizedBranches(t)).length)return"administers no branch";if(!this.globalBranches().some(t=>c(e,t)))return}return(await this.getUserPermissions(t,e)).read?void 0:`does not have read permission for branch ${e}`}sourcePermission(t){return"read"}async storedDn(t){try{const e=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["1.1"]},t),s=e.searchEntries?.[0]?.dn;if(s)return String(s)}catch(e){if(34===e?.code)throw new i(`Invalid DN: ${t}`);if(32!==e?.code)throw this.logger.error(`${this.name}: ${t} could not be read to be authorized: ${String(e)}`),new n("Directory unavailable")}return t}async storedParent(t){const e=h(t);return e===t?t:`${u(t)},${await this.storedDn(e)}`}async effectiveBranch(t){const e=this.config.ldap_organization_link_attribute;if(!e||!this.filtersAttachedEntries())return this.extractBranchDn(t);try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t),r=l(s.searchEntries?.[0]?.[e]);if(r)return r}catch{}return this.extractBranchDn(t)}flatBases(){const t=[],e=this.server.loadedPlugins.ldapFlatGeneric;for(const s of e?.instances??[])s.base&&t.push(s.base);const s=this.server.loadedPlugins.ldapGroups;s?.base&&this.config.ldap_group_base&&t.push(s.base);const r=this.server.loadedPlugins.ldapBulkImport;return"function"==typeof r?.bases&&t.push(...r.bases()),this.config.ldap_users_base&&t.push(this.config.ldap_users_base),this.config.ldap_group_base&&t.push(this.config.ldap_group_base),this.config.applicative_account_base&&t.push(this.config.applicative_account_base),this.config.external_members_branch&&t.push(this.config.external_members_branch),this.config.scim_user_base&&t.push(this.config.scim_user_base),this.config.scim_group_base&&t.push(this.config.scim_group_base),this.config.ldap_base&&(t.push(`ou=users,${this.config.ldap_base}`),t.push(`ou=groups,${this.config.ldap_base}`)),t}isFlatBase(t){let e;try{e=d(t)}catch{return!1}return this.flatBases().some(t=>{try{return d(t)===e}catch{return!1}})}transitBranch(){if(!this.filtersAttachedEntries())return;const t=String(this.config.authz_transit_branch??"").trim();return t.length>0?t:void 0}inTransit(t,e=this.transitKey()){return void 0===t||void 0!==e&&d(t)===e}transitOriginAttribute(){if(void 0===this.transitBranch())return;const t=String(this.config.authz_transit_origin_attribute??"").trim();return t.length>0?t:void 0}async recordTransitOrigin(t,e){const s=this.transitOriginAttribute(),r=this.config.ldap_organization_link_attribute;if(!s||!r)return e;const same=(t,e)=>t.split(";")[0].toLowerCase()===e.toLowerCase(),named=(t,e)=>void 0!==t&&Object.keys(t).some(t=>same(t,e)),deleted=t=>Array.isArray(e.delete)?e.delete.some(e=>same(e,t)):named(e.delete,t);let i,n;if(named(e.replace,r))i=l(AuthzBase.valuesOf(e.replace,r));else if(named(e.add,r))i=l(AuthzBase.valuesOf(e.add,r));else if(!deleted(r))return e;if(named(e.replace,s)||named(e.add,s)||deleted(s))return e;try{n=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[r,s]},t)).searchEntries[0]}catch{}if(!n)return e;const a=l(AuthzBase.valuesOf(n,r)),o=this.inTransit(a),h=this.inTransit(i);return!o&&h&&a?{...e,replace:{...e.replace,[s]:a}}:o&&!h&&AuthzBase.valuesOf(n,s).length>0?{...e,delete:Array.isArray(e.delete)?[...e.delete,s]:{...e.delete,[s]:[]}}:e}transitKey(){const t=this.transitBranch();return void 0===t?void 0:d(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${f(t)})`,s=(await this.server.ldap.system.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(s.length>1)throw new a(t,s.length);if(0===s.length)return null;const r=s[0].dn;return"string"==typeof r?r:String(r)}administeredBranches(t){return Promise.resolve([])}async guardAdministratorAccount(t,e){for(const s of await this.administeredBranches(e))if(!(await this.getUserPermissions(t,s)).write)throw new o(`User ${t} may not write administrator account ${e}: no write permission for branch ${s}`)}onlyRelinks(t){const e=[this.config.ldap_organization_link_attribute,this.config.ldap_organization_path_attribute].filter(t=>"string"==typeof t).map(t=>t.toLowerCase());return[...Object.keys(t.add??{}),...Object.keys(t.replace??{}),...Array.isArray(t.delete)?t.delete:Object.keys(t.delete??{})].every(t=>e.includes(String(t).split(";")[0].toLowerCase()))}async refusingRule(t,e,s,r){const i=d(e);for(const n of await this.ruleBranches(t)){if(!(d(n)===i||r&&c(n,e)))continue;const a=await this.getUserPermissions(t,n);if(s.some(t=>!a[t]))return n}}shouldSkipAuthorization(t){return!t?.user||!p(t,this.config)}resolutionCache=new Map;resolutionGeneration=0;static RESOLUTION_CACHE_MAX=1e4;resolutionCacheFullWarned=!1;fallbackWarned=!1;async resolveCaller(t){const{value:e,fellBack:s}=r(t,this.config);s&&!this.fallbackWarned&&(this.fallbackWarned=!0,this.logger.warn(`${this.name}: --authz-identity asks for req.userName and the authenticator published none, so permissions are read for req.user instead. Rules written on logins will not match`));const i=e,n=Date.now(),a=this.resolutionCache.get(i);let h;if(a&&n-a.at<this.cacheTTL)h=a.user;else{const t=this.resolutionGeneration;if(h=await this.resolveUser(i),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)n-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);t===this.resolutionGeneration&&this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(i,{user:h,at:n}):t!==this.resolutionGeneration||this.resolutionCacheFullWarned||(this.resolutionCacheFullWarned=!0,this.logger.warn(`${this.name}: more than ${AuthzBase.RESOLUTION_CACHE_MAX} identities resolved within one cache window, so resolutions are no longer cached and each request costs a lookup`))}if(h)return h;if("allow"===this.config.authz_unresolved_user)return this.logger.warn(`User ${i} could not be resolved by ${this.name}; allowed by --authz-unresolved-user allow`),null;throw new o(`User ${i} could not be resolved by ${this.name}, so no permission can be read for them`)}domainLinkAttribute(){const t=this.server.loadedPlugins.ldapOrganizations;return e(t?.schema,"domainLink")||this.config.enterprise_domain_link_attribute||void 0}static organizationLinks(t,e){if(e.some(t=>""===t.trim()))throw new i(`${t} cannot hold an empty value`);const s=[...new Map(e.map(t=>[d(t),t])).values()];if(s.length>1)throw new i(`${t} attaches an entry to one organization, not ${s.length}`);return s}static valuesOf(t,e){if(!t)return[];const s=e.toLowerCase(),r=[];for(const[e,i]of Object.entries(t))if(e.split(";")[0].toLowerCase()===s&&null!=i)for(const t of Array.isArray(i)?i:[i])r.push(String(t));return r}async storedValues(t,e){try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t);return AuthzBase.valuesOf(s.searchEntries?.[0],e)}catch{return[]}}async guardDomainLinks(t,e,s,r,i,n){if(0===r.length)return;const a=new Set((n?[]:await this.storedValues(s,i)).map(d)),u=r.filter(t=>!a.has(d(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,f=d(l||this.server.ldap.base),g=new Set;let w=h(s);const p=new Set;for(;w&&!p.has(d(w));){const t=d(w);if(p.add(t),t!==f&&!c(w,f))break;for(const t of await this.storedValues(w,i))g.add(d(t));if(t===f)break;const e=h(w);if(e===w)break;w=e}const m=u.filter(t=>!g.has(d(t)));if(0!==m.length&&!await this.mayLinkAnyDomain(t))throw new o(`User ${e.user} may not link ${s} to domain ${m[0]}: no organization above it is linked to it`)}async mayLinkAnyDomain(t){const e=this.config.ldap_top_organization;return!!e&&!0===(await this.getUserPermissions(t,e)).write}clearResolutionCache(){this.resolutionGeneration++,this.resolutionCache.clear()}hooks={ldaprenamedone:()=>{this.clearResolutionCache()},ldapdeletedone:()=>{this.clearResolutionCache()},ldapmodifyrequest:async([t,e,s,r])=>{if(this.shouldSkipAuthorization(r))return[t,await this.recordTransitOrigin(t,e),s,r];const i=await this.resolveCaller(r);if(!i)return[t,await this.recordTransitOrigin(t,e),s,r];const n=await this.storedDn(t);await this.guardAdministratorAccount(i,n);const a=this.config.ldap_organization_link_attribute,h=a?AuthzBase.organizationLinks(a,[...AuthzBase.valuesOf(e.replace,a),...AuthzBase.valuesOf(e.add,a)]):[];if(a&&h.length>0){let s,c=this.extractBranchDn(n),u=!1;try{const e=(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[a]},t)).searchEntries[0];e&&(u=!0,s=l(e[a]),s&&(c=s))}catch{}const d=await this.storedDn(h[0]),f=this.filtersAttachedEntries(),g=f&&u&&this.inTransit(s),w=f&&this.inTransit(d);if(!g){const t=w?"write":this.sourcePermission(c);if(!(await this.getUserPermissions(i,c))[t])throw new o(`User ${r.user} does not have ${t} permission for source branch ${c}`)}if(w){if(g){if(0===(await this.getAuthorizedBranches(i)).length)throw new o(`User ${r.user} administers no branch`)}}else{if(!(await this.getUserPermissions(i,d)).write)throw new o(`User ${r.user} does not have write permission for destination branch ${d}`)}if(!g&&!this.onlyRelinks(e)){const t=f?c:this.extractBranchDn(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${r.user} does not have write permission for branch ${t}`)}}else{const t=await this.effectiveBranch(n);if(!(await this.getUserPermissions(i,t)).write)throw new o(`User ${r.user} does not have write permission for branch ${t}`)}const c=await this.refusingRule(i,n,["write"],!1);if(c)throw new o(`User ${r.user} does not have write permission for branch ${c}`);const u=this.domainLinkAttribute();return u&&await this.guardDomainLinks(i,r,t,[...AuthzBase.valuesOf(e.add,u),...AuthzBase.valuesOf(e.replace,u)],u,!1),[t,await this.recordTransitOrigin(t,e),s,r]},ldapaddrequest:async([t,e,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,s];const r=await this.resolveCaller(s);if(!r)return[t,e,s];await this.guardAdministratorAccount(r,t);const i=this.config.ldap_organization_link_attribute,n=i?AuthzBase.organizationLinks(i,AuthzBase.valuesOf(e,i)):[],a=await this.storedParent(t),h=this.extractBranchDn(a),c=(this.config.ldap_organization_class??[]).filter(t=>"top"!==t.toLowerCase()).map(t=>t.toLowerCase()),u=AuthzBase.valuesOf(e,"objectClass").map(t=>String(t).toLowerCase()).some(t=>c.includes(t)),l=new Set;n.length>0?(l.add(await this.storedDn(n[0])),!u&&this.isFlatBase(h)||l.add(h)):l.add(h);for(const t of l){if(!(await this.getUserPermissions(r,t)).write)throw new o(`User ${s.user} does not have write permission for branch ${t}`)}const d=await this.refusingRule(r,a,["write"],!1);if(d)throw new o(`User ${s.user} does not have write permission for branch ${d}`);const f=this.domainLinkAttribute();return f&&await this.guardDomainLinks(r,s,t,AuthzBase.valuesOf(e,f),f,!0),[t,e,s]},ldapsearchrequest:async([t,e,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,s];const r=await this.resolveCaller(s);if(!r)return[t,e,s];const i=await this.searchRefusal(r,t);if(i)throw new o(`User ${s.user} ${i}`);if(this.filtersAttachedEntries()){const r=this.config.ldap_organization_link_attribute;if(r&&!AuthzBase.wantsAttribute(e,r)){const t=e.attributes,s=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...s,r],[AuthzBase.widenedForLink]:!0}}if(r&&!AuthzBase.wantsAttribute(e,"objectClass")){const t=e.attributes,s=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...s,"objectClass"],[AuthzBase.widenedForObjectClass]:!0}}return[t,e,s]}return[t,e,s]},ldapsearchfilter:async([t,e,s])=>{const r=[t,e,s];if(this.shouldSkipAuthorization(e))return r;if(!t?.searchEntries?.length)return r;const i=await this.resolveCaller(e);if(!i)return r;if(!this.filtersAttachedEntries()){const s=await this.ruleBranches(i),n=[],a=new Map,o=new Set(s.map(d));for(const s of t.searchEntries){const t=String(s.dn),r=d(h(t)),c=o.has(d(t));let u=c?void 0:a.get(r);void 0===u&&(u=!0===(await this.getUserPermissions(i,t)).read,c||a.set(r,u)),u?n.push(s):this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, refused by a narrower branch rule`)}return t.searchEntries=n,r}const n=this.globalBranches();if(n.length){const s=new Map,r=[];for(const a of t.searchEntries){const t=n.find(t=>c(a.dn,t));void 0===t||(s.has(t)||s.set(t,!0===(await this.getUserPermissions(i,t)).read),s.get(t))?r.push(a):this.logger.debug(`${this.name}: hiding ${String(a.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=r}const a=this.config.ldap_organization_link_attribute;if(!a)return r;const o=await this.getAuthorizedBranches(i);if(0===o.length)return r;const u=this.transitKey(),l=new Map,reads=async t=>{if(e=t,!o.some(t=>c(e,t)))return!1;var e;const s=d(t);return l.has(s)||l.set(s,!0===(await this.getUserPermissions(i,t)).read),l.get(s)},readsOrganisation=async t=>{const e=d(t);return l.has(e)||l.set(e,!0===(await this.getUserPermissions(i,t)).read),l.get(e)},f=this.config.ldap_top_organization,g=(this.config.ldap_organization_class??[]).filter(t=>"top"!==t.toLowerCase()).map(t=>t.toLowerCase()),organization=(t,e)=>{if(u&&d(t)===u)return!1;if(!f||!c(t,f))return!1;const s=[].concat(e.objectClass??[]).map(t=>String(t).toLowerCase());return g.some(t=>s.includes(t))},w=[];for(const s of t.searchEntries){const t=s[a],r=null==t?[]:(Array.isArray(t)?t:[t]).map(String);if(0===r.length){const t=String(s.dn);if(organization(t,s)&&!await readsOrganisation(t)){this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, outside the branches they hold`);continue}w.push(s);continue}let i=!1;for(const t of r)if(await reads(t)){i=!0;break}i||r.some(t=>this.inTransit(t,u))?w.push(s):this.logger.debug(`${this.name}: hiding ${String(s.dn)} from ${e.user}, attached to ${r[0]}`)}if(t.searchEntries=w,s?.[AuthzBase.widenedForLink])for(const e of t.searchEntries)delete e[a];if(s?.[AuthzBase.widenedForObjectClass])for(const e of t.searchEntries)delete e.objectClass;return[t,e,s]},getOrganisationTop:async([t,e])=>{if(this.shouldSkipAuthorization(t))return[t,e];const s=await this.resolveCaller(t);if(!s)return[t,e];const r=await this.getAuthorizedBranches(s);if(r.length>0){const e=[];for(const s of r)try{const r=await this.server.ldap.search({paged:!1,scope:"base"},s,t);1===r.searchEntries.length&&e.push(r.searchEntries[0])}catch(t){this.logger.warn(`Failed to fetch authorized branch ${s}: ${t}`)}if(1===e.length)return[t,e[0]];if(e.length>1)return[t,e[0]]}return[t,e]},ldaprenamerequest:async([t,e,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,s];const r=await this.resolveCaller(s);if(!r)return[t,e,s];const i=await this.storedDn(t);await this.guardAdministratorAccount(r,i);const n=await this.storedParent(e);await this.guardAdministratorAccount(r,n);const a=this.extractBranchDn(i),h=this.extractBranchDn(n),c=await this.getUserPermissions(r,a),u=this.sourcePermission(a);if(!c[u])throw new o(`User ${s.user} does not have ${u} permission for source branch ${a}`);const l=await this.refusingRule(r,i,["read","write"],!0);if(l)throw new o(`User ${s.user} does not have read and write permission for branch ${l}`);if(!(await this.getUserPermissions(r,h)).write)throw new o(`User ${s.user} does not have write permission for destination branch ${h}`);const d=await this.refusingRule(r,n,["write"],!1);if(d)throw new o(`User ${s.user} does not have write permission for branch ${d}`);return[t,e,s]},ldapdeleterequest:async([t,e])=>{if(this.shouldSkipAuthorization(e))return[t,e];const s=await this.resolveCaller(e);if(!s)return[t,e];const r=Array.isArray(t)?t:[t];for(const t of r){const r=await this.storedDn(t);await this.guardAdministratorAccount(s,r);const i=await this.effectiveBranch(r);if(!(await this.getUserPermissions(s,i)).delete)throw new o(`User ${e.user} does not have delete permission for branch ${i}`);const n=await this.refusingRule(s,r,["delete"],!0);if(n)throw new o(`User ${e.user} does not have delete permission for branch ${n}`)}return[t,e]}}}export{AuthzBase as default,mostSpecificBranchRule};
@@ -0,0 +1 @@
1
+ function filterAttributeNames(t){const e=[],r=[],i=new Set;for(t&&r.push(t);r.length>0;){const t=r.pop();if(!t||"object"!=typeof t||i.has(t))continue;i.add(t);const s=t;"string"==typeof s.attribute&&e.push(s.attribute),"string"==typeof s.matchType&&e.push(s.matchType),Array.isArray(s.filters)&&r.push(...s.filters),s.filter&&r.push(s.filter)}return e}function filterAttributeKey(t){return t.replace(/^oid\./i,"").split(";")[0].trim().toLowerCase()}export{filterAttributeKey,filterAttributeNames};
@@ -1 +1 @@
1
- import{defineOptions as _,opt as t,readOptions as e}from"../../config/options.js";const n=_({auth_token:t.array({env:"DM_AUTH_TOKENS",default:[],plural:"auth-tokens"})}),a=_({auth_fake_user:t.string({env:"DM_AUTH_FAKE_USER",default:""})}),r=_({auth_totp:t.array({env:"DM_AUTH_TOTP",default:[],plural:"auth-totps",kind:"phrases"}),auth_totp_window:t.number({env:"DM_AUTH_TOTP_WINDOW",default:1}),auth_totp_step:t.number({env:"DM_AUTH_TOTP_STEP",default:30,min:1})}),i=_({auth_hmac:t.array({env:"DM_AUTH_HMAC",default:[],plural:"auth-hmacs",kind:"phrases"}),auth_hmac_window:t.number({env:"DM_AUTH_HMAC_WINDOW",default:12e4,min:1})}),u=_({llng_ini:t.string({env:"DM_LLNG_INI",default:"/etc/lemonldap-ng/lemonldap-ng.ini"}),llng_username_header:t.string({env:"DM_LLNG_USERNAME_HEADER",default:""})}),l=_({authz_per_route:t.array({env:"DM_AUTHZ_PER_ROUTES",default:[],plural:"authz-per-routes"})}),d=_({authz_per_branch_config:t.json({env:"DM_AUTHZ_PER_BRANCH_CONFIG",default:{default:{read:!0,write:!1,delete:!1}}}),authz_per_branch_cache_ttl:t.number({env:"DM_AUTHZ_PER_BRANCH_CACHE_TTL",default:60,min:1}),ldap_group_member_attribute:t.string({env:"DM_LDAP_GROUP_MEMBER_ATTRIBUTE",default:"member"})}),s=_({authz_dynamic_bypass:t.array({env:"DM_AUTHZ_DYNAMIC_BYPASS",default:[],kind:"identifiers"}),authz_dynamic_base:t.string({env:"DM_AUTHZ_DYNAMIC_BASE",default:""}),authz_dynamic_cache_ttl:t.number({env:"DM_AUTHZ_DYNAMIC_CACHE_TTL",default:60,min:1}),authz_dynamic_token_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE",default:"userPassword"}),authz_dynamic_config_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE",default:"description"}),authz_dynamic_tenant_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE",default:"cn"}),authz_dynamic_reload_endpoint:t.boolean({env:"DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",default:!1})}),o=_({authz_local_admin_attribute:t.string({env:"DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE",default:"twakeLocalAdminLink"}),authz_linid1_cache_ttl:t.number({env:"DM_AUTHZ_LINID1_CACHE_TTL",default:300,min:1}),authz_global_branch:t.array({env:"DM_AUTHZ_GLOBAL_BRANCHES",default:[],plural:"authz-global-branches",kind:"dns"}),authz_global_write_branch:t.array({env:"DM_AUTHZ_GLOBAL_WRITE_BRANCHES",default:[],plural:"authz-global-write-branches",kind:"dns"}),authz_global_organization:t.string({env:"DM_AUTHZ_GLOBAL_ORGANIZATION",default:""})}),T=_({oidc_server:t.string({env:"DM_OIDC_SERVER",default:""}),oidc_username_claim:t.string({env:"DM_OIDC_USERNAME_CLAIM",default:"sub"}),oidc_client_id:t.string({env:"DM_OIDC_CLIENT_ID",default:""}),oidc_client_secret:t.string({env:"DM_OIDC_CLIENT_SECRET",default:""}),oidc_session_secret:t.array({env:"DM_OIDC_SESSION_SECRET",default:[],plural:"oidc-session-secrets",kind:"phrases"}),base_url:t.string({env:"DM_BASE_URL",default:""}),oidc_logout_origins:t.array({env:"DM_OIDC_LOGOUT_ORIGINS",default:[],plural:"oidc-logout-origins"})}),c=_({rate_limit_window_ms:t.number({env:"DM_RATE_LIMIT_WINDOW_MS",default:9e5,min:1}),rate_limit_max:t.number({env:"DM_RATE_LIMIT_MAX",default:100,min:1})}),A=_({crowdsec_url:t.string({env:"DM_CROWDSEC_URL",default:"http://localhost:8080/v1/decisions"}),crowdsec_api_key:t.string({env:"DM_CROWDSEC_API_KEY",default:""}),crowdsec_cache_ttl:t.number({env:"DM_CROWDSEC_CACHE_TTL",default:60,min:1})}),D=_({trusted_proxy:t.array({env:"DM_TRUSTED_PROXIES",default:[],plural:"trusted-proxies",kind:"identifiers"}),trusted_proxy_auth_header:t.string({env:"DM_TRUSTED_PROXY_AUTH_HEADER",default:"Auth-User"})});function totpOptionsOf(_){return e(r,_)}function hmacOptionsOf(_){return e(i,_)}function perBranchOptionsOf(_){return e(d,_)}function dynamicOptionsOf(_){return e(s,_)}function rateLimitOptionsOf(_){return e(c,_)}function crowdsecOptionsOf(_){return e(A,_)}export{A as crowdsecOptions,crowdsecOptionsOf,s as dynamicOptions,dynamicOptionsOf,a as fakeOptions,i as hmacOptions,hmacOptionsOf,o as linid1Options,u as llngOptions,T as openidconnectOptions,d as perBranchOptions,perBranchOptionsOf,l as perRouteOptions,c as rateLimitOptions,rateLimitOptionsOf,n as tokenOptions,r as totpOptions,totpOptionsOf,D as trustedProxyOptions};
1
+ import{defineOptions as _,opt as t,readOptions as e}from"../../config/options.js";const n=_({auth_token:t.array({env:"DM_AUTH_TOKENS",default:[],plural:"auth-tokens"})}),a=_({auth_fake_user:t.string({env:"DM_AUTH_FAKE_USER",default:""})}),r=_({auth_totp:t.array({env:"DM_AUTH_TOTP",default:[],plural:"auth-totps",kind:"phrases"}),auth_totp_window:t.number({env:"DM_AUTH_TOTP_WINDOW",default:1}),auth_totp_step:t.number({env:"DM_AUTH_TOTP_STEP",default:30,min:1})}),i=_({auth_hmac:t.array({env:"DM_AUTH_HMAC",default:[],plural:"auth-hmacs",kind:"phrases"}),auth_hmac_window:t.number({env:"DM_AUTH_HMAC_WINDOW",default:12e4,min:1})}),u=_({llng_ini:t.string({env:"DM_LLNG_INI",default:"/etc/lemonldap-ng/lemonldap-ng.ini"}),llng_username_header:t.string({env:"DM_LLNG_USERNAME_HEADER",default:""})}),l=_({authz_per_route:t.array({env:"DM_AUTHZ_PER_ROUTES",default:[],plural:"authz-per-routes"})}),d=_({authz_per_branch_config:t.json({env:"DM_AUTHZ_PER_BRANCH_CONFIG",default:{default:{read:!0,write:!1,delete:!1}}}),authz_per_branch_cache_ttl:t.number({env:"DM_AUTHZ_PER_BRANCH_CACHE_TTL",default:60,min:1}),ldap_group_member_attribute:t.string({env:"DM_LDAP_GROUP_MEMBER_ATTRIBUTE",default:"member"})}),s=_({authz_dynamic_bypass:t.array({env:"DM_AUTHZ_DYNAMIC_BYPASS",default:[],kind:"identifiers"}),authz_dynamic_base:t.string({env:"DM_AUTHZ_DYNAMIC_BASE",default:""}),authz_dynamic_cache_ttl:t.number({env:"DM_AUTHZ_DYNAMIC_CACHE_TTL",default:60,min:1}),authz_dynamic_token_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE",default:"userPassword"}),authz_dynamic_config_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE",default:"description"}),authz_dynamic_tenant_attribute:t.string({env:"DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE",default:"cn"}),authz_dynamic_reload_endpoint:t.boolean({env:"DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT",default:!1})}),o=_({authz_local_admin_attribute:t.string({env:"DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE",default:"twakeLocalAdminLink"}),authz_linid1_cache_ttl:t.number({env:"DM_AUTHZ_LINID1_CACHE_TTL",default:300,min:1}),authz_global_branch:t.array({env:"DM_AUTHZ_GLOBAL_BRANCHES",default:[],plural:"authz-global-branches",kind:"dns"}),authz_global_write_branch:t.array({env:"DM_AUTHZ_GLOBAL_WRITE_BRANCHES",default:[],plural:"authz-global-write-branches",kind:"dns"}),authz_global_organization:t.string({env:"DM_AUTHZ_GLOBAL_ORGANIZATION",default:""}),authz_group_admin_attribute:t.string({env:"DM_AUTHZ_GROUP_ADMIN_ATTRIBUTE",default:"",emptyIsValue:!0})}),T=_({oidc_server:t.string({env:"DM_OIDC_SERVER",default:""}),oidc_username_claim:t.string({env:"DM_OIDC_USERNAME_CLAIM",default:"sub"}),oidc_client_id:t.string({env:"DM_OIDC_CLIENT_ID",default:""}),oidc_client_secret:t.string({env:"DM_OIDC_CLIENT_SECRET",default:""}),oidc_session_secret:t.array({env:"DM_OIDC_SESSION_SECRET",default:[],plural:"oidc-session-secrets",kind:"phrases"}),base_url:t.string({env:"DM_BASE_URL",default:""}),oidc_logout_origins:t.array({env:"DM_OIDC_LOGOUT_ORIGINS",default:[],plural:"oidc-logout-origins"})}),A=_({rate_limit_window_ms:t.number({env:"DM_RATE_LIMIT_WINDOW_MS",default:9e5,min:1}),rate_limit_max:t.number({env:"DM_RATE_LIMIT_MAX",default:100,min:1})}),c=_({crowdsec_url:t.string({env:"DM_CROWDSEC_URL",default:"http://localhost:8080/v1/decisions"}),crowdsec_api_key:t.string({env:"DM_CROWDSEC_API_KEY",default:""}),crowdsec_cache_ttl:t.number({env:"DM_CROWDSEC_CACHE_TTL",default:60,min:1})}),D=_({trusted_proxy:t.array({env:"DM_TRUSTED_PROXIES",default:[],plural:"trusted-proxies",kind:"identifiers"}),trusted_proxy_auth_header:t.string({env:"DM_TRUSTED_PROXY_AUTH_HEADER",default:"Auth-User"})});function totpOptionsOf(_){return e(r,_)}function hmacOptionsOf(_){return e(i,_)}function perBranchOptionsOf(_){return e(d,_)}function dynamicOptionsOf(_){return e(s,_)}function rateLimitOptionsOf(_){return e(A,_)}function crowdsecOptionsOf(_){return e(c,_)}export{c as crowdsecOptions,crowdsecOptionsOf,s as dynamicOptions,dynamicOptionsOf,a as fakeOptions,i as hmacOptions,hmacOptionsOf,o as linid1Options,u as llngOptions,T as openidconnectOptions,d as perBranchOptions,perBranchOptionsOf,l as perRouteOptions,A as rateLimitOptions,rateLimitOptionsOf,n as tokenOptions,r as totpOptions,totpOptionsOf,D as trustedProxyOptions};
@@ -1 +1 @@
1
- import e,{mostSpecificBranchRule as s}from"../../lib/authz/base.js";import{isDnInBranch as t,escapeLdapFilter as i}from"../../lib/utils.js";import{linid1Options as r}from"./auth.options.js";class AuthzLinid1 extends e{static optionDefinitions=r;name="authzLinid1";permissionsCache=new Map;permissionsGeneration=0;declaredByBase;constructor(e){super(e),this.declaredByBase={...this.hooks},this.cacheTTL=1e3*this.options(r).authz_linid1_cache_ttl;const s=this.options(r).authz_local_admin_attribute;this.logger.info(`AuthzLinid1: Authorization based on ${s} attribute enabled`);const i=this.globalOrganization(),n=i&&this.globalBranches().find(e=>t(i,e));if(n)throw new Error(`AuthzLinid1: --authz-global-organization ${i} lies in the global branch ${n}`);const a=i&&this.globalWriteBranches().find(e=>t(i,e));if(a)throw new Error(`AuthzLinid1: --authz-global-organization ${i} lies in the global write branch ${a}`);for(const e of this.globalWriteBranches()){const s=this.globalBranches().find(s=>t(e,s)||t(s,e));if(s)throw new Error(`AuthzLinid1: the global branch ${s} and the global write branch ${e} overlap`)}this.hooks={...this.declaredByBase,ldapmodifydone:(e,t,i,r)=>((e=>[...Object.keys(e.add??{}),...Object.keys(e.replace??{}),...Array.isArray(e.delete)?e.delete:Object.keys(e.delete??{})].some(e=>e.split(";")[0].toLowerCase()===s.toLowerCase()))(t)&&this.forgetPermissions(),this.declaredByBase.ldapmodifydone?.(e,t,i,r)),ldapdeletedone:(e,s)=>(this.forgetPermissions(),this.declaredByBase.ldapdeletedone?.(e,s)),ldaprenamedone:(e,s,t)=>(this.forgetPermissions(),this.declaredByBase.ldaprenamedone?.(e,s,t))}}forgetPermissions(){this.permissionsGeneration++,this.permissionsCache.clear()}async administeredBranches(e){const s=this.options(r).authz_local_admin_attribute,t=this.config.ldap_top_organization||this.config.ldap_base||"";if(!t)return[];return((await this.server.ldap.system.search({paged:!1,filter:`(${s}=${i(e)})`,attributes:["dn"],scope:"sub"},t)).searchEntries??[]).map(e=>String(e.dn))}async mayLinkAnyDomain(e){if(await super.mayLinkAnyDomain(e))return!0;const s=this.globalOrganization();return!!s&&!0===(await this.getUserPermissions(e,s)).write}globalOrganization(){return this.options(r).authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.options(r).authz_global_branch}globalWriteBranches(){return this.options(r).authz_global_write_branch}isGlobalWrite(e){return this.globalWriteBranches().some(s=>t(e,s))}sourcePermission(e){return this.isGlobalWrite(e)?"delete":"read"}async resolveUser(e){return this.getUserDn(e)}async getUserDn(e){return this.findUserDn(e)}async getUserPermissions(e,s){const i=this.globalOrganization();if(this.globalBranches().some(e=>t(s,e)))return i&&!t(i,s)?this.getUserPermissions(e,i):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(s)){const r=i&&!t(i,s)?await this.getUserPermissions(e,i):{read:!1,write:!1,delete:!1};return{read:r.read||(await this.getAuthorizedBranches(e)).length>0,write:r.write,delete:r.delete}}return this.ownPermissions(e,s)}async ownPermissions(e,s){const t=Date.now(),i=this.permissionsCache.get(e);if(i&&t-i.timestamp<this.cacheTTL){const e=this.findBranchPermissions(i.branches,s);if(e)return e}const r=await this.refreshUserPermissions(e),n=this.findBranchPermissions(r,s);return n||{read:!1,write:!1,delete:!1}}async refreshUserPermissions(e){const s=this.permissionsGeneration,t=new Map;try{const s=`(${this.options(r).authz_local_admin_attribute}=${i(e)})`,n=this.config.ldap_top_organization||this.config.ldap_base||"",a=await this.server.ldap.system.search({paged:!1,filter:s,attributes:["dn"],scope:"sub"},n);if(a.searchEntries)for(const e of a.searchEntries){const s="string"==typeof e.dn?e.dn:String(e.dn);t.set(s,{read:!0,write:!0,delete:!0})}}catch(s){this.logger.error(`Failed to refresh permissions for user ${e}: ${s}`)}return s===this.permissionsGeneration&&this.permissionsCache.set(e,{branches:t,timestamp:Date.now()}),t}async getAuthorizedBranches(e){const s=Date.now(),t=this.permissionsCache.get(e);return!t||s-t.timestamp>=this.cacheTTL?Array.from((await this.refreshUserPermissions(e)).keys()):Array.from(t.branches.keys())}ruleBranches(e){return this.getAuthorizedBranches(e)}findBranchPermissions(e,t){return s(e.entries(),t)}}export{AuthzLinid1 as default};
1
+ import{FilterParser as e,SearchFilter as t}from"ldapts";import{roleAttribute as s}from"../../config/schema.js";import r,{mostSpecificBranchRule as i}from"../../lib/authz/base.js";import{AuthzRefusedError as n}from"../../lib/errors.js";import{filterAttributeKey as a}from"../../lib/ldapFilter.js";import{isDnInBranch as o,organizationLink as h,normalizeDn as d,isDummyMemberDn as c,escapeLdapFilter as l}from"../../lib/utils.js";import{linid1Options as u}from"./auth.options.js";function delegationsIntersect(e,t){return t.some(t=>e.some(e=>((e,t)=>{try{return o(e,t)}catch{return!1}})(e,t)))}function filterTouchesReduced(e,s){const r=e;return!!r&&(r.type===t.and||r.type===t.or?(r.filters??[]).some(e=>filterTouchesReduced(e,s)):r.type===t.not?filterTouchesReduced(r.filter,s):function(e,s){return"string"==typeof e.attribute&&e.attribute.length>0?s.has(a(e.attribute)):"string"==typeof e.matchType&&e.matchType.length>0?s.has(a(e.matchType)):e.type===t.extensibleMatch}(r,s))}class AuthzLinid1 extends r{static optionDefinitions=u;name="authzLinid1";permissionsCache=new Map;permissionsGeneration=0;groupAdminAttr;adminAttr;reducedFilterNames;static widenedForDelegation=Symbol("widened for the group delegation");declaredByBase;constructor(e){super(e),this.declaredByBase={...this.hooks},this.cacheTTL=1e3*this.options(u).authz_linid1_cache_ttl,this.adminAttr=this.options(u).authz_local_admin_attribute,this.groupAdminAttr=this.options(u).authz_group_admin_attribute,this.logger.info(`AuthzLinid1: Authorization based on ${this.adminAttr} attribute enabled`),this.groupAdminAttr&&this.logger.info(`AuthzLinid1: group membership delegated through the ${this.groupAdminAttr} attribute`);const t=this.globalOrganization(),s=t&&this.globalBranches().find(e=>o(t,e));if(s)throw new Error(`AuthzLinid1: --authz-global-organization ${t} lies in the global branch ${s}`);const r=t&&this.globalWriteBranches().find(e=>o(t,e));if(r)throw new Error(`AuthzLinid1: --authz-global-organization ${t} lies in the global write branch ${r}`);for(const e of this.globalWriteBranches()){const t=this.globalBranches().find(t=>o(e,t)||o(t,e));if(t)throw new Error(`AuthzLinid1: the global branch ${t} and the global write branch ${e} overlap`)}const i=[this.adminAttr],touches=(e,t)=>{const s=[...Object.keys(e.add??{}),...Object.keys(e.replace??{}),...Array.isArray(e.delete)?e.delete:Object.keys(e.delete??{})].map(e=>e.split(";")[0].toLowerCase());return t.some(e=>s.includes(e.toLowerCase()))};this.hooks={...this.declaredByBase,ldapsearchrequest:e=>this.widenForDelegation(e),ldapsearchfilter:e=>this.filterDelegatedGroups(e),ldapmodifyrequest:e=>this.judgeDelegatedGroup(e),ldapmodifydone:(e,t,s,r)=>(touches(t,i)&&this.forgetPermissions(),touches(t,this.statusAttributes())&&this.clearResolutionCache(),this.declaredByBase.ldapmodifydone?.(e,t,s,r)),ldapdeletedone:(e,t)=>(this.forgetPermissions(),this.declaredByBase.ldapdeletedone?.(e,t)),ldaprenamedone:(e,t,s)=>(this.forgetPermissions(),this.declaredByBase.ldaprenamedone?.(e,t,s))}}forgetPermissions(){this.permissionsGeneration++,this.permissionsCache.clear()}async widenForDelegation([e,t,s]){const[i,n,a]=await this.declaredByBase.ldapsearchrequest([e,t,s]),o=this.groupAdminAttr;if(!o||this.shouldSkipAuthorization(s))return[i,n,a];let h=n;if(!r.wantsAttribute(h,o)){const e=h.attributes,t=Array.isArray(e)?e.map(String):[String(e)];h={...h,attributes:[...t,o],[AuthzLinid1.widenedForDelegation]:!0}}return[i,h,a]}async filterDelegatedGroups([t,s,i]){const n=this.declaredByBase.ldapsearchfilter,passThrough=async()=>{const e=await n([t,s,i]);return this.stripWidened(e[0],e[2]),e},a=this.groupAdminAttr;if(!a||!t?.searchEntries?.length)return passThrough();if(this.shouldSkipAuthorization(s))return passThrough();const o=await this.resolveCaller(s);if(!o)return passThrough();const h=new Map;for(const e of t.searchEntries){const t=String(e.dn);this.isGlobalBranch(t)||0!==this.delegationsOf(e,a).length&&h.set(this.dnKey(t),e)}const d=await n([t,s,i]),[c,,l]=d;if(h.size>0){const t=c.searchEntries??[],s=new Set(t.map(e=>this.dnKey(String(e.dn)))),n=await this.memberFilterNames();let d=!1;if(i?.filter)try{d=filterTouchesReduced(e.parseString(String(i.filter)),n)}catch{d=!0}const l=this.config.ldap_organization_link_attribute,u=i?.[r.widenedForLink],g=i?.[r.widenedForObjectClass],m=await this.getAuthorizedBranches(o),p=new Map,organizationOf=e=>{const t=this.dnKey(e);let s=p.get(t);return s||(s=this.storedOrganization(e),p.set(t,s)),s},manages=async(e,t)=>{const s=await organizationOf(e);return!!s&&(!!this.withinDelegation(s,t)&&!0===(await this.getUserPermissions(o,s)).write)};for(const[e,r]of h){if(s.has(e)||d)continue;const i=this.delegationsOf(r,a);if(!delegationsIntersect(m,i))continue;const n={...r};await this.keepManagedMembers(n,i,manages),u&&l&&delete n[l],g&&delete n.objectClass,t.push(n)}c.searchEntries=t}return this.stripWidened(c,l),d}stripWidened(e,t){const s=t,r=s?.[AuthzLinid1.widenedForDelegation]?this.groupAdminAttr:void 0;if(r)for(const t of e.searchEntries??[])delete t[r]}async judgeDelegatedGroup([e,t,s,r]){const i=this.declaredByBase.ldapmodifyrequest,n=this.groupAdminAttr;if(!n||this.shouldSkipAuthorization(r))return await i([e,t,s,r]);const a=await this.resolveCaller(r);if(!a)return await i([e,t,s,r]);const o=await this.storedDn(e);if(this.isGlobalBranch(o))return await i([e,t,s,r]);const d=await this.storedGroup(o,n);if(!d||0===d.delegations.length)return await i([e,t,s,r]);const c=this.config.ldap_organization_link_attribute,l=(c?h(d.entry[c]):void 0)??this.extractBranchDn(o);if((await this.getUserPermissions(a,l)).write)return await i([e,t,s,r]);return delegationsIntersect(await this.getAuthorizedBranches(a),d.delegations)?(await this.assertDelegatedChanges(a,d.delegations,t,o),[e,t,s,r]):await i([e,t,s,r])}groupAttributes(){const e=this.server.loadedPlugins.ldapGroups;return{members:s(e?.schema,"members")||"member",owners:s(e?.schema,"owners")||"owner"}}memberFilterNames(){return this.reducedFilterNames??=this.resolveMemberFilterNames(),this.reducedFilterNames}async resolveMemberFilterNames(){const{members:e,owners:t}=this.groupAttributes(),s=new Set,add=e=>{const t=a(e);t&&s.add(t)};for(const s of[e,t])add(s);add("distinguishedName");try{const s=await this.server.ldap.schemaIndex();for(const r of[e,t]){let e=s.getAttributeType(r);const t=new Set;for(;e&&!t.has(e.oid);){t.add(e.oid),add(e.oid);for(const t of e.names)add(t);e=e.sup?s.getAttributeType(e.sup):void 0}}}catch(e){this.reducedFilterNames=void 0,this.logger.debug(`AuthzLinid1: could not resolve member attribute types: ${e}`)}return s}isGlobalBranch(e){return this.globalBranches().some(t=>this.within(e,t))}async keepManagedMembers(e,t,s){const{members:r,owners:i}=this.groupAttributes();for(const n of new Set([r,i])){const r=e[n];if(null==r)continue;const i=[];for(const e of(Array.isArray(r)?r:[r]).map(String))await s(e,t)&&i.push(e);e[n]=i}}delegationsOf(e,t){const s=e[t];return null==s?[]:(Array.isArray(s)?s:[s]).map(e=>String(e).trim()).filter(Boolean)}withinDelegation(e,t){return t.some(t=>this.within(e,t))}async storedOrganization(e){const t=this.config.ldap_organization_link_attribute;if(t)try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[t]},e);return h(s.searchEntries?.[0]?.[t])}catch{return}}async storedGroup(e,t){const s=this.config.ldap_organization_link_attribute,r=["dn",t,...s?[s]:[]];try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:r},e),i=s.searchEntries?.[0];if(!i)return;return{entry:i,delegations:this.delegationsOf(i,t)}}catch{return}}dnKey(e){try{return d(e)}catch{return e.toLowerCase()}}within(e,t){try{return o(e,t)}catch{return!1}}async assertDelegatedChanges(e,t,s,r){const{members:i}=this.groupAttributes(),same=e=>e.split(";")[0].toLowerCase()===i.toLowerCase(),refuse=t=>{throw new n(`User ${e} may only add or remove members of ${r}, not ${t}`)};if(s.replace&&Object.keys(s.replace).length>0&&refuse("replace an attribute"),s.add)for(const e of Object.keys(s.add))same(e)||refuse(e);if(Array.isArray(s.delete))s.delete.length>0&&refuse(`delete ${s.delete[0]}`);else if(s.delete)for(const e of Object.keys(s.delete))same(e)||refuse(e);const values=e=>{if(!e)return;const t=Object.keys(e).find(e=>same(e));if(void 0===t)return;const s=e[t];return(Array.isArray(s)?s:[s]).map(e=>String(e))},a=values(s.add),o=Array.isArray(s.delete)?void 0:values(s.delete);a&&0===a.length&&refuse("add no member"),o&&0===o.length&&refuse("delete every member");for(const s of[...a??[],...o??[]])if(!this.isDummyMember(s)&&!await this.managesMember(e,t,s))throw new n(`User ${e} may not manage member ${s} of ${r}`)}async managesMember(e,t,s){const r=await this.storedOrganization(s);return!!r&&(!!this.withinDelegation(r,t)&&!0===(await this.getUserPermissions(e,r)).write)}isDummyMember(e){return c(e,this.config.group_dummy_user)}async delegatedGroups(e){const t=this.groupAdminAttr,s=this.server.loadedPlugins.ldapGroups?.base;if(!t||!s)return[];const r=await this.getAuthorizedBranches(e);if(0===r.length)return[];try{const e=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:["dn",t]},s),i=[];for(const s of e.searchEntries??[]){const e=String(s.dn);this.isGlobalBranch(e)||delegationsIntersect(r,this.delegationsOf(s,t))&&i.push({dn:e,manageMembers:!0})}return i}catch(e){return this.logger.error(`AuthzLinid1: could not read delegated groups: ${e}`),[]}}async administeredBranches(e){const t=this.config.ldap_top_organization||this.config.ldap_base||"";if(!t)return[];return((await this.server.ldap.system.search({paged:!1,filter:`(${this.adminAttr}=${l(e)})`,attributes:["dn"],scope:"sub"},t)).searchEntries??[]).map(e=>String(e.dn))}async mayLinkAnyDomain(e){if(await super.mayLinkAnyDomain(e))return!0;const t=this.globalOrganization();return!!t&&!0===(await this.getUserPermissions(e,t)).write}globalOrganization(){return this.options(u).authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.options(u).authz_global_branch}globalWriteBranches(){return this.options(u).authz_global_write_branch}isGlobalWrite(e){return this.globalWriteBranches().some(t=>o(e,t))}sourcePermission(e){return this.isGlobalWrite(e)?"delete":"read"}async resolveUser(e){const t=await this.getUserDn(e);if(!t)return null;if(!await this.enabled(t))throw new n(`User ${e} is not enabled`);return t}accountStatus(e){const t=this.server.loadedPlugins.ldapFlatGeneric,r=(t?.instances??[]).filter(t=>t.schema&&t.base&&o(e,t.base)).sort((e,t)=>d(t.base).length-d(e.base).length);for(const e of r){const t=s(e.schema,"accountStatus"),r=t?e.schema?.attributes[t]?.states?.enabled:void 0,i=h(r);if(t&&i)return{attribute:t,enabled:i}}}async enabled(e){const t=this.accountStatus(e);if(!t)return!0;const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[t.attribute]},e),r=s.searchEntries?.[0],i=Object.keys(r??{}).find(e=>e.split(";")[0].toLowerCase()===t.attribute.toLowerCase()),n=h(i?r?.[i]:void 0);return void 0===n||d(n)===d(t.enabled)}statusAttributes(){const e=this.server.loadedPlugins.ldapFlatGeneric,t=new Set;for(const r of e?.instances??[]){const e=s(r.schema,"accountStatus");e&&t.add(e)}return[...t]}async getUserDn(e){return this.findUserDn(e)}async getUserPermissions(e,t){const s=this.globalOrganization();if(this.globalBranches().some(e=>o(t,e)))return s&&!o(s,t)?this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(t)){const r=s&&!o(s,t)?await this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};return{read:r.read||(await this.getAuthorizedBranches(e)).length>0,write:r.write,delete:r.delete}}return this.ownPermissions(e,t)}async ownPermissions(e,t){const s=Date.now(),r=this.permissionsCache.get(e);if(r&&s-r.timestamp<this.cacheTTL){const e=this.findBranchPermissions(r.branches,t);if(e)return e}const i=await this.refreshUserPermissions(e),n=this.findBranchPermissions(i,t);return n||{read:!1,write:!1,delete:!1}}async refreshUserPermissions(e){const t=this.permissionsGeneration,s=new Map;try{const t=`(${this.adminAttr}=${l(e)})`,r=this.config.ldap_top_organization||this.config.ldap_base||"",i=await this.server.ldap.system.search({paged:!1,filter:t,attributes:["dn"],scope:"sub"},r);if(i.searchEntries)for(const e of i.searchEntries){const t="string"==typeof e.dn?e.dn:String(e.dn);s.set(t,{read:!0,write:!0,delete:!0})}}catch(t){this.logger.error(`Failed to refresh permissions for user ${e}: ${t}`)}return t===this.permissionsGeneration&&this.permissionsCache.set(e,{branches:s,timestamp:Date.now()}),s}async getAuthorizedBranches(e){const t=Date.now(),s=this.permissionsCache.get(e);return!s||t-s.timestamp>=this.cacheTTL?Array.from((await this.refreshUserPermissions(e)).keys()):Array.from(s.branches.keys())}ruleBranches(e){return this.getAuthorizedBranches(e)}findBranchPermissions(e,t){return i(e.entries(),t)}}export{AuthzLinid1 as default};
@@ -1 +1 @@
1
- import{Type as e}from"@sinclair/typebox";import s from"../../abstract/plugin.js";import{identityFor as t}from"../../lib/auth/base.js";import{servesRequest as i,overlap as n,EVERYONE as o,authzFor as a}from"../../lib/authz/composition.js";import{UnauthorizedError as r}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";import{defineRoute as l}from"../../lib/route.js";const u=e.Object({user:e.Union([e.String(),e.Null()]),unrestricted:e.Boolean(),described:e.Boolean(),source:e.Union([e.String(),e.Null()]),sources:e.Array(e.String()),branches:e.Array(e.Object({dn:e.String(),read:e.Optional(e.Boolean()),write:e.Optional(e.Boolean()),delete:e.Optional(e.Boolean()),name:e.Optional(e.String()),path:e.Optional(e.String())})),transit:e.Optional(e.Union([e.String(),e.Null()])),linksAnyDomain:e.Boolean(),passwordPolicies:e.Optional(e.Boolean()),entities:e.Array(e.Object({name:e.String(),base:e.String(),create:e.Boolean()}))});class AuthzScope extends s{name="authzScope";roles=["api"];api(e){l(e,{method:"get",path:`${this.config.api_prefix}/v1/authz/scope`,responses:{200:u},handler:async({req:e,res:s})=>this.scope(e,s)})}static canDescribe(e){if(!e.roles?.includes("authz"))return!1;const s=e;return"function"==typeof s.resolveUser&&"function"==typeof s.getAuthorizedBranches&&"function"==typeof s.getUserPermissions}describers(){return Object.values(this.server.loadedPlugins).filter(e=>AuthzScope.canDescribe(e))}static judges(e,s){return e.roles?.includes("auth")?Boolean(s.authenticators?.includes(e.name)):Boolean(s.user)&&i(s,e.config)}assertComposition(){const e=this.config.authz_scope_source;if(!e)return;const s=this.describers().map(e=>e.name);if(!s.includes(e))throw new Error(`${this.name}: --authz-scope-source names ${e}, which is not a loaded plugin able to describe a scope`+(s.length>0?` (loaded: ${s.join(", ")})`:""))}afterLoad(){if(this.config.authz_scope_source)return;const e=this.describers(),s=this.server.authenticators,population=e=>a(e.config,e.name)??o;for(let t=0;t<e.length;t++)for(let i=t+1;i<e.length;i++){const o=e[t],a=e[i];if(n(population(o),population(a),s))return void this.logger.warn(`${this.name}: ${o.name} and ${a.name} can both describe the scope of one caller, and ${o.name} answers because it loaded first. Set --authz-scope-source to choose; the answer lists both under \`sources\``)}}entities(){const e=(this.config.ldap_organization_link_attribute||"").toLowerCase(),linkable=s=>Boolean(e)&&(!s?.attributes||Object.keys(s.attributes).some(s=>s.toLowerCase()===e)),s=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const e of t?.instances||[])s.push({name:e.pluralName,base:e.base,linkable:linkable(e.schema)});const i=this.server.loadedPlugins.ldapGroups;return i?.base&&s.push({name:"groups",base:i.base,linkable:linkable(i.schema)}),this.server.loadedPlugins.ldapOrganizations&&this.config.ldap_top_organization&&s.push({name:"organizations",base:this.config.ldap_top_organization,linkable:!1}),s}async scope(e,s){const i=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new r("No authenticated user");const n=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),o=i.filter(e=>n.includes(e)),a=this.config.authz_scope_source,c=o.find(e=>e.name===a)??o[0],l=n.map(e=>e.name);if(!c)return 0===n.length?void s.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],linksAnyDomain:!0,...this.passwordPolicies(!0),entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void s.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:l,branches:[],linksAnyDomain:!1,...this.passwordPolicies(!1),entities:[]});const u=t(e,c.config).value,h=await c.resolveUser(u);if(!h)throw new r(`Unknown user ${u}`);const d=await c.getAuthorizedBranches(h),p=[],g=new Map,permissionsOn=async e=>{let s=g.get(e);return s||(s=await c.getUserPermissions(h,e),g.set(e,s)),s};for(const e of d){const s=await permissionsOn(e);p.push({dn:e,...s,...await this.branchLabel(e)})}const f=d.some(e=>!0===g.get(e)?.write),m=this.config.ldap_top_organization,b=[];for(const{name:e,base:s,linkable:t}of this.entities()){const i=m&&s===m?!0===(await permissionsOn(m)).write:t?f:!0===(await permissionsOn(s)).write;b.push({name:e,base:s,create:i})}s.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:l,branches:p,transit:c.transitBranch?.()??null,linksAnyDomain:!0===await(c.mayLinkAnyDomain?.(h)),...this.passwordPolicies(await this.readsPolicies(c,h)),entities:b})}policyPlugin(){return this.server.loadedPlugins.ldapPasswordPolicy}passwordPolicies(e){return this.policyPlugin()?{passwordPolicies:e}:{}}async readsPolicies(e,s){const t=this.policyPlugin();return!(!t||!e.searchRefusal)&&void 0===await e.searchRefusal(s,t.policyBase())}async branchLabel(e){const s=this.server.loadedPlugins.ldapOrganizations,t=c(s?.schema,"organizationPath")||this.config.ldap_organization_path_attribute;try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),i=s?.searchEntries?.[0];if(!i)return{};const first=e=>{const s=Array.isArray(e)?e[0]:e;return"string"==typeof s?s||void 0:Buffer.isBuffer(s)&&s.toString("utf8")||void 0};return{name:first(i.ou)||first(i.o)||first(i.cn),path:t?first(i[t]):void 0}}catch(e){return{}}}}export{u as ScopeResponse,AuthzScope as default};
1
+ import{Type as e}from"@sinclair/typebox";import s from"../../abstract/plugin.js";import{identityFor as t}from"../../lib/auth/base.js";import{servesRequest as i,overlap as n,EVERYONE as a,authzFor as o}from"../../lib/authz/composition.js";import{UnauthorizedError as r}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";import{defineRoute as l}from"../../lib/route.js";const u=e.Object({user:e.Union([e.String(),e.Null()]),unrestricted:e.Boolean(),described:e.Boolean(),source:e.Union([e.String(),e.Null()]),sources:e.Array(e.String()),branches:e.Array(e.Object({dn:e.String(),read:e.Optional(e.Boolean()),write:e.Optional(e.Boolean()),delete:e.Optional(e.Boolean()),name:e.Optional(e.String()),path:e.Optional(e.String())})),transit:e.Optional(e.Union([e.String(),e.Null()])),delegatedGroups:e.Optional(e.Array(e.Object({dn:e.String(),manageMembers:e.Boolean()}))),linksAnyDomain:e.Boolean(),passwordPolicies:e.Optional(e.Boolean()),entities:e.Array(e.Object({name:e.String(),base:e.String(),create:e.Boolean()}))});class AuthzScope extends s{name="authzScope";roles=["api"];api(e){l(e,{method:"get",path:`${this.config.api_prefix}/v1/authz/scope`,responses:{200:u},handler:async({req:e,res:s})=>this.scope(e,s)})}static canDescribe(e){if(!e.roles?.includes("authz"))return!1;const s=e;return"function"==typeof s.resolveUser&&"function"==typeof s.getAuthorizedBranches&&"function"==typeof s.getUserPermissions}describers(){return Object.values(this.server.loadedPlugins).filter(e=>AuthzScope.canDescribe(e))}static judges(e,s){return e.roles?.includes("auth")?Boolean(s.authenticators?.includes(e.name)):Boolean(s.user)&&i(s,e.config)}assertComposition(){const e=this.config.authz_scope_source;if(!e)return;const s=this.describers().map(e=>e.name);if(!s.includes(e))throw new Error(`${this.name}: --authz-scope-source names ${e}, which is not a loaded plugin able to describe a scope`+(s.length>0?` (loaded: ${s.join(", ")})`:""))}afterLoad(){if(this.config.authz_scope_source)return;const e=this.describers(),s=this.server.authenticators,population=e=>o(e.config,e.name)??a;for(let t=0;t<e.length;t++)for(let i=t+1;i<e.length;i++){const a=e[t],o=e[i];if(n(population(a),population(o),s))return void this.logger.warn(`${this.name}: ${a.name} and ${o.name} can both describe the scope of one caller, and ${a.name} answers because it loaded first. Set --authz-scope-source to choose; the answer lists both under \`sources\``)}}entities(){const e=(this.config.ldap_organization_link_attribute||"").toLowerCase(),linkable=s=>Boolean(e)&&(!s?.attributes||Object.keys(s.attributes).some(s=>s.toLowerCase()===e)),s=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const e of t?.instances||[])s.push({name:e.pluralName,base:e.base,linkable:linkable(e.schema)});const i=this.server.loadedPlugins.ldapGroups;return i?.base&&s.push({name:"groups",base:i.base,linkable:linkable(i.schema)}),this.server.loadedPlugins.ldapOrganizations&&this.config.ldap_top_organization&&s.push({name:"organizations",base:this.config.ldap_top_organization,linkable:!1}),s}async scope(e,s){const i=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new r("No authenticated user");const n=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),a=i.filter(e=>n.includes(e)),o=this.config.authz_scope_source,c=a.find(e=>e.name===o)??a[0],l=n.map(e=>e.name);if(!c)return 0===n.length?void s.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],linksAnyDomain:!0,...this.passwordPolicies(!0),entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void s.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:l,branches:[],linksAnyDomain:!1,...this.passwordPolicies(!1),entities:[]});const u=t(e,c.config).value,h=await c.resolveUser(u);if(!h)throw new r(`Unknown user ${u}`);const d=await c.getAuthorizedBranches(h),p=[],g=new Map,permissionsOn=async e=>{let s=g.get(e);return s||(s=await c.getUserPermissions(h,e),g.set(e,s)),s};for(const e of d){const s=await permissionsOn(e);p.push({dn:e,...s,...await this.branchLabel(e)})}const m=d.some(e=>!0===g.get(e)?.write),b=this.config.ldap_top_organization,f=[];for(const{name:e,base:s,linkable:t}of this.entities()){const i=b&&s===b?!0===(await permissionsOn(b)).write:t?m:!0===(await permissionsOn(s)).write;f.push({name:e,base:s,create:i})}const w=await(c.delegatedGroups?.(h));s.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:l,branches:p,transit:c.transitBranch?.()??null,...w&&w.length>0?{delegatedGroups:w}:{},linksAnyDomain:!0===await(c.mayLinkAnyDomain?.(h)),...this.passwordPolicies(await this.readsPolicies(c,h)),entities:f})}policyPlugin(){return this.server.loadedPlugins.ldapPasswordPolicy}passwordPolicies(e){return this.policyPlugin()?{passwordPolicies:e}:{}}async readsPolicies(e,s){const t=this.policyPlugin();return!(!t||!e.searchRefusal)&&void 0===await e.searchRefusal(s,t.policyBase())}async branchLabel(e){const s=this.server.loadedPlugins.ldapOrganizations,t=c(s?.schema,"organizationPath")||this.config.ldap_organization_path_attribute;try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),i=s?.searchEntries?.[0];if(!i)return{};const first=e=>{const s=Array.isArray(e)?e[0]:e;return"string"==typeof s?s||void 0:Buffer.isBuffer(s)&&s.toString("utf8")||void 0};return{name:first(i.ou)||first(i.o)||first(i.cn),path:t?first(i[t]):void 0}}catch(e){return{}}}}export{u as ScopeResponse,AuthzScope as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{crowdsecOptions as r}from"./auth.options.js";class CrowdSec extends e{static optionDefinitions=r;name="crowdsec";roles=["protect"];apiUrl;apiKey;cacheTtl;cache=new Map;constructor(...e){super(...e);const t=this.options(r);if(this.apiUrl=t.crowdsec_url,this.apiKey=t.crowdsec_api_key,this.cacheTtl=t.crowdsec_cache_ttl,!this.apiKey)throw new Error("CrowdSec plugin requires crowdsec_api_key configuration");this.logger.info(`CrowdSec integration enabled with API at ${this.apiUrl} (cache TTL: ${this.cacheTtl}s)`)}api(e){e.use(async(e,r,t)=>{const s=this.getClientIp(e);try{if(await this.checkIpBanned(s))return this.logger.warn(`Blocked request from CrowdSec-banned IP: ${s}`),r.status(403).json({error:"Access denied",reason:"IP address is banned by security policies"});t()}catch(e){this.logger.error(`CrowdSec API error for IP ${s}: ${e instanceof Error?e.message:String(e)}`),t()}})}async checkIpBanned(e){const r=this.cache.get(e),t=Date.now();if(r&&t<r.expiresAt)return this.logger.debug(`CrowdSec cache hit for IP ${e}: ${r.isBanned}`),r.isBanned;const s=`${this.apiUrl}?ip=${encodeURIComponent(e)}`;this.logger.debug(`Querying CrowdSec API: ${s}`);const o=await this.fetch(s,{headers:{"X-Api-Key":this.apiKey}});if(!o.ok)throw new Error(`CrowdSec API returned status ${o.status}: ${o.statusText}`);let i;try{const e=await o.text();i="null"===e||""===e?null:JSON.parse(e)}catch(e){throw this.logger.error(`Failed to parse CrowdSec API response: ${e instanceof Error?e.message:String(e)}`),new Error("Invalid JSON response from CrowdSec API")}let c=!1;return null!==i&&Array.isArray(i)&&(c=i.some(e=>"ban"===e.type)),this.cache.set(e,{isBanned:c,expiresAt:t+1e3*this.cacheTtl}),this.logger.debug(`CrowdSec decision for IP ${e}: ${c?"BANNED":"ALLOWED"}`),c}getClientIp(e){const r=e.headers["x-forwarded-for"];if(r){return(Array.isArray(r)?r[0]:r).split(",")[0].trim()}return e.socket.remoteAddress||"unknown"}}export{CrowdSec as default};
1
+ import e from"../../abstract/plugin.js";import{crowdsecOptions as t}from"./auth.options.js";class CrowdSec extends e{static optionDefinitions=t;name="crowdsec";roles=["protect"];apiUrl;apiKey;cacheTtl;cache=new Map;lastSweep=0;static MAX_CLIENTS=1e4;constructor(...e){super(...e);const s=this.options(t);if(this.apiUrl=s.crowdsec_url,this.apiKey=s.crowdsec_api_key,this.cacheTtl=s.crowdsec_cache_ttl,!this.apiKey)throw new Error("CrowdSec plugin requires crowdsec_api_key configuration");this.logger.info(`CrowdSec integration enabled with API at ${this.apiUrl} (cache TTL: ${this.cacheTtl}s)`)}api(e){e.use(async(e,t,s)=>{const r=this.getClientIp(e);try{if(await this.checkIpBanned(r))return this.logger.warn(`Blocked request from CrowdSec-banned IP: ${r}`),t.status(403).json({error:"Access denied",reason:"IP address is banned by security policies"});s()}catch(e){this.logger.error(`CrowdSec API error for IP ${r}: ${e instanceof Error?e.message:String(e)}`),s()}})}async checkIpBanned(e){const t=this.cache.get(e),s=Date.now();if(t&&s<t.expiresAt)return this.logger.debug(`CrowdSec cache hit for IP ${e}: ${t.isBanned}`),t.isBanned;const r=`${this.apiUrl}?ip=${encodeURIComponent(e)}`;this.logger.debug(`Querying CrowdSec API: ${r}`);const i=await this.fetch(r,{headers:{"X-Api-Key":this.apiKey}});if(!i.ok)throw new Error(`CrowdSec API returned status ${i.status}: ${i.statusText}`);let c;try{const e=await i.text();c="null"===e||""===e?null:JSON.parse(e)}catch(e){throw this.logger.error(`Failed to parse CrowdSec API response: ${e instanceof Error?e.message:String(e)}`),new Error("Invalid JSON response from CrowdSec API")}let o=!1;return null!==c&&Array.isArray(c)&&(o=c.some(e=>"ban"===e.type)),this.cache.has(e)&&this.cache.delete(e),this.prune(s),this.cache.size>=CrowdSec.MAX_CLIENTS&&this.evictOldest(),this.cache.set(e,{isBanned:o,expiresAt:s+1e3*this.cacheTtl}),this.logger.debug(`CrowdSec decision for IP ${e}: ${o?"BANNED":"ALLOWED"}`),o}prune(e){if(!(e-this.lastSweep<1e3*this.cacheTtl)){this.lastSweep=e;for(const[t,s]of this.cache)e>s.expiresAt&&this.cache.delete(t);for(;this.cache.size>=CrowdSec.MAX_CLIENTS;)this.evictOldest()}}evictOldest(){const e=this.cache.keys().next().value;void 0!==e&&this.cache.delete(e)}getClientIp(e){return e.clientIp||e.socket.remoteAddress||"unknown"}}export{CrowdSec as default};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{rateLimitOptions as e}from"./auth.options.js";class RateLimit extends t{static optionDefinitions=e;name="rateLimit";roles=["protect"];store=new Map;windowMs;maxRequests;constructor(...t){super(...t);const s=this.options(e);this.windowMs=s.rate_limit_window_ms,this.maxRequests=s.rate_limit_max,this.logger.info(`Auth rate limiting enabled: ${this.maxRequests} failed attempts per ${this.windowMs/1e3} seconds`)}api(t){this.server.loadedPlugins.trustedProxy||this.logger.warn("Auth rate limiting keys on X-Forwarded-For but core/auth/trustedProxy is not loaded: a client can forge that header and evade the limit. Load core/auth/trustedProxy, or put the API behind a proxy that overwrites the header."),t.use((t,e,s)=>{const i=this.getClientIp(t),r=Date.now(),o=this.store.get(i);o&&r>o.resetTime&&this.store.delete(i);const a=this.store.get(i);if(a&&a.count>=this.maxRequests&&r<=a.resetTime)return this.logger.warn(`Rate limiting ${i}: ${a.count}/${this.maxRequests} failed attempts`),e.status(429).json({error:"Too many authentication attempts, please try again later",retryAfter:Math.ceil((a.resetTime-r)/1e3)});e.on("finish",()=>{if(401===e.statusCode||403===e.statusCode){const t=this.store.get(i),e=Date.now();!t||e>t.resetTime?this.store.set(i,{count:1,resetTime:e+this.windowMs}):t.count++,this.logger.debug(`Failed auth from ${i}: ${this.store.get(i)?.count}/${this.maxRequests}`)}}),s()})}getClientIp(t){const e=t.headers["x-forwarded-for"];if(e){return(Array.isArray(e)?e[0]:e).split(",")[0].trim()}return t.socket.remoteAddress||"unknown"}}export{RateLimit as default};
1
+ import t from"../../abstract/plugin.js";import{rateLimitOptions as e}from"./auth.options.js";class RateLimit extends t{static optionDefinitions=e;name="rateLimit";roles=["protect"];store=new Map;windowMs;maxRequests;lastSweep=0;static MAX_CLIENTS=1e4;constructor(...t){super(...t);const s=this.options(e);this.windowMs=s.rate_limit_window_ms,this.maxRequests=s.rate_limit_max,this.logger.info(`Auth rate limiting enabled: ${this.maxRequests} failed attempts per ${this.windowMs/1e3} seconds`)}api(t){this.server.loadedPlugins.trustedProxy||this.logger.warn("Auth rate limiting ignores X-Forwarded-For because core/auth/trustedProxy is not loaded and uses the TCP peer: requests through a proxy share one counter. Load core/auth/trustedProxy to key on the client it identifies."),t.use((t,e,s)=>{const i=this.getClientIp(t),o=Date.now();this.prune(o);const r=this.store.get(i);if(r&&r.count>=this.maxRequests&&o<=r.resetTime)return this.logger.warn(`Rate limiting ${i}: ${r.count}/${this.maxRequests} failed attempts`),e.status(429).json({error:"Too many authentication attempts, please try again later",retryAfter:Math.ceil((r.resetTime-o)/1e3)});e.on("finish",()=>{if(401===e.statusCode||403===e.statusCode){const t=this.store.get(i),e=Date.now();!t||e>t.resetTime?(t&&this.store.delete(i),this.prune(e),this.store.size>=RateLimit.MAX_CLIENTS&&this.evictOldest(),this.store.set(i,{count:1,resetTime:e+this.windowMs})):t.count++,this.logger.debug(`Failed auth from ${i}: ${this.store.get(i)?.count}/${this.maxRequests}`)}}),s()})}prune(t){if(!(t-this.lastSweep<this.windowMs)){this.lastSweep=t;for(const[e,s]of this.store)t>s.resetTime&&this.store.delete(e);for(;this.store.size>=RateLimit.MAX_CLIENTS;)this.evictOldest()}}evictOldest(){const t=this.store.keys().next().value;void 0!==t&&this.store.delete(t)}getClientIp(t){return t.clientIp||t.socket.remoteAddress||"unknown"}}export{RateLimit as default};
@@ -1 +1 @@
1
- import{createHmac as t}from"crypto";import{unauthorized as e}from"../../lib/expressFormatedResponses.js";import s from"../../lib/auth/base.js";import{totpOptions as r}from"./auth.options.js";class AuthTotp extends s{static optionDefinitions=r;knownIdentities(){return this.totpUsers.map(t=>t.name)}identitySource(){return"req.user and req.userName: the name given in --auth-totp"}name="authTotp";roles=["auth"];totpUsers=[];window;step;constructor(...t){super(...t);const e=this.options(r),s=e.auth_totp;this.window=e.auth_totp_window,this.step=e.auth_totp_step,s.forEach((t,e)=>{const s=t.split(":"),[r,i,o]=s.map(t=>t.trim()),n=void 0===o?6:Number(o),fail=t=>{throw new Error(`Invalid --auth-totp entry at index ${e}: ${t}; entries are separated by \`,\`, \`;\` or newlines`)};(s.length<2||s.length>3||!r||!i)&&fail('expected "secret:name[:digits]"'),this.isValidBase32(r)||fail("the secret is not Base32"),(""===o||!Number.isInteger(n)||n<6||n>10)&&fail("digits must be an integer from 6 to 10"),this.totpUsers.push({name:i,secret:r,digits:n})}),0===this.totpUsers.length?this.logger.warn("No valid TOTP users configured"):this.logger.info(`TOTP authentication initialized with ${this.totpUsers.length} user(s)`)}authMethod(t,s,r){const i=t.headers.authorization;if(!i||!/^Bearer .+/.test(i))return this.logger.warn("Missing or invalid Authorization header"),e(s);const o=i.split(" ")[1];for(const e of this.totpUsers)if(this.verifyTotp(e.secret,o,e.digits))return this.logger.debug(`TOTP authentication successful for user: ${e.name}`),this.publishIdentity(t,e.name),r();return this.logger.warn(`Unauthorized TOTP token: ${o}`),e(s)}verifyTotp(t,e,s){const r=Math.floor(Date.now()/1e3);for(let i=-this.window;i<=this.window;i++){const o=Math.floor(r/this.step)+i;if(this.generateTotp(t,o,s)===e)return!0}return!1}generateTotp(e,s,r){const i=this.base32Decode(e),o=Buffer.alloc(8);o.writeBigUInt64BE(BigInt(s));const n=t("sha1",i);n.update(o);const a=n.digest(),h=15&a[a.length-1];return(((127&a[h])<<24|(255&a[h+1])<<16|(255&a[h+2])<<8|255&a[h+3])%Math.pow(10,r)).toString().padStart(r,"0")}base32Decode(t){let e=t.toUpperCase();for(;e.endsWith("=");)e=e.slice(0,-1);let s=0,r=0;const i=[];for(let t=0;t<e.length;t++){const o="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(e[t]);if(-1===o)throw new Error(`Invalid Base32 character: ${e[t]}`);r=r<<5|o,s+=5,s>=8&&(i.push(r>>>s-8&255),s-=8)}return Buffer.from(i)}isValidBase32(t){return/^[A-Z2-7]+=*$/.test(t.toUpperCase())}}export{AuthTotp as default};
1
+ import{timingSafeEqual as t,createHmac as e}from"crypto";import{unauthorized as s}from"../../lib/expressFormatedResponses.js";import r from"../../lib/auth/base.js";import{totpOptions as o}from"./auth.options.js";class AuthTotp extends r{static optionDefinitions=o;knownIdentities(){return this.totpUsers.map(t=>t.name)}identitySource(){return"req.user and req.userName: the name given in --auth-totp"}name="authTotp";roles=["auth"];totpUsers=[];window;step;lastCounters=new Map;constructor(...t){super(...t);const e=this.options(o),s=e.auth_totp;this.window=e.auth_totp_window,this.step=e.auth_totp_step,s.forEach((t,e)=>{const s=t.split(":"),[r,o,n]=s.map(t=>t.trim()),i=void 0===n?6:Number(n),fail=t=>{throw new Error(`Invalid --auth-totp entry at index ${e}: ${t}; entries are separated by \`,\`, \`;\` or newlines`)};(s.length<2||s.length>3||!r||!o)&&fail('expected "secret:name[:digits]"'),this.isValidBase32(r)||fail("the secret is not Base32"),(""===n||!Number.isInteger(i)||i<6||i>10)&&fail("digits must be an integer from 6 to 10"),this.totpUsers.push({name:o,secret:r,digits:i})}),0===this.totpUsers.length?this.logger.warn("No valid TOTP users configured"):this.logger.info(`TOTP authentication initialized with ${this.totpUsers.length} user(s)`)}authMethod(t,e,r){const o=t.headers.authorization;if(!o||!/^Bearer .+/.test(o))return this.logger.warn("Missing or invalid Authorization header"),s(e);const n=o.replace(/^Bearer\s+/i,""),i=n.lastIndexOf(":");if(i<=0||i===n.length-1)return this.logger.warn("Missing TOTP user: expected Authorization: Bearer <name>:<code>"),s(e);const a=n.slice(0,i),h=n.slice(i+1),u=this.totpUsers.find(t=>t.name===a);if(!u)return this.logger.warn(`Unknown TOTP user: ${a}`),s(e);const p=this.verifyTotp(u.secret,h,u.digits);return null!==p&&this.acceptCounter(u.name,p)?(this.logger.debug(`TOTP authentication successful for user: ${u.name}`),this.publishIdentity(t,u.name),r()):(this.logger.warn(`Unauthorized TOTP token for user: ${a}`),s(e))}acceptCounter(t,e){const s=this.lastCounters.get(t);return!(void 0!==s&&e<=s)&&(this.lastCounters.set(t,e),!0)}verifyTotp(t,e,s){const r=Math.floor(Date.now()/1e3);for(let o=-this.window;o<=this.window;o++){const n=Math.floor(r/this.step)+o,i=this.generateTotp(t,n,s);if(this.sameCode(i,e))return n}return null}sameCode(e,s){const r=Buffer.from(e,"utf8"),o=Buffer.from(s,"utf8");return r.length===o.length&&t(r,o)}generateTotp(t,s,r){const o=this.base32Decode(t),n=Buffer.alloc(8);n.writeBigUInt64BE(BigInt(s));const i=e("sha1",o);i.update(n);const a=i.digest(),h=15&a[a.length-1];return(((127&a[h])<<24|(255&a[h+1])<<16|(255&a[h+2])<<8|255&a[h+3])%Math.pow(10,r)).toString().padStart(r,"0")}base32Decode(t){let e=t.toUpperCase();for(;e.endsWith("=");)e=e.slice(0,-1);let s=0,r=0;const o=[];for(let t=0;t<e.length;t++){const n="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(e[t]);if(-1===n)throw new Error(`Invalid Base32 character: ${e[t]}`);r=r<<5|n,s+=5,s>=8&&(o.push(r>>>s-8&255),s-=8)}return Buffer.from(o)}isValidBase32(t){return/^[A-Z2-7]+=*$/.test(t.toUpperCase())}}export{AuthTotp as default};
@@ -1 +1 @@
1
- import{isIP as r}from"node:net";import e from"../../abstract/plugin.js";import{trustedProxyOptions as t}from"./auth.options.js";class TrustedProxy extends e{static optionDefinitions=t;name="trustedProxy";roles=["protect"];trustedNetworks=[];authHeader;constructor(...r){super(...r);const e=this.options(t),s=e.trusted_proxy;if(0===s.length)throw new Error("TrustedProxy plugin requires trusted_proxy configuration (array of IPs or CIDR ranges)");for(const r of s)try{this.trustedNetworks.push(this.parseCIDR(r))}catch(e){throw new Error(`Invalid trusted proxy address "${r}": ${e instanceof Error?e.message:String(e)}`)}this.authHeader=e.trusted_proxy_auth_header.toLowerCase(),this.logger.info(`Trusted proxy validation enabled for ${s.length} address(es): ${s.join(", ")} (auth header: ${this.authHeader})`)}api(r){r.use((r,e,t)=>{const s=r.socket.remoteAddress;if(!s)return this.logger.debug("No remote address, removing X-Forwarded-For header"),delete r.headers["x-forwarded-for"],t();const o=r.headers["x-forwarded-for"];if(this.isTrustedProxy(s)){r.trustedProxy=!0;const e=r.headers[this.authHeader];e?(r.proxyAuthUser=Array.isArray(e)?e[0]:e,this.logger.debug(`Request from trusted proxy ${s}, Auth-User: ${r.proxyAuthUser}, X-Forwarded-For: ${o?String(o):"(none)"}`)):this.logger.debug(`Request from trusted proxy ${s}, X-Forwarded-For: ${o?String(o):"(none)"}`)}else r.trustedProxy=!1,o&&(this.logger.warn(`Removing X-Forwarded-For header from untrusted source ${s} (was: ${String(o)})`),delete r.headers["x-forwarded-for"]);t()})}isTrustedProxy(r){try{const e=this.parseIP(r);for(const r of this.trustedNetworks)if(this.isInNetwork(e,r))return!0;return!1}catch(e){return this.logger.error(`Failed to parse IP address "${r}": ${e instanceof Error?e.message:String(e)}`),!1}}isInNetwork(r,e){const t=[];if(r.isIPv6===e.isIPv6&&t.push(r.ip),r.isIPv6&&!e.isIPv6){const e=this.mappedIPv4(r.ip);void 0!==e&&t.push(e)}return t.some(r=>(r&e.mask)===(e.ip&e.mask))}mappedIPv4(r){return r>>32n==0xffffn?0xffffffffn&r:void 0}parseIP(e){const t=e.indexOf("%"),s=e.includes(":")&&-1!==t?e.slice(0,t):e,o=r(s);if(6===o)return{ip:this.parseIPv6(s),isIPv6:!0};if(4===o)return{ip:this.parseIPv4(s),isIPv6:!1};throw new Error(`Invalid IP address: ${e}`)}parseCIDR(r){const e=r.indexOf("/"),t=-1===e?r:r.slice(0,e),s=-1===e?void 0:r.slice(e+1);if(t.includes("%"))throw new Error(`Invalid network address: ${t}`);const o=this.parseIP(t);let i,{isIPv6:n}=o,{ip:d}=o;if(void 0!==s){if(!/^\d{1,3}$/.test(s))throw new Error(`Invalid prefix length: ${s}`);if(i=Number(s),i>(n?128:32))throw new Error(`Invalid prefix length: ${s}`)}if(n&&(void 0===i||i>=96)){const r=this.mappedIPv4(d);void 0!==r&&(n=!1,d=r,void 0!==i&&(i-=96))}const a=n?128:32;let f;if(void 0===i)f=(1n<<BigInt(a))-1n;else if(0===i)f=0n;else{const r=BigInt(a);f=(1n<<r)-1n^(1n<<r-BigInt(i))-1n}return{ip:d,mask:f,isIPv6:n}}parseIPv4(e){if(4!==r(e))throw new Error(`Invalid IPv4 address: ${e}`);return e.split(".").reduce((r,e)=>r<<8n|BigInt(Number(e)),0n)}parseIPv6(e){if(6!==r(e))throw new Error(`Invalid IPv6 address: ${e}`);const t=/^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(e);let s,o=e;if(t){const r=this.parseIPv4(t[2]);o=`${t[1]}${(r>>16n).toString(16)}:${(0xffffn&r).toString(16)}`}const i=o.split("::");if(i.length>2)throw new Error(`Invalid IPv6 address: ${e}`);if(2===i.length){const r=i[0]?i[0].split(":"):[],t=i[1]?i[1].split(":"):[],o=8-r.length-t.length;if(o<0)throw new Error(`Invalid IPv6 address: ${e}`);s=[...r,...Array(o).fill("0"),...t]}else s=o.split(":");if(8!==s.length)throw new Error(`Invalid IPv6 address: ${e}`);let n=0n;for(const r of s){if(!/^[0-9a-fA-F]{1,4}$/.test(r))throw new Error(`Invalid IPv6 address: ${e}`);n=n<<16n|BigInt(parseInt(r,16))}return n}}export{TrustedProxy as default};
1
+ import{isIP as r}from"node:net";import e from"../../abstract/plugin.js";import{trustedProxyOptions as t}from"./auth.options.js";class TrustedProxy extends e{static optionDefinitions=t;name="trustedProxy";roles=["protect"];trustedNetworks=[];authHeader;constructor(...r){super(...r);const e=this.options(t),s=e.trusted_proxy;if(0===s.length)throw new Error("TrustedProxy plugin requires trusted_proxy configuration (array of IPs or CIDR ranges)");for(const r of s)try{this.trustedNetworks.push(this.parseCIDR(r))}catch(e){throw new Error(`Invalid trusted proxy address "${r}": ${e instanceof Error?e.message:String(e)}`)}this.authHeader=e.trusted_proxy_auth_header.toLowerCase(),this.logger.info(`Trusted proxy validation enabled for ${s.length} address(es): ${s.join(", ")} (auth header: ${this.authHeader})`)}api(r){r.use((r,e,t)=>{const s=r.socket.remoteAddress;if(!s)return this.logger.debug("No remote address, removing X-Forwarded-For header"),r.clientIp="unknown",delete r.headers["x-forwarded-for"],t();const o=r.headers["x-forwarded-for"];if(this.isTrustedProxy(s)){r.trustedProxy=!0,r.clientIp=this.clientIpFromXff(o,s);const e=r.headers[this.authHeader];e?(r.proxyAuthUser=Array.isArray(e)?e[0]:e,this.logger.debug(`Request from trusted proxy ${s}, Auth-User: ${r.proxyAuthUser}, X-Forwarded-For: ${o?String(o):"(none)"}`)):this.logger.debug(`Request from trusted proxy ${s}, X-Forwarded-For: ${o?String(o):"(none)"}`)}else r.trustedProxy=!1,r.clientIp=s,o&&(this.logger.warn(`Removing X-Forwarded-For header from untrusted source ${s} (was: ${String(o)})`),delete r.headers["x-forwarded-for"]);t()})}clientIpFromXff(r,e){if(!r)return e;const t=(Array.isArray(r)?r.join(","):r).split(",").map(r=>r.trim()).filter(Boolean);for(let r=t.length-1;r>=0;r--){const s=this.forwardedAddress(t[r]);if(!s)return e;if(!this.isTrustedProxy(s))return s}return e}forwardedAddress(e){const t=e.trim();if(!t)return;const s=/^\[([^\]]+)\](?::\d+)?$/.exec(t);if(s)return 0===r(s[1])?void 0:s[1];if(0!==r(t))return t;const o=/^([^:]+):\d+$/.exec(t);return o&&4===r(o[1])?o[1]:void 0}isTrustedProxy(r){try{const e=this.parseIP(r);for(const r of this.trustedNetworks)if(this.isInNetwork(e,r))return!0;return!1}catch(e){return this.logger.error(`Failed to parse IP address "${r}": ${e instanceof Error?e.message:String(e)}`),!1}}isInNetwork(r,e){const t=[];if(r.isIPv6===e.isIPv6&&t.push(r.ip),r.isIPv6&&!e.isIPv6){const e=this.mappedIPv4(r.ip);void 0!==e&&t.push(e)}return t.some(r=>(r&e.mask)===(e.ip&e.mask))}mappedIPv4(r){return r>>32n==0xffffn?0xffffffffn&r:void 0}parseIP(e){const t=e.indexOf("%"),s=e.includes(":")&&-1!==t?e.slice(0,t):e,o=r(s);if(6===o)return{ip:this.parseIPv6(s),isIPv6:!0};if(4===o)return{ip:this.parseIPv4(s),isIPv6:!1};throw new Error(`Invalid IP address: ${e}`)}parseCIDR(r){const e=r.indexOf("/"),t=-1===e?r:r.slice(0,e),s=-1===e?void 0:r.slice(e+1);if(t.includes("%"))throw new Error(`Invalid network address: ${t}`);const o=this.parseIP(t);let i,{isIPv6:n}=o,{ip:d}=o;if(void 0!==s){if(!/^\d{1,3}$/.test(s))throw new Error(`Invalid prefix length: ${s}`);if(i=Number(s),i>(n?128:32))throw new Error(`Invalid prefix length: ${s}`)}if(n&&(void 0===i||i>=96)){const r=this.mappedIPv4(d);void 0!==r&&(n=!1,d=r,void 0!==i&&(i-=96))}const a=n?128:32;let f;if(void 0===i)f=(1n<<BigInt(a))-1n;else if(0===i)f=0n;else{const r=BigInt(a);f=(1n<<r)-1n^(1n<<r-BigInt(i))-1n}return{ip:d,mask:f,isIPv6:n}}parseIPv4(e){if(4!==r(e))throw new Error(`Invalid IPv4 address: ${e}`);return e.split(".").reduce((r,e)=>r<<8n|BigInt(Number(e)),0n)}parseIPv6(e){if(6!==r(e))throw new Error(`Invalid IPv6 address: ${e}`);const t=/^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(e);let s,o=e;if(t){const r=this.parseIPv4(t[2]);o=`${t[1]}${(r>>16n).toString(16)}:${(0xffffn&r).toString(16)}`}const i=o.split("::");if(i.length>2)throw new Error(`Invalid IPv6 address: ${e}`);if(2===i.length){const r=i[0]?i[0].split(":"):[],t=i[1]?i[1].split(":"):[],o=8-r.length-t.length;if(o<0)throw new Error(`Invalid IPv6 address: ${e}`);s=[...r,...Array(o).fill("0"),...t]}else s=o.split(":");if(8!==s.length)throw new Error(`Invalid IPv6 address: ${e}`);let n=0n;for(const r of s){if(!/^[0-9a-fA-F]{1,4}$/.test(r))throw new Error(`Invalid IPv6 address: ${e}`);n=n<<16n|BigInt(parseInt(r,16))}return n}}export{TrustedProxy as default};
@@ -1 +1 @@
1
- import t from"multer";import{parse as e}from"csv-parse/sync";import r from"../../abstract/plugin.js";import{badRequest as i,serverError as s}from"../../lib/expressFormatedResponses.js";import{isAuthzRefusal as o,errorBody as a,AUTHZ_REFUSED as n,HttpError as l}from"../../lib/errors.js";import{loadSchemaFile as c}from"../../lib/schemaFile.js";import{validateDnValue as p,escapeDnValue as d}from"../../lib/utils.js";import{bulkImportOptions as u}from"./ldap.options.js";import{defineRoute as m}from"../../lib/route.js";const h=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)(;[A-Za-z0-9-]+)*$/;class LdapBulkImport extends r{static optionDefinitions=u;name="ldapBulkImport";roles=["configurable"];settings;resources=new Map;upload;constructor(e){super(e),this.settings=this.options(u);const r=parseInt(this.settings.bulk_import_max_file_size,10)||10485760;this.upload=t({storage:t.memoryStorage(),limits:{fileSize:r},fileFilter:(t,e,r)=>{"text/csv"===e.mimetype||e.originalname.endsWith(".csv")?r(null,!0):r(new Error("Only CSV files are allowed"))}});const i=this.settings.bulk_import_schemas;if(!i)return void this.logger.warn("No bulk import schemas configured");const s=i.split(",").map(t=>t.trim());for(const t of s){const[e,r]=t.split(":").map(t=>t.trim());if(e&&r)try{const t=c(r,{config:this.config});this.resources.set(e,{name:e,schema:t,base:t.base||this.config.ldap_base,mainAttribute:t.mainAttribute||"cn"}),this.logger.info(`Loaded bulk import resource: ${e} from ${r}`)}catch(t){this.logger.error(`Failed to load schema for ${e}: ${t.message}`)}else this.logger.warn(`Invalid schema entry: ${t}`)}}api(t){for(const[e,r]of this.resources)m(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/bulk-import/${e}/template.csv`,json:!1,handler:({req:t,res:e})=>this.getTemplate(t,e,r)}),m(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/bulk-import/${e}`,middleware:this.upload.single("file"),handler:async({req:t,res:e})=>this.bulkImport(t,e,r)})}getTemplate(t,e,r){const i=this.getEditableAttributes(r.schema);i.push("organizationDn"),e.setHeader("Content-Type","text/csv; charset=utf-8"),e.setHeader("Content-Disposition",`attachment; filename="${r.name}-template.csv"`),e.send(i.join(",")+"\n")}async bulkImport(t,r,c){if(!t.file)return i(r,"No file uploaded");const p="true"===t.body.dryRun||!0===t.body.dryRun,d="true"===t.body.updateExisting||!0===t.body.updateExisting,u="false"!==t.body.continueOnError&&!1!==t.body.continueOnError,m=Date.now(),h={success:!0,total:0,created:0,updated:0,skipped:0,failed:0,errors:[],details:{duration:"",linesProcessed:0}};try{const i=t.file.buffer.toString("utf-8"),s=e(i,{columns:!0,skip_empty_lines:!0,trim:!0});h.total=s.length;for(let e=0;e<s.length;e++){const r=e+2,i=s[e];try{const{dn:e,entry:r,orgDn:s}=this.buildRecord(i,c);await this.authorizeRow(e,r,t),await this.completeRecord(r,s,c);const o=await this.entryExists(e);if(o&&!d){h.skipped++;continue}if(p){o||await this.server.ldap.authorize("ldapaddrequest",[e,r,t]),o&&await this.server.ldap.authorize("ldapmodifyrequest",[e,{replace:r},this.opNumber(),t]),o?h.updated++:h.created++;continue}o&&d?(await this.server.ldap.modify(e,{replace:r},t),h.updated++):(await this.server.ldap.add(e,r,t),h.created++)}catch(t){if(h.failed++,h.errors.push({line:r,identifier:i[c.mainAttribute],...o(t)?a(t,n):{error:t.message,...t instanceof l?t.refusal:void 0}}),!u)throw t}}h.details.linesProcessed=s.length,h.details.duration=`${((Date.now()-m)/1e3).toFixed(1)}s`,r.json(h)}catch(t){return s(r,t)}}async authorizeRow(t,e,r){try{await this.server.ldap.authorize("ldapaddrequest",[t,e,r])}catch(i){if(!o(i))throw i;try{await this.server.ldap.authorize("ldapmodifyrequest",[t,{replace:e},this.opNumber(),r])}catch{throw i}}}buildRecord(t,e){const r={},i=e.schema,s=t.organizationDn;delete t.organizationDn;const o=new Set;for(const[t,e]of Object.entries(i.properties||{}))if(!0===e.fixed){const i=e.default||e.const;void 0!==i&&(r[t]=i,o.add(t.toLowerCase()))}for(const[e,i]of Object.entries(t))if(i&&""!==i.trim()){if(!h.test(e))throw new Error(`Invalid column name: ${e}`);if(o.has(e.split(";")[0].toLowerCase()))throw new Error(`Attribute "${e}" is fixed and cannot be imported`);r[e]=i.includes(";")?i.split(";").map(t=>t.trim()):i}if(s){const t=this.findAttributeByRole(i,"organizationLink");t&&(r[t]=s)}const a=e.mainAttribute,n=r[a];if(!n)throw new Error(`Missing main attribute: ${a}`);const l=Array.isArray(n)?String(n[0]):String(n);p(l,a);return{dn:`${a}=${d(l)},${e.base}`,entry:r,orgDn:s}}async completeRecord(t,e,r){const i=r.schema;if(e){const r=this.findAttributeByRole(i,"organizationPath");if(r){const i=(await this.fetchOrganization(e))[r];t[r]=Array.isArray(i)?i[0]:i}}for(const[e,r]of Object.entries(i.properties||{}))if(!0===r.required&&!t[e])throw new Error(`Missing required attribute: ${e}`)}getEditableAttributes(t){const e=[];for(const[r,i]of Object.entries(t.properties||{}))!0!==i.fixed&&(i.role?.includes("organizationLink")||i.role?.includes("organizationPath")||e.push(r));return e}findAttributeByRole(t,e){for(const[r,i]of Object.entries(t.properties||{}))if(i.role?.includes(e))return r;return null}async fetchOrganization(t){const e=await this.server.ldap.system.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new Error(`Organization not found: ${t}`);return e.searchEntries[0]}async entryExists(t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base"},t)).searchEntries.length>0}catch(t){return!1}}getConfigApiData(){const t=this.config.api_prefix||"/api",e=[];return this.resources.forEach((r,i)=>{e.push({name:i,mainAttribute:r.mainAttribute,base:r.base,maxFileSize:parseInt(this.settings.bulk_import_max_file_size,10)||10485760,batchSize:parseInt(this.settings.bulk_import_batch_size,10)||100,endpoints:{template:`${t}/v1/ldap/bulk-import/${i}/template.csv`,import:`${t}/v1/ldap/bulk-import/${i}`}})}),{enabled:!0,resources:e}}}export{LdapBulkImport as default};
1
+ import t from"multer";import{parse as e}from"csv-parse/sync";import r from"../../abstract/plugin.js";import{badRequest as i,serverError as s}from"../../lib/expressFormatedResponses.js";import{isAuthzRefusal as o,errorBody as a,AUTHZ_REFUSED as n,HttpError as l}from"../../lib/errors.js";import{loadSchemaFile as c}from"../../lib/schemaFile.js";import{validateDnValue as p,escapeDnValue as d}from"../../lib/utils.js";import{bulkImportOptions as u}from"./ldap.options.js";import{defineRoute as m}from"../../lib/route.js";const h=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)(;[A-Za-z0-9-]+)*$/;class LdapBulkImport extends r{static optionDefinitions=u;name="ldapBulkImport";roles=["configurable"];settings;resources=new Map;upload;bases(){return[...new Set([...this.resources.values()].map(t=>t.base))]}constructor(e){super(e),this.settings=this.options(u);const r=parseInt(this.settings.bulk_import_max_file_size,10)||10485760;this.upload=t({storage:t.memoryStorage(),limits:{fileSize:r},fileFilter:(t,e,r)=>{"text/csv"===e.mimetype||e.originalname.endsWith(".csv")?r(null,!0):r(new Error("Only CSV files are allowed"))}});const i=this.settings.bulk_import_schemas;if(!i)return void this.logger.warn("No bulk import schemas configured");const s=i.split(",").map(t=>t.trim());for(const t of s){const[e,r]=t.split(":").map(t=>t.trim());if(e&&r)try{const t=c(r,{config:this.config});this.resources.set(e,{name:e,schema:t,base:t.base||this.config.ldap_base,mainAttribute:t.mainAttribute||"cn"}),this.logger.info(`Loaded bulk import resource: ${e} from ${r}`)}catch(t){this.logger.error(`Failed to load schema for ${e}: ${t.message}`)}else this.logger.warn(`Invalid schema entry: ${t}`)}}api(t){for(const[e,r]of this.resources)m(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/bulk-import/${e}/template.csv`,json:!1,handler:({req:t,res:e})=>this.getTemplate(t,e,r)}),m(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/bulk-import/${e}`,middleware:this.upload.single("file"),handler:async({req:t,res:e})=>this.bulkImport(t,e,r)})}getTemplate(t,e,r){const i=this.getEditableAttributes(r.schema);i.push("organizationDn"),e.setHeader("Content-Type","text/csv; charset=utf-8"),e.setHeader("Content-Disposition",`attachment; filename="${r.name}-template.csv"`),e.send(i.join(",")+"\n")}async bulkImport(t,r,c){if(!t.file)return i(r,"No file uploaded");const p="true"===t.body.dryRun||!0===t.body.dryRun,d="true"===t.body.updateExisting||!0===t.body.updateExisting,u="false"!==t.body.continueOnError&&!1!==t.body.continueOnError,m=Date.now(),h={success:!0,total:0,created:0,updated:0,skipped:0,failed:0,errors:[],details:{duration:"",linesProcessed:0}};try{const i=t.file.buffer.toString("utf-8"),s=e(i,{columns:!0,skip_empty_lines:!0,trim:!0});h.total=s.length;for(let e=0;e<s.length;e++){const r=e+2,i=s[e];try{const{dn:e,entry:r,orgDn:s}=this.buildRecord(i,c);await this.authorizeRow(e,r,t),await this.completeRecord(r,s,c);const o=await this.entryExists(e);if(o&&!d){h.skipped++;continue}if(p){o||await this.server.ldap.authorize("ldapaddrequest",[e,r,t]),o&&await this.server.ldap.authorize("ldapmodifyrequest",[e,{replace:r},this.opNumber(),t]),o?h.updated++:h.created++;continue}o&&d?(await this.server.ldap.modify(e,{replace:r},t),h.updated++):(await this.server.ldap.add(e,r,t),h.created++)}catch(t){if(h.failed++,h.errors.push({line:r,identifier:i[c.mainAttribute],...o(t)?a(t,n):{error:t.message,...t instanceof l?t.refusal:void 0}}),!u)throw t}}h.details.linesProcessed=s.length,h.details.duration=`${((Date.now()-m)/1e3).toFixed(1)}s`,r.json(h)}catch(t){return s(r,t)}}async authorizeRow(t,e,r){try{await this.server.ldap.authorize("ldapaddrequest",[t,e,r])}catch(i){if(!o(i))throw i;try{await this.server.ldap.authorize("ldapmodifyrequest",[t,{replace:e},this.opNumber(),r])}catch{throw i}}}buildRecord(t,e){const r={},i=e.schema,s=t.organizationDn;delete t.organizationDn;const o=new Set;for(const[t,e]of Object.entries(i.properties||{}))if(!0===e.fixed){const i=e.default||e.const;void 0!==i&&(r[t]=i,o.add(t.toLowerCase()))}for(const[e,i]of Object.entries(t))if(i&&""!==i.trim()){if(!h.test(e))throw new Error(`Invalid column name: ${e}`);if(o.has(e.split(";")[0].toLowerCase()))throw new Error(`Attribute "${e}" is fixed and cannot be imported`);r[e]=i.includes(";")?i.split(";").map(t=>t.trim()):i}if(s){const t=this.findAttributeByRole(i,"organizationLink");t&&(r[t]=s)}const a=e.mainAttribute,n=r[a];if(!n)throw new Error(`Missing main attribute: ${a}`);const l=Array.isArray(n)?String(n[0]):String(n);p(l,a);return{dn:`${a}=${d(l)},${e.base}`,entry:r,orgDn:s}}async completeRecord(t,e,r){const i=r.schema;if(e){const r=this.findAttributeByRole(i,"organizationPath");if(r){const i=(await this.fetchOrganization(e))[r];t[r]=Array.isArray(i)?i[0]:i}}for(const[e,r]of Object.entries(i.properties||{}))if(!0===r.required&&!t[e])throw new Error(`Missing required attribute: ${e}`)}getEditableAttributes(t){const e=[];for(const[r,i]of Object.entries(t.properties||{}))!0!==i.fixed&&(i.role?.includes("organizationLink")||i.role?.includes("organizationPath")||e.push(r));return e}findAttributeByRole(t,e){for(const[r,i]of Object.entries(t.properties||{}))if(i.role?.includes(e))return r;return null}async fetchOrganization(t){const e=await this.server.ldap.system.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new Error(`Organization not found: ${t}`);return e.searchEntries[0]}async entryExists(t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base"},t)).searchEntries.length>0}catch(t){return!1}}getConfigApiData(){const t=this.config.api_prefix||"/api",e=[];return this.resources.forEach((r,i)=>{e.push({name:i,mainAttribute:r.mainAttribute,base:r.base,maxFileSize:parseInt(this.settings.bulk_import_max_file_size,10)||10485760,batchSize:parseInt(this.settings.bulk_import_batch_size,10)||100,endpoints:{template:`${t}/v1/ldap/bulk-import/${i}/template.csv`,import:`${t}/v1/ldap/bulk-import/${i}`}})}),{enabled:!0,resources:e}}}export{LdapBulkImport 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=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
+ 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{filterAttributeNames as u}from"../../lib/ldapFilter.js";import{rawOptions as m}from"./ldap.options.js";import{defineRoute as b}from"../../lib/route.js";const w=["base","one","sub"],f=["userpassword","pwdhistory","sambantpassword","sambalmpassword","sambapasswordhistory","krbprincipalkey","krbextradata","krbpwdhistory","unicodepwd","dbcspwd","lmpwdhistory","ntpwdhistory","supplementalcredentials","userpkcs12","authpassword"],y=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}))}),g=t.Object({dn:t.String({minLength:1})}),S=t.Object({children:t.Optional(t.String())});class LdapRaw extends s{static optionDefinitions=m;name="ldapRaw";roles=["api"];bases;hiddenAttributes;hiddenTypeNames;hiddenFilterNames;showSecrets;maxResults;constructor(t){super(t);const e=this.options(m),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?[]:f,...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 u(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`;b(t,{method:"get",path:`${e}/bases`,handler:async({res:t})=>{await r(t,()=>({bases:this.bases}))}}),b(t,{method:"get",path:`${e}/rootdse`,handler:async({res:t})=>{await r(t,this.getRootDse.bind(this))}}),b(t,{method:"get",path:`${e}/schema`,handler:async({res:t})=>{await r(t,this.getSchema.bind(this))}}),b(t,{method:"get",path:`${e}/search`,query:y,handler:async({req:t,res:e,query:s})=>{const i=s.scope??"sub";if(!w.includes(i))throw new o(`Invalid scope "${i}", expected one of ${w.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)}}),b(t,{method:"get",path:`${e}/children/:dn`,params:g,query:S,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)}}),b(t,{method:"get",path:`${e}/entry/:dn`,params:g,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{S as RawChildrenQuery,g as RawDnParams,y as RawSearchQuery,LdapRaw as default};
@@ -1 +1 @@
1
- import e from"../abstract/plugin.js";import{requestId as r}from"../lib/changeContext.js";import{runWithLogContext as o}from"../lib/requestContext.js";class WebLogs extends e{name="weblogs";roles=["logging"];api(e){e.use((e,r,o)=>{let t=!0;this.logger.debug(`Incoming request: ${e.method} ${e.originalUrl}`);const s=Date.now();r.on("finish",()=>{const o={};e.proxyAuthUser?o.user=e.proxyAuthUser:e.user&&(o.user=e.user),t&&this.log(e,r,s,o),t=!1}),r.on("error",o=>{t&&this.log(e,r,s,{error:o.message}),t=!1}),r.on("close",()=>{r.writableEnded||(t&&this.log(e,r,s,{error:`Connection closed before response was sent for ${e.method} ${e.originalUrl}`}),t=!1)}),o()})}log(e,t,s,n){const i=Date.now()-s,l={...e.logDetails||{},requestId:r(e),method:e.method,url:e.originalUrl,status:t.statusCode,duration:i,ip:this.getClientIp(e),...n};o({requestId:l.requestId},()=>this.logger.notice(l))}getClientIp(e){const r=e.headers["x-forwarded-for"];if(r){return(Array.isArray(r)?r[0]:r).split(",")[0].trim()}return e.socket.remoteAddress||"unknown"}}export{WebLogs as default};
1
+ import e from"../abstract/plugin.js";import{requestId as o}from"../lib/changeContext.js";import{runWithLogContext as t}from"../lib/requestContext.js";class WebLogs extends e{name="weblogs";roles=["logging"];api(e){e.use((e,o,t)=>{let s=!0;this.logger.debug(`Incoming request: ${e.method} ${e.originalUrl}`);const r=Date.now();o.on("finish",()=>{const t={};e.proxyAuthUser?t.user=e.proxyAuthUser:e.user&&(t.user=e.user),s&&this.log(e,o,r,t),s=!1}),o.on("error",t=>{s&&this.log(e,o,r,{error:t.message}),s=!1}),o.on("close",()=>{o.writableEnded||(s&&this.log(e,o,r,{error:`Connection closed before response was sent for ${e.method} ${e.originalUrl}`}),s=!1)}),t()})}log(e,s,r,n){const i=Date.now()-r,l={...e.logDetails||{},requestId:o(e),method:e.method,url:e.originalUrl,status:s.statusCode,duration:i,ip:this.getClientIp(e),...n};t({requestId:l.requestId},()=>this.logger.notice(l))}getClientIp(e){return e.clientIp||e.socket.remoteAddress||"unknown"}}export{WebLogs as default};
@@ -169,8 +169,8 @@ export declare class DM {
169
169
  * Position is the whole point. Guards (`protect`: rate limiting, proxy
170
170
  * trust, CrowdSec) and access logging must see a request *before*
171
171
  * authentication — a rate limiter that runs after a 401 never gets to
172
- * answer 429, and `trustedProxy` strips forged `X-Forwarded-For` headers
173
- * that authentication relies on. Everything else may register routes, and
172
+ * answer 429, and `trustedProxy` publishes the client address those
173
+ * guards and the access log read. Everything else may register routes, and
174
174
  * a route must never precede authentication: mounting one layer here, at
175
175
  * the boundary between the two, is what makes protection independent of
176
176
  * the order plugins happen to load in.
@@ -10,6 +10,10 @@ export interface TotpConfig {
10
10
  digits?: number;
11
11
  step?: number;
12
12
  }
13
+ /** A TOTP client names the user the server must check the code against. */
14
+ export interface TotpClientConfig extends TotpConfig {
15
+ user: string;
16
+ }
13
17
  /**
14
18
  * Generate a TOTP code from a Base32 secret
15
19
  */
@@ -28,7 +32,7 @@ export declare function getRemainingSeconds(step?: number): number;
28
32
  */
29
33
  export declare class TotpAuthClient {
30
34
  private config;
31
- constructor(config: TotpConfig);
35
+ constructor(config: TotpClientConfig);
32
36
  /**
33
37
  * Get current TOTP code
34
38
  */
@@ -45,9 +45,9 @@ export default abstract class AuthzBase extends DmPlugin {
45
45
  * `twake/tombstone` narrows the filter of a SCIM search — hands on a copy.
46
46
  * An object spread copies the key; a set would no longer know the copy.
47
47
  */
48
- private static readonly widenedForLink;
48
+ protected static readonly widenedForLink: unique symbol;
49
49
  /** Searches widened to carry `objectClass`, by the same means and for the same reason. */
50
- private static readonly widenedForObjectClass;
50
+ protected static readonly widenedForObjectClass: unique symbol;
51
51
  roles: Role[];
52
52
  cacheTTL: number;
53
53
  /** What `--authz-unresolved-user` accepts. */
@@ -143,6 +143,14 @@ export default abstract class AuthzBase extends DmPlugin {
143
143
  * off — a relaxation in the default configuration.
144
144
  */
145
145
  protected effectiveBranch(dn: string): Promise<string>;
146
+ /**
147
+ * The flat branches holding entries that are not branches of their own:
148
+ * the user, group and other schema-driven stores. An entry created there is
149
+ * judged on the organization it links to, not on the store it lands in.
150
+ */
151
+ private flatBases;
152
+ /** Whether `dn` is one of the flat branches an attached entry lands in. */
153
+ private isFlatBase;
146
154
  /**
147
155
  * The organization entries are handed over through between
148
156
  * administrators: `--authz-transit-branch`. It only means something when
@@ -282,6 +290,12 @@ export default abstract class AuthzBase extends DmPlugin {
282
290
  protected shouldSkipAuthorization(req?: DmRequest): boolean;
283
291
  /** What `resolveUser` answered for an identity, and when. */
284
292
  private resolutionCache;
293
+ /**
294
+ * Bumped by every invalidation. A resolution that started before a write
295
+ * may have read the status or the identity before it, so it must not put
296
+ * what it read back into the cache afterwards.
297
+ */
298
+ private resolutionGeneration;
285
299
  /** How many identities to remember at once. A cap, not a threshold. */
286
300
  private static readonly RESOLUTION_CACHE_MAX;
287
301
  /** Said once, when the cap is reached: a log line per request helps nobody. */
@@ -294,8 +308,8 @@ export default abstract class AuthzBase extends DmPlugin {
294
308
  * request that *was* authenticated, whose identity this plugin's model
295
309
  * cannot place.
296
310
  *
297
- * That used to return the operation untouched behind a `warn`, which made
298
- * a configuration mismatch an open door rather than a refusal.
311
+ * Returning the operation untouched behind a `warn` made a configuration
312
+ * mismatch an open door rather than a refusal.
299
313
  * `authzLinid1` resolves `req.user` by searching
300
314
  * `(<ldap_user_main_attribute>=<identity>)`, so an authenticator
301
315
  * publishing anything else — `core/auth/openidconnect` publishes the OIDC
@@ -321,10 +335,10 @@ export default abstract class AuthzBase extends DmPlugin {
321
335
  * - a *positive* answer can go stale. `authzLinid1` resolves an identity
322
336
  * to a DN, so an administrator whose own entry is renamed or moved is
323
337
  * still resolved to the former DN, where no organization names them:
324
- * every operation is refused until the TTL runs out. A rename or a
325
- * delete therefore drops what was resolved, which closes the window
326
- * for the case that produces it; a change made directly in the
327
- * directory still waits.
338
+ * every operation is refused until the TTL runs out. A rename, a
339
+ * delete or a status change therefore drops what was resolved, which
340
+ * closes the window for the case that produces it; a change made
341
+ * directly in the directory still waits.
328
342
  *
329
343
  * `authzScope` resolves the identity itself (`authzScope.ts`), outside
330
344
  * this cache, so during such a window it can describe a scope the hooks
@@ -398,7 +412,7 @@ export default abstract class AuthzBase extends DmPlugin {
398
412
  * @param user the resolved caller
399
413
  */
400
414
  mayLinkAnyDomain(user: string): Promise<boolean>;
401
- /** Forget what was resolved, for a test that changes the directory. */
415
+ /** Forget what was resolved: a write changed an identity or its status. */
402
416
  protected clearResolutionCache(): void;
403
417
  /**
404
418
  * Common hooks for all authorization plugins
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Helpers shared by the code that has to know what an LDAP filter tests.
3
+ * @module lib/ldapFilter
4
+ */
5
+ /**
6
+ * The attribute names a parsed filter tests, at any depth. An extensible
7
+ * match carries its attribute in `matchType`.
8
+ *
9
+ * @param filter filter tree returned by `FilterParser.parseString`
10
+ * @returns attribute descriptions, in no particular order
11
+ */
12
+ export declare function filterAttributeNames(filter: unknown): string[];
13
+ /**
14
+ * An attribute description as LDAP compares it: no `oid.` prefix, no options.
15
+ *
16
+ * @param name attribute description from a filter
17
+ * @returns its lowercased base name
18
+ */
19
+ export declare function filterAttributeKey(name: string): string;
@@ -54,6 +54,7 @@ export declare const linid1Options: {
54
54
  authz_global_branch: import("../../config/options.js").ArrayOption;
55
55
  authz_global_write_branch: import("../../config/options.js").ArrayOption;
56
56
  authz_global_organization: import("../../config/options.js").StringOption;
57
+ authz_group_admin_attribute: import("../../config/options.js").StringOption;
57
58
  };
58
59
  export type Linid1Options = OptionsOf<typeof linid1Options>;
59
60
  export declare const openidconnectOptions: {