ldap-rest 0.22.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{assertIdentityMode as r,identityFor as s}from"../auth/base.js";import{AmbiguousIdentityError as i,ForbiddenError as n}from"../errors.js";import{isDnInBranch as a,organizationLink as o,getParentDn as h,normalizeDn as c,escapeLdapFilter as u}from"../utils.js";import{authzFor as l,servesRequest as d}from"./composition.js";class AuthzBase extends t{constructor(t){super(t),this.roles=["authz"],this.resolutionCache=new Map,this.resolutionCacheFullWarned=!1,this.fallbackWarned=!1,this.hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},ldapmodifyrequest:async([t,e,r,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,r,s];const i=await this.resolveCaller(s);if(!i)return[t,e,r,s];const n=this.config.ldap_organization_link_attribute;if(n&&e.replace?.[n]){let r,a=this.extractBranchDn(t),h=!1;try{const e=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[n]},t)).searchEntries[0];e&&(h=!0,r=o(e[n]),r&&(a=r))}catch{}const c=e.replace[n],u=Array.isArray(c)?String(c[0]):String(c),l=this.filtersAttachedEntries(),d=l&&h&&this.inTransit(r),f=l&&this.inTransit(u);if(!d){const t=f?"write":this.sourcePermission(a);if(!(await this.getUserPermissions(i,a))[t])throw new Error(`[authz-forbidden] User ${s.user} does not have ${t} permission for source branch ${a}`)}if(f){if(d){if(0===(await this.getAuthorizedBranches(i)).length)throw new Error(`[authz-forbidden] User ${s.user} administers no branch`)}}else{if(!(await this.getUserPermissions(i,u)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for destination branch ${u}`)}}else{const e=await this.effectiveBranch(t);if(!(await this.getUserPermissions(i,e)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for branch ${e}`)}const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(i,s,t,[...AuthzBase.valuesOf(e.add,a),...AuthzBase.valuesOf(e.replace,a)],a,!1),[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;const n=this.config.ldap_organization_link_attribute;if(n&&e[n]){const t=e[n];i=Array.isArray(t)?String(t[0]):String(t)}else i=this.extractBranchDn(t);if(!(await this.getUserPermissions(s,i)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for branch ${i}`);const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(s,r,t,AuthzBase.valuesOf(e,a),a,!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];if(t===this.config.ldap_top_organization&&"base"===e.scope)return[t,e,r];if(this.filtersAttachedEntries()){if(0===(await this.getAuthorizedBranches(s)).length)throw new Error(`[authz-forbidden] User ${r.user} administers no branch`);if(this.globalBranches().some(e=>a(t,e))&&!(await this.getUserPermissions(s,t)).read)throw new Error(`[authz-forbidden] User ${r.user} does not have read permission for branch ${t}`);const i=this.config.ldap_organization_link_attribute;if(i&&!AuthzBase.wantsAttribute(e,i)){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,i]},AuthzBase.widenedForLink.add(e)}return[t,e,r]}if(!(await this.getUserPermissions(s,t)).read)throw new Error(`[authz-forbidden] User ${r.user} does not have read permission for branch ${t}`);return[t,e,r]},ldapsearchfilter:async([t,e,r])=>{const s=[t,e,r];if(!this.filtersAttachedEntries())return s;if(this.shouldSkipAuthorization(e))return s;if(!t?.searchEntries?.length)return s;const i=await this.resolveCaller(e);if(!i)return s;const n=this.globalBranches();if(n.length){const r=new Map,s=[];for(const o of t.searchEntries){const t=n.find(t=>a(o.dn,t));void 0===t||(r.has(t)||r.set(t,!0===(await this.getUserPermissions(i,t)).read),r.get(t))?s.push(o):this.logger.debug(`${this.name}: hiding ${String(o.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=s}const o=this.config.ldap_organization_link_attribute;if(!o)return s;const h=await this.getAuthorizedBranches(i);if(0===h.length)return s;const within=t=>h.some(e=>a(t,e)),c=this.transitKey();if(t.searchEntries=t.searchEntries.filter(t=>{const r=t[o];if(null==r)return!0;const s=(Array.isArray(r)?r:[r]).map(String);return 0===s.length||(!!s.some(within)||(!!s.some(t=>this.inTransit(t,c))||(this.logger.debug(`${this.name}: hiding ${String(t.dn)} from ${e.user}, attached to ${s[0]}`),!1)))}),r&&AuthzBase.widenedForLink.has(r))for(const e of t.searchEntries)delete e[o];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=this.extractBranchDn(t),n=this.extractBranchDn(e),a=await this.getUserPermissions(s,i),o=this.sourcePermission(i);if(!a[o])throw new Error(`[authz-forbidden] User ${r.user} does not have ${o} permission for source branch ${i}`);if(!(await this.getUserPermissions(s,n)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for destination branch ${n}`);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.effectiveBranch(t);if(!(await this.getUserPermissions(r,s)).delete)throw new Error(`[authz-forbidden] User ${e.user} does not have delete permission for branch ${s}`)}return[t,e]}},r(this.config,this.constructor.name),l(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)}globalBranches(){return[]}sourcePermission(t){return"read"}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.search({paged:!1,scope:"base",attributes:[e]},t),s=o(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&&c(t)===e}transitKey(){const t=this.transitBranch();return void 0===t?void 0:c(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${u(t)})`,r=(await this.server.ldap.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(r.length>1)throw new i(t,r.length);if(0===r.length)return null;const s=r[0].dn;return"string"==typeof s?s:String(s)}shouldSkipAuthorization(t){return!t?.user||!d(t,this.config)}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,a=Date.now(),o=this.resolutionCache.get(i);let h;if(o&&a-o.at<this.cacheTTL)h=o.user;else{if(h=await this.resolveUser(i),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)a-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(i,{user:h,at:a}):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 n(`[authz-forbidden] 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 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.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 o=new Set((n?[]:await this.storedValues(r,i)).map(c)),u=s.filter(t=>!o.has(c(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,d=c(l||this.server.ldap.base),f=new Set;let g=h(r);const w=new Set;for(;g&&!w.has(c(g));){const t=c(g);if(w.add(t),t!==d&&!a(g,d))break;for(const t of await this.storedValues(g,i))f.add(c(t));if(t===d)break;const e=h(g);if(e===g)break;g=e}const p=u.filter(t=>!f.has(c(t)));if(!(0===p.length||l&&(await this.getUserPermissions(t,l)).write))throw new Error(`[authz-forbidden] User ${e.user} may not link ${r} to domain ${p[0]}: no organization above it is linked to it`)}clearResolutionCache(){this.resolutionCache.clear()}}AuthzBase.widenedForLink=new WeakSet,AuthzBase.UNRESOLVED_POLICIES=["deny","allow"],AuthzBase.RESOLUTION_CACHE_MAX=1e4;export{AuthzBase as default};
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{AmbiguousIdentityError as i,ForbiddenError as n}from"../errors.js";import{isDnInBranch as a,organizationLink as o,getParentDn as h,normalizeDn as c,escapeLdapFilter as u}from"../utils.js";import{authzFor as l,servesRequest as d}from"./composition.js";class AuthzBase extends t{constructor(t){super(t),this.roles=["authz"],this.resolutionCache=new Map,this.resolutionCacheFullWarned=!1,this.fallbackWarned=!1,this.hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},ldapmodifyrequest:async([t,e,r,s])=>{if(this.shouldSkipAuthorization(s))return[t,e,r,s];const i=await this.resolveCaller(s);if(!i)return[t,e,r,s];const n=this.config.ldap_organization_link_attribute;if(n&&e.replace?.[n]){let r,a=this.extractBranchDn(t),h=!1;try{const e=(await this.server.ldap.search({paged:!1,scope:"base",attributes:[n]},t)).searchEntries[0];e&&(h=!0,r=o(e[n]),r&&(a=r))}catch{}const c=e.replace[n],u=Array.isArray(c)?String(c[0]):String(c),l=this.filtersAttachedEntries(),d=l&&h&&this.inTransit(r),f=l&&this.inTransit(u);if(!d){const t=f?"write":this.sourcePermission(a);if(!(await this.getUserPermissions(i,a))[t])throw new Error(`[authz-forbidden] User ${s.user} does not have ${t} permission for source branch ${a}`)}if(f){if(d){if(0===(await this.getAuthorizedBranches(i)).length)throw new Error(`[authz-forbidden] User ${s.user} administers no branch`)}}else{if(!(await this.getUserPermissions(i,u)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for destination branch ${u}`)}}else{const e=await this.effectiveBranch(t);if(!(await this.getUserPermissions(i,e)).write)throw new Error(`[authz-forbidden] User ${s.user} does not have write permission for branch ${e}`)}const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(i,s,t,[...AuthzBase.valuesOf(e.add,a),...AuthzBase.valuesOf(e.replace,a)],a,!1),[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;const n=this.config.ldap_organization_link_attribute;if(n&&e[n]){const t=e[n];i=Array.isArray(t)?String(t[0]):String(t)}else i=this.extractBranchDn(t);if(!(await this.getUserPermissions(s,i)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for branch ${i}`);const a=this.domainLinkAttribute();return a&&await this.guardDomainLinks(s,r,t,AuthzBase.valuesOf(e,a),a,!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];if(t===this.config.ldap_top_organization&&"base"===e.scope)return[t,e,r];if(this.filtersAttachedEntries()){if(0===(await this.getAuthorizedBranches(s)).length)throw new Error(`[authz-forbidden] User ${r.user} administers no branch`);if(this.globalBranches().some(e=>a(t,e))&&!(await this.getUserPermissions(s,t)).read)throw new Error(`[authz-forbidden] User ${r.user} does not have read permission for branch ${t}`);const i=this.config.ldap_organization_link_attribute;if(i&&!AuthzBase.wantsAttribute(e,i)){const t=e.attributes,r=Array.isArray(t)?t.map(String):[String(t)];e={...e,attributes:[...r,i]},AuthzBase.widenedForLink.add(e)}return[t,e,r]}if(!(await this.getUserPermissions(s,t)).read)throw new Error(`[authz-forbidden] User ${r.user} does not have read permission for branch ${t}`);return[t,e,r]},ldapsearchfilter:async([t,e,r])=>{const s=[t,e,r];if(!this.filtersAttachedEntries())return s;if(this.shouldSkipAuthorization(e))return s;if(!t?.searchEntries?.length)return s;const i=await this.resolveCaller(e);if(!i)return s;const n=this.globalBranches();if(n.length){const r=new Map,s=[];for(const o of t.searchEntries){const t=n.find(t=>a(o.dn,t));void 0===t||(r.has(t)||r.set(t,!0===(await this.getUserPermissions(i,t)).read),r.get(t))?s.push(o):this.logger.debug(`${this.name}: hiding ${String(o.dn)} from ${e.user}, in the global branch ${t}`)}t.searchEntries=s}const o=this.config.ldap_organization_link_attribute;if(!o)return s;const h=await this.getAuthorizedBranches(i);if(0===h.length)return s;const within=t=>h.some(e=>a(t,e)),c=this.transitKey();if(t.searchEntries=t.searchEntries.filter(t=>{const r=t[o];if(null==r)return!0;const s=(Array.isArray(r)?r:[r]).map(String);return 0===s.length||(!!s.some(within)||(!!s.some(t=>this.inTransit(t,c))||(this.logger.debug(`${this.name}: hiding ${String(t.dn)} from ${e.user}, attached to ${s[0]}`),!1)))}),r&&AuthzBase.widenedForLink.has(r))for(const e of t.searchEntries)delete e[o];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=this.extractBranchDn(t),n=this.extractBranchDn(e),a=await this.getUserPermissions(s,i),o=this.sourcePermission(i);if(!a[o])throw new Error(`[authz-forbidden] User ${r.user} does not have ${o} permission for source branch ${i}`);if(!(await this.getUserPermissions(s,n)).write)throw new Error(`[authz-forbidden] User ${r.user} does not have write permission for destination branch ${n}`);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.effectiveBranch(t);if(!(await this.getUserPermissions(r,s)).delete)throw new Error(`[authz-forbidden] User ${e.user} does not have delete permission for branch ${s}`)}return[t,e]}},r(this.config,this.constructor.name),l(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)}globalBranches(){return[]}sourcePermission(t){return"read"}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.search({paged:!1,scope:"base",attributes:[e]},t),s=o(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&&c(t)===e}transitKey(){const t=this.transitBranch();return void 0===t?void 0:c(t)}async findUserDn(t){const e=`(${this.config.ldap_user_main_attribute||"uid"}=${u(t)})`,r=(await this.server.ldap.search({paged:!1,filter:e,attributes:["dn"],scope:"sub"},this.server.ldap.base)).searchEntries??[];if(r.length>1)throw new i(t,r.length);if(0===r.length)return null;const s=r[0].dn;return"string"==typeof s?s:String(s)}shouldSkipAuthorization(t){return!t?.user||!d(t,this.config)}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,a=Date.now(),o=this.resolutionCache.get(i);let h;if(o&&a-o.at<this.cacheTTL)h=o.user;else{if(h=await this.resolveUser(i),this.resolutionCache.size>=AuthzBase.RESOLUTION_CACHE_MAX)for(const[t,e]of this.resolutionCache)a-e.at>=this.cacheTTL&&this.resolutionCache.delete(t);this.resolutionCache.size<AuthzBase.RESOLUTION_CACHE_MAX?this.resolutionCache.set(i,{user:h,at:a}):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 n(`[authz-forbidden] 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 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.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 o=new Set((n?[]:await this.storedValues(r,i)).map(c)),u=s.filter(t=>!o.has(c(t)));if(0===u.length)return;const l=this.config.ldap_top_organization,d=c(l||this.server.ldap.base),f=new Set;let g=h(r);const w=new Set;for(;g&&!w.has(c(g));){const t=c(g);if(w.add(t),t!==d&&!a(g,d))break;for(const t of await this.storedValues(g,i))f.add(c(t));if(t===d)break;const e=h(g);if(e===g)break;g=e}const m=u.filter(t=>!f.has(c(t)));if(0!==m.length&&!await this.mayLinkAnyDomain(t))throw new Error(`[authz-forbidden] User ${e.user} may not link ${r} 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.resolutionCache.clear()}}AuthzBase.widenedForLink=new WeakSet,AuthzBase.UNRESOLVED_POLICIES=["deny","allow"],AuthzBase.RESOLUTION_CACHE_MAX=1e4;export{AuthzBase as default};
@@ -1 +1 @@
1
- import e from"../../lib/authz/base.js";import{isDnInBranch as t,escapeLdapFilter as i}from"../../lib/utils.js";class AuthzLinid1 extends e{constructor(e){super(e),this.name="authzLinid1",this.permissionsCache=new Map,this.cacheTTL=3e5;const i=this.config.authz_local_admin_attribute||"twakeLocalAdminLink";this.logger.info(`AuthzLinid1: Authorization based on ${i} attribute enabled`);const s=this.globalOrganization(),r=s&&this.globalBranches().find(e=>t(s,e));if(r)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global branch ${r}`);const n=s&&this.globalWriteBranches().find(e=>t(s,e));if(n)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global write branch ${n}`);for(const e of this.globalWriteBranches()){const i=this.globalBranches().find(i=>t(e,i)||t(i,e));if(i)throw new Error(`AuthzLinid1: the global branch ${i} and the global write branch ${e} overlap`)}}globalOrganization(){return this.config.authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.config.authz_global_branch??[]}globalWriteBranches(){return this.config.authz_global_write_branch??[]}isGlobalWrite(e){return this.globalWriteBranches().some(i=>t(e,i))}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,i){const s=this.globalOrganization();if(this.globalBranches().some(e=>t(i,e)))return s&&!t(s,i)?this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(i)){const r=s&&!t(s,i)?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,i)}async ownPermissions(e,t){const i=Date.now(),s=this.permissionsCache.get(e);if(s&&i-s.timestamp<this.cacheTTL){const e=this.findBranchPermissions(s.branches,t);if(e)return e}await this.refreshUserPermissions(e);const r=this.permissionsCache.get(e);if(r){const e=this.findBranchPermissions(r.branches,t);if(e)return e}return{read:!1,write:!1,delete:!1}}async refreshUserPermissions(e){const t=new Map;try{const s=`(${this.config.authz_local_admin_attribute||"twakeLocalAdminLink"}=${i(e)})`,r=this.config.ldap_top_organization||this.config.ldap_base||"",n=await this.server.ldap.search({paged:!1,filter:s,attributes:["dn"],scope:"sub"},r);if(n.searchEntries)for(const e of n.searchEntries){const i="string"==typeof e.dn?e.dn:String(e.dn);t.set(i,{read:!0,write:!0,delete:!0})}}catch(t){this.logger.error(`Failed to refresh permissions for user ${e}: ${t}`)}this.permissionsCache.set(e,{branches:t,timestamp:Date.now()})}async getAuthorizedBranches(e){const t=Date.now(),i=this.permissionsCache.get(e);(!i||t-i.timestamp>=this.cacheTTL)&&await this.refreshUserPermissions(e);const s=this.permissionsCache.get(e);return s?Array.from(s.branches.keys()):[]}findBranchPermissions(e,t){if(e.has(t))return e.get(t)||null;for(const[i,s]of e.entries())if(t.toLowerCase().endsWith(`,${i.toLowerCase()}`))return s;return null}}export{AuthzLinid1 as default};
1
+ import e from"../../lib/authz/base.js";import{isDnInBranch as t,escapeLdapFilter as i}from"../../lib/utils.js";class AuthzLinid1 extends e{constructor(e){super(e),this.name="authzLinid1",this.permissionsCache=new Map,this.cacheTTL=3e5;const i=this.config.authz_local_admin_attribute||"twakeLocalAdminLink";this.logger.info(`AuthzLinid1: Authorization based on ${i} attribute enabled`);const s=this.globalOrganization(),r=s&&this.globalBranches().find(e=>t(s,e));if(r)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global branch ${r}`);const n=s&&this.globalWriteBranches().find(e=>t(s,e));if(n)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global write branch ${n}`);for(const e of this.globalWriteBranches()){const i=this.globalBranches().find(i=>t(e,i)||t(i,e));if(i)throw new Error(`AuthzLinid1: the global branch ${i} and the global write branch ${e} overlap`)}}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.config.authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.config.authz_global_branch??[]}globalWriteBranches(){return this.config.authz_global_write_branch??[]}isGlobalWrite(e){return this.globalWriteBranches().some(i=>t(e,i))}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,i){const s=this.globalOrganization();if(this.globalBranches().some(e=>t(i,e)))return s&&!t(s,i)?this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(i)){const r=s&&!t(s,i)?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,i)}async ownPermissions(e,t){const i=Date.now(),s=this.permissionsCache.get(e);if(s&&i-s.timestamp<this.cacheTTL){const e=this.findBranchPermissions(s.branches,t);if(e)return e}await this.refreshUserPermissions(e);const r=this.permissionsCache.get(e);if(r){const e=this.findBranchPermissions(r.branches,t);if(e)return e}return{read:!1,write:!1,delete:!1}}async refreshUserPermissions(e){const t=new Map;try{const s=`(${this.config.authz_local_admin_attribute||"twakeLocalAdminLink"}=${i(e)})`,r=this.config.ldap_top_organization||this.config.ldap_base||"",n=await this.server.ldap.search({paged:!1,filter:s,attributes:["dn"],scope:"sub"},r);if(n.searchEntries)for(const e of n.searchEntries){const i="string"==typeof e.dn?e.dn:String(e.dn);t.set(i,{read:!0,write:!0,delete:!0})}}catch(t){this.logger.error(`Failed to refresh permissions for user ${e}: ${t}`)}this.permissionsCache.set(e,{branches:t,timestamp:Date.now()})}async getAuthorizedBranches(e){const t=Date.now(),i=this.permissionsCache.get(e);(!i||t-i.timestamp>=this.cacheTTL)&&await this.refreshUserPermissions(e);const s=this.permissionsCache.get(e);return s?Array.from(s.branches.keys()):[]}findBranchPermissions(e,t){if(e.has(t))return e.get(t)||null;for(const[i,s]of e.entries())if(t.toLowerCase().endsWith(`,${i.toLowerCase()}`))return s;return null}}export{AuthzLinid1 as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{identityFor as s}from"../../lib/auth/base.js";import{servesRequest as t,overlap as r,EVERYONE as i,authzFor as n}from"../../lib/authz/composition.js";import{asyncHandler as a}from"../../lib/utils.js";import{UnauthorizedError as o}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";class AuthzScope extends e{constructor(){super(...arguments),this.name="authzScope",this.roles=["api"]}api(e){e.get(`${this.config.api_prefix}/v1/authz/scope`,a(async(e,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)&&t(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=>n(e.config,e.name)??i;for(let t=0;t<e.length;t++)for(let i=t+1;i<e.length;i++){const n=e[t],a=e[i];if(r(population(n),population(a),s))return void this.logger.warn(`${this.name}: ${n.name} and ${a.name} can both describe the scope of one caller, and ${n.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 r=this.server.loadedPlugins.ldapGroups;return r?.base&&s.push({name:"groups",base:r.base,linkable:linkable(r.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,t){const r=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new o("No authenticated user");const i=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),n=r.filter(e=>i.includes(e)),a=this.config.authz_scope_source,c=n.find(e=>e.name===a)??n[0],u=i.map(e=>e.name);if(!c)return 0===i.length?void t.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void t.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:u,branches:[],entities:[]});const l=s(e,c.config).value,h=await c.resolveUser(l);if(!h)throw new o(`Unknown user ${l}`);const d=await c.getAuthorizedBranches(h),p=[],f=new Map,permissionsOn=async e=>{let s=f.get(e);return s||(s=await c.getUserPermissions(h,e),f.set(e,s)),s};for(const e of d){const s=await permissionsOn(e);p.push({dn:e,...s,...await this.branchLabel(e)})}const b=d.some(e=>!0===f.get(e)?.write),g=this.config.ldap_top_organization,m=[];for(const{name:e,base:s,linkable:t}of this.entities()){const r=g&&s===g?!0===(await permissionsOn(g)).write:t?b:!0===(await permissionsOn(s)).write;m.push({name:e,base:s,create:r})}t.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:u,branches:p,transit:c.transitBranch?.()??null,entities:m})}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.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),r=s?.searchEntries?.[0];if(!r)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(r.ou)||first(r.o)||first(r.cn),path:t?first(r[t]):void 0}}catch(e){return{}}}}export{AuthzScope as default};
1
+ import e from"../../abstract/plugin.js";import{identityFor as s}from"../../lib/auth/base.js";import{servesRequest as t,overlap as n,EVERYONE as i,authzFor as a}from"../../lib/authz/composition.js";import{asyncHandler as r}from"../../lib/utils.js";import{UnauthorizedError as o}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";class AuthzScope extends e{constructor(){super(...arguments),this.name="authzScope",this.roles=["api"]}api(e){e.get(`${this.config.api_prefix}/v1/authz/scope`,r(async(e,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)&&t(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)??i;for(let t=0;t<e.length;t++)for(let i=t+1;i<e.length;i++){const a=e[t],r=e[i];if(n(population(a),population(r),s))return void this.logger.warn(`${this.name}: ${a.name} and ${r.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 n=this.server.loadedPlugins.ldapGroups;return n?.base&&s.push({name:"groups",base:n.base,linkable:linkable(n.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,t){const n=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new o("No authenticated user");const i=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),a=n.filter(e=>i.includes(e)),r=this.config.authz_scope_source,c=a.find(e=>e.name===r)??a[0],u=i.map(e=>e.name);if(!c)return 0===i.length?void t.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],linksAnyDomain:!0,entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void t.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:u,branches:[],linksAnyDomain:!1,entities:[]});const l=s(e,c.config).value,h=await c.resolveUser(l);if(!h)throw new o(`Unknown user ${l}`);const d=await c.getAuthorizedBranches(h),p=[],f=new Map,permissionsOn=async e=>{let s=f.get(e);return s||(s=await c.getUserPermissions(h,e),f.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===f.get(e)?.write),b=this.config.ldap_top_organization,g=[];for(const{name:e,base:s,linkable:t}of this.entities()){const n=b&&s===b?!0===(await permissionsOn(b)).write:t?m:!0===(await permissionsOn(s)).write;g.push({name:e,base:s,create:n})}t.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:u,branches:p,transit:c.transitBranch?.()??null,linksAnyDomain:!0===await(c.mayLinkAnyDomain?.(h)),entities:g})}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.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),n=s?.searchEntries?.[0];if(!n)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(n.ou)||first(n.o)||first(n.cn),path:t?first(n[t]):void 0}}catch(e){return{}}}}export{AuthzScope as default};
@@ -238,10 +238,9 @@ export default abstract class AuthzBase extends DmPlugin {
238
238
  * the directory — another customer's — and create addresses in it (#325).
239
239
  *
240
240
  * A domain newly linked to an organization must therefore be one an
241
- * organization above it already links. Only a caller holding write on the
242
- * top organization — who creates the organizations domains are given to —
243
- * links another. Removing a link narrows nothing above the caller's own
244
- * branch and is judged as any other write.
241
+ * organization above it already links. Only a caller accepted by
242
+ * {@link mayLinkAnyDomain} may link another. Removing a link narrows
243
+ * nothing above the caller's own branch and is judged as any other write.
245
244
  *
246
245
  * @param user the resolved caller
247
246
  * @param req the request, for the refusal's wording
@@ -251,6 +250,16 @@ export default abstract class AuthzBase extends DmPlugin {
251
250
  * @param creating whether the organization is being created, so holds none
252
251
  */
253
252
  private guardDomainLinks;
253
+ /**
254
+ * Whether a caller links an organization to any mail domain, not only to
255
+ * those an organization above it links: one holding write on the top
256
+ * organization, who creates the organizations domains are given to. A
257
+ * plugin knowing another such caller says so here; `GET /authz/scope`
258
+ * reports the answer.
259
+ *
260
+ * @param user the resolved caller
261
+ */
262
+ mayLinkAnyDomain(user: string): Promise<boolean>;
254
263
  /** Forget what was resolved, for a test that changes the directory. */
255
264
  protected clearResolutionCache(): void;
256
265
  /**
@@ -17,6 +17,12 @@ export default class AuthzLinid1 extends AuthzBase {
17
17
  name: string;
18
18
  permissionsCache: Map<string, CachedPermissions>;
19
19
  constructor(server: DM);
20
+ /**
21
+ * The administrators of the global organization link any domain too: they
22
+ * create and delete the domains (`--authz-global-write-branches`), and the
23
+ * top organization may be a bare container nobody administers.
24
+ */
25
+ mayLinkAnyDomain(user: string): Promise<boolean>;
20
26
  /** The organization holding the global branches */
21
27
  private globalOrganization;
22
28
  /**
@@ -105,8 +105,11 @@ write on its parent, so the link itself is held to more:
105
105
 
106
106
  - a domain newly linked to an organization must already be linked by an
107
107
  organization above it, up to the top organization;
108
- - a caller holding write on the top organization links any domain: they are
109
- the one giving each organization its own;
108
+ - a caller holding write on the top organization, or on
109
+ `--authz-global-organization`, links any domain: they are the one giving
110
+ each organization its own. The second matters where the top organization
111
+ is a bare container nobody administers, the administrators of the whole
112
+ directory holding an organization under it;
110
113
  - a domain the organization already links can be kept, and removing one is
111
114
  judged as any other change.
112
115
 
@@ -115,7 +118,14 @@ Sales' domain, but cannot give it another customer's. The attribute is the
115
118
  organization schema's `domainLink` role, or
116
119
  `--enterprise-domain-link-attribute`; with neither, nothing is checked. The
117
120
  rule belongs to the base of the authorization plugins, so `authzPerBranch`
118
- applies it too.
121
+ applies it too, the top organization alone granting there.
122
+
123
+ `GET /authz/scope` answers `linksAnyDomain: true` to a caller linking any
124
+ domain, so that a form offers them every domain and the others only those
125
+ the organizations above inherit. Like the rest of the scope, it is the
126
+ answer of the plugin describing it (`source`, see `--authz-scope-source`):
127
+ where `authzPerBranch` judges the same caller too, it may still refuse the
128
+ link, granting it on the top organization only.
119
129
 
120
130
  ### Global branches
121
131
 
@@ -159,6 +169,12 @@ the read every administrator holds: a local administrator cannot move a
159
169
  domain into their own organization and delete it from there. Several
160
170
  branches are separated by `;`.
161
171
 
172
+ The administrators of `--authz-global-organization` also give the
173
+ organizations their domains: they link any domain, as those of the top
174
+ organization do ([Mail domains](#mail-domains)), so that the ones who create
175
+ a domain can hand it to an organization, the top organization being a bare
176
+ container or not.
177
+
162
178
  `--authz-global-organization` must lie outside the global branches and the
163
179
  global write branches: nobody would administer it otherwise. A global
164
180
  branch and a global write branch may not be the same, nor lie one in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"