ldap-rest 2.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import"../../abstract/plugin.js";import t from"../../lib/auth/base.js";import{runTogether as e}from"../../lib/authz/composition.js";import{AuthzRefusedError as i}from"../../lib/errors.js";import{unauthorized as n,ok as s}from"../../lib/expressFormatedResponses.js";import{getParentDn as a}from"../../lib/utils.js";import{dynamicOptions as r}from"./auth.options.js";import{verifyLdapPassword as o}from"./authzDynamicHash.js";import{defineRoute as h}from"../../lib/route.js";function asString(t){if(null!=t){if(Array.isArray(t)){const e=t[0];return null==e?void 0:Buffer.isBuffer(e)?e.toString():String(e)}return Buffer.isBuffer(t)?t.toString():String(t)}}function isAtOrUnder(t,e){const i=t.toLowerCase().trim(),n=e.toLowerCase().trim();return!(!i||!n)&&(i===n||i.endsWith(","+n))}class AuthzDynamic extends t{static optionDefinitions=r;name="authzDynamic";roles=["auth","authz","configurable"];tokens=[];lastLoad=0;lastFailure=0;cacheTtlMs;failureBackoffMs;base;tokenAttr;configAttr;tenantAttr;reloadEndpoint;loading;constructor(t){super(t);const e=this.options(r);if(this.base=e.authz_dynamic_base,this.cacheTtlMs=1e3*e.authz_dynamic_cache_ttl,this.failureBackoffMs=Math.max(Math.floor(this.cacheTtlMs/4),5e3),this.tokenAttr=e.authz_dynamic_token_attribute,this.configAttr=e.authz_dynamic_config_attribute,this.tenantAttr=e.authz_dynamic_tenant_attribute,this.reloadEndpoint=e.authz_dynamic_reload_endpoint,!this.base)throw new Error("authzDynamic requires --authz-dynamic-base (the LDAP branch holding token entries)")}api(t){if(super.api(t),this.reloadEndpoint){const e=this.config.api_prefix||"/api";h(t,{method:"post",path:`${e}/v1/authz-dynamic/reload`,handler:async({req:t,res:e})=>{t.authzToken?(await this.reload(),s(e,{success:!0,tokens:this.tokens.length})):n(e)}}),this.logger.info(`authzDynamic reload endpoint registered at ${e}/v1/authz-dynamic/reload`)}}async reload(){try{const t=Array.from(new Set(["dn","cn",this.tokenAttr,this.configAttr,this.tenantAttr])),e=await this.server.ldap.system.search({filter:"(objectClass=*)",scope:"sub",paged:!1,attributes:t},this.base),i=[];for(const t of e.searchEntries||[]){const e="string"==typeof t.dn?t.dn:String(t.dn),n=asString(t.cn)||"",s=asString(t[this.tokenAttr])||"";if(!s)continue;let a,r=[];const o=asString(t[this.configAttr]);if(o)try{const t=JSON.parse(o);t&&"string"==typeof t.tenant&&t.tenant&&(a=t.tenant),t&&Array.isArray(t.bases)&&(r=t.bases.filter(t=>t&&"string"==typeof t.dn&&t.dn.length>0).map(t=>({dn:t.dn,read:Boolean(t.read),write:Boolean(t.write),delete:Boolean(t.delete)})))}catch(t){this.logger.warn(`authzDynamic: invalid JSON in ${this.configAttr} of ${e}: ${String(t)}`)}const h="cn"===this.tenantAttr?n:asString(t[this.tenantAttr])||n,u=a||h;i.push({dn:e,cn:n,tenant:u,hash:s,bases:r})}this.tokens=i,this.lastLoad=Date.now(),this.lastFailure=0,this.logger.info(`authzDynamic: loaded ${i.length} token(s) from ${this.base}`)}catch(t){this.lastFailure=Date.now(),this.logger.error(`authzDynamic: failed to load tokens from ${this.base}: ${String(t)}`)}}async ensureFresh(){const t=Date.now();if(!(this.lastLoad>0&&t-this.lastLoad<this.cacheTtlMs||this.lastFailure>0&&t-this.lastFailure<this.failureBackoffMs))return this.loading||(this.loading=this.reload().finally(()=>{this.loading=void 0})),this.loading}bypassReason(t,e){if(0===e.length)return;const i=t;return e.includes("trusted-proxy")&&!0===i.trustedProxy&&i.proxyAuthUser?`trusted-proxy vouching for ${i.proxyAuthUser}`:t.user?e.includes("any-authenticated")?"any-authenticated":e.includes(t.user)?`identity ${t.user}`:void 0:void 0}identitySource(){return"req.user and req.userName: the token's tenant"}afterLoad(){super.afterLoad();const t=Object.values(this.server.loadedPlugins).filter(t=>t!==this&&t.roles?.includes("auth")&&e(this,{name:t.name,pathPrefixes:t.pathPrefixes||[]})).map(t=>t.name);if(0===t.length)return;const i=this.options(r).authz_dynamic_bypass;this.logger.warn(`authzDynamic: ${t.join(", ")} authenticate the same paths as this plugin, so a request can arrive already identified. It is `+(0===i.length?"checked against the ACLs of the token it carries, and refused without one — set --authz-dynamic-bypass to let an identity through without a token.":`let through without a token when it matches ${i.join(", ")} (--authz-dynamic-bypass), and checked against its token ACLs otherwise.`))}afterChain(t,e,i){const s=t;if(!s.authzDynamicPending)return i();s.authzDynamicPending=!1;const a=s.authzDynamicBypass??this.options(r).authz_dynamic_bypass,o=this.bypassReason(s,a);if(o)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${o})`),i();this.logger.warn(s.authzDynamicPendingReason||"authzDynamic: no token on a request no bypass covers"),n(e)}refuseOrDefer(t,e,i,s,a){const r=this.bypassReason(t,a);if(r)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${r})`),void this.passThrough(t,i);if(!t.user&&a.length>0){const e=t;return e.authzDynamicPending=!0,e.authzDynamicPendingReason=s,e.authzDynamicBypass=a,void this.passThrough(t,i)}this.logger.warn(s),n(e)}authMethod(t,e,i){const s=this.options(r).authz_dynamic_bypass,a=this.bypassReason(t,s);if(a)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${a})`),void this.passThrough(t,i);this.ensureFresh().then(()=>{const n=t.headers.authorization;if(!n||!/^Bearer\s+/.test(n))return void this.refuseOrDefer(t,e,i,"authzDynamic: missing or invalid Authorization header",s);const a=n.slice(n.indexOf(" ")+1).trim();if(!a)return void this.refuseOrDefer(t,e,i,"authzDynamic: empty bearer token",s);let r;for(const t of this.tokens)if(o(a,t.hash)){r=t;break}if(!r){const n=a.length>8?`${a.substring(0,8)}...`:"***";return void this.refuseOrDefer(t,e,i,`authzDynamic: unauthorized token ${n}`,s)}t.user||this.publishIdentity(t,r.tenant),t.authzToken=r,i()}).catch(t=>{this.logger.error(`authzDynamic: authMethod error: ${String(t)}`),n(e)})}hooks={ldapsearchrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,t,"read"),[t,e,i]):[t,e,i]},ldapaddrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,a(t),"write"),[t,e,i]):[t,e,i]},ldapmodifyrequest:([t,e,i,n])=>{const s=this.activeToken(n);return s?(this.requireBranchPermission(s,a(t),"write"),[t,e,i,n]):[t,e,i,n]},ldapdeleterequest:([t,e])=>{const i=this.activeToken(e);if(!i)return[t,e];const n=Array.isArray(t)?t:[t];for(const t of n)this.requireBranchPermission(i,a(t),"delete");return[t,e]},ldaprenamerequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,a(t),"read"),this.requireBranchPermission(n,a(e),"write"),[t,e,i]):[t,e,i]}};activeToken(t){return t?.authzToken}requireBranchPermission(t,e,n){for(const i of t.bases)if(isAtOrUnder(e,i.dn)&&i[n])return;throw this.logger.debug(`authzDynamic: token "${t.tenant}" denied ${n} on ${e}`),new i(`Token does not have ${n} permission on this branch`)}getConfigApiData(){return{enabled:!0,base:this.base,cacheTtlSeconds:this.cacheTtlMs/1e3,tokenCount:this.tokens.length,reloadEndpoint:this.reloadEndpoint?`${this.config.api_prefix||"/api"}/v1/authz-dynamic/reload`:void 0,tokenAttribute:this.tokenAttr,configAttribute:this.configAttr,tenantAttribute:this.tenantAttr}}_tokens(){return this.tokens}}export{AuthzDynamic as default};
1
+ import"../../abstract/plugin.js";import t from"../../lib/auth/base.js";import{runTogether as e}from"../../lib/authz/composition.js";import{AuthzRefusedError as i}from"../../lib/errors.js";import{unauthorized as n,ok as s}from"../../lib/expressFormatedResponses.js";import{getParentDn as a,isDnInBranch as r}from"../../lib/utils.js";import{dynamicOptions as o}from"./auth.options.js";import{verifyLdapPassword as h}from"./authzDynamicHash.js";import{defineRoute as u}from"../../lib/route.js";function asString(t){if(null!=t){if(Array.isArray(t)){const e=t[0];return null==e?void 0:Buffer.isBuffer(e)?e.toString():String(e)}return Buffer.isBuffer(t)?t.toString():String(t)}}class AuthzDynamic extends t{static optionDefinitions=o;name="authzDynamic";roles=["auth","authz","configurable"];tokens=[];lastLoad=0;lastFailure=0;cacheTtlMs;failureBackoffMs;base;tokenAttr;configAttr;tenantAttr;reloadEndpoint;loading;constructor(t){super(t);const e=this.options(o);if(this.base=e.authz_dynamic_base,this.cacheTtlMs=1e3*e.authz_dynamic_cache_ttl,this.failureBackoffMs=Math.max(Math.floor(this.cacheTtlMs/4),5e3),this.tokenAttr=e.authz_dynamic_token_attribute,this.configAttr=e.authz_dynamic_config_attribute,this.tenantAttr=e.authz_dynamic_tenant_attribute,this.reloadEndpoint=e.authz_dynamic_reload_endpoint,!this.base)throw new Error("authzDynamic requires --authz-dynamic-base (the LDAP branch holding token entries)")}api(t){if(super.api(t),this.reloadEndpoint){const e=this.config.api_prefix||"/api";u(t,{method:"post",path:`${e}/v1/authz-dynamic/reload`,handler:async({req:t,res:e})=>{t.authzToken?(await this.reload(),s(e,{success:!0,tokens:this.tokens.length})):n(e)}}),this.logger.info(`authzDynamic reload endpoint registered at ${e}/v1/authz-dynamic/reload`)}}async reload(){try{const t=Array.from(new Set(["dn","cn",this.tokenAttr,this.configAttr,this.tenantAttr])),e=await this.server.ldap.system.search({filter:"(objectClass=*)",scope:"sub",paged:!1,attributes:t},this.base),i=[];for(const t of e.searchEntries||[]){const e="string"==typeof t.dn?t.dn:String(t.dn),n=asString(t.cn)||"",s=asString(t[this.tokenAttr])||"";if(!s)continue;let a,r=[];const o=asString(t[this.configAttr]);if(o)try{const t=JSON.parse(o);t&&"string"==typeof t.tenant&&t.tenant&&(a=t.tenant),t&&Array.isArray(t.bases)&&(r=t.bases.filter(t=>t&&"string"==typeof t.dn&&t.dn.length>0).map(t=>({dn:t.dn,read:Boolean(t.read),write:Boolean(t.write),delete:Boolean(t.delete)})))}catch(t){this.logger.warn(`authzDynamic: invalid JSON in ${this.configAttr} of ${e}: ${String(t)}`)}const h="cn"===this.tenantAttr?n:asString(t[this.tenantAttr])||n,u=a||h;i.push({dn:e,cn:n,tenant:u,hash:s,bases:r})}this.tokens=i,this.lastLoad=Date.now(),this.lastFailure=0,this.logger.info(`authzDynamic: loaded ${i.length} token(s) from ${this.base}`)}catch(t){this.lastFailure=Date.now(),this.logger.error(`authzDynamic: failed to load tokens from ${this.base}: ${String(t)}`)}}async ensureFresh(){const t=Date.now();if(!(this.lastLoad>0&&t-this.lastLoad<this.cacheTtlMs||this.lastFailure>0&&t-this.lastFailure<this.failureBackoffMs))return this.loading||(this.loading=this.reload().finally(()=>{this.loading=void 0})),this.loading}bypassReason(t,e){if(0===e.length)return;const i=t;return e.includes("trusted-proxy")&&!0===i.trustedProxy&&i.proxyAuthUser?`trusted-proxy vouching for ${i.proxyAuthUser}`:t.user?e.includes("any-authenticated")?"any-authenticated":e.includes(t.user)?`identity ${t.user}`:void 0:void 0}identitySource(){return"req.user and req.userName: the token's tenant"}afterLoad(){super.afterLoad();const t=Object.values(this.server.loadedPlugins).filter(t=>t!==this&&t.roles?.includes("auth")&&e(this,{name:t.name,pathPrefixes:t.pathPrefixes||[]})).map(t=>t.name);if(0===t.length)return;const i=this.options(o).authz_dynamic_bypass;this.logger.warn(`authzDynamic: ${t.join(", ")} authenticate the same paths as this plugin, so a request can arrive already identified. It is `+(0===i.length?"checked against the ACLs of the token it carries, and refused without one — set --authz-dynamic-bypass to let an identity through without a token.":`let through without a token when it matches ${i.join(", ")} (--authz-dynamic-bypass), and checked against its token ACLs otherwise.`))}afterChain(t,e,i){const s=t;if(!s.authzDynamicPending)return i();s.authzDynamicPending=!1;const a=s.authzDynamicBypass??this.options(o).authz_dynamic_bypass,r=this.bypassReason(s,a);if(r)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${r})`),i();this.logger.warn(s.authzDynamicPendingReason||"authzDynamic: no token on a request no bypass covers"),n(e)}refuseOrDefer(t,e,i,s,a){const r=this.bypassReason(t,a);if(r)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${r})`),void this.passThrough(t,i);if(!t.user&&a.length>0){const e=t;return e.authzDynamicPending=!0,e.authzDynamicPendingReason=s,e.authzDynamicBypass=a,void this.passThrough(t,i)}this.logger.warn(s),n(e)}authMethod(t,e,i){const s=this.options(o).authz_dynamic_bypass,a=this.bypassReason(t,s);if(a)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${a})`),void this.passThrough(t,i);this.ensureFresh().then(()=>{const n=t.headers.authorization;if(!n||!/^Bearer\s+/.test(n))return void this.refuseOrDefer(t,e,i,"authzDynamic: missing or invalid Authorization header",s);const a=n.slice(n.indexOf(" ")+1).trim();if(!a)return void this.refuseOrDefer(t,e,i,"authzDynamic: empty bearer token",s);let r;for(const t of this.tokens)if(h(a,t.hash)){r=t;break}if(!r){const n=a.length>8?`${a.substring(0,8)}...`:"***";return void this.refuseOrDefer(t,e,i,`authzDynamic: unauthorized token ${n}`,s)}t.user||this.publishIdentity(t,r.tenant),t.authzToken=r,i()}).catch(t=>{this.logger.error(`authzDynamic: authMethod error: ${String(t)}`),n(e)})}hooks={ldapsearchrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,t,"read"),[t,e,i]):[t,e,i]},ldapaddrequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,a(t),"write"),[t,e,i]):[t,e,i]},ldapmodifyrequest:([t,e,i,n])=>{const s=this.activeToken(n);return s?(this.requireBranchPermission(s,a(t),"write"),[t,e,i,n]):[t,e,i,n]},ldapdeleterequest:([t,e])=>{const i=this.activeToken(e);if(!i)return[t,e];const n=Array.isArray(t)?t:[t];for(const t of n)this.requireBranchPermission(i,a(t),"delete");return[t,e]},ldaprenamerequest:([t,e,i])=>{const n=this.activeToken(i);return n?(this.requireBranchPermission(n,a(t),"read"),this.requireBranchPermission(n,a(e),"write"),[t,e,i]):[t,e,i]}};activeToken(t){return t?.authzToken}requireBranchPermission(t,e,n){for(const i of t.bases){let t;try{t=r(e,i.dn)}catch{continue}if(t&&i[n])return}throw this.logger.debug(`authzDynamic: token "${t.tenant}" denied ${n} on ${e}`),new i(`Token does not have ${n} permission on this branch`)}getConfigApiData(){return{enabled:!0,base:this.base,cacheTtlSeconds:this.cacheTtlMs/1e3,tokenCount:this.tokens.length,reloadEndpoint:this.reloadEndpoint?`${this.config.api_prefix||"/api"}/v1/authz-dynamic/reload`:void 0,tokenAttribute:this.tokenAttr,configAttribute:this.configAttr,tenantAttribute:this.tenantAttr}}_tokens(){return this.tokens}}export{AuthzDynamic as default};
@@ -118,7 +118,10 @@ description: {
118
118
  ```
119
119
 
120
120
  - **Sub-branch matching**: a permission on `ou=acme,dc=example,dc=com`
121
- automatically covers `ou=users,ou=acme,…` and any sub-branch.
121
+ automatically covers `ou=users,ou=acme,…` and any sub-branch. Branches and
122
+ target DNs are compared RDN by RDN, so case, spacing, escapes, `;`
123
+ separators and attribute-type spellings (`ou`, `2.5.4.11`, `OID.2.5.4.11`)
124
+ do not matter; an escaped comma is part of a value, never a separator.
122
125
  - Absent flags default to `false`.
123
126
  - `tenant` is optional; if omitted, `req.user` falls back to the value of the
124
127
  configured `tenantAttribute` (default `cn`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"