ldap-rest 2.0.1 → 2.0.3

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{Type as t}from"@sinclair/typebox";import{assertModifyRequest as e}from"../lib/ldapActions.js";import{jsonBody as i,created as a,tryMethod as s,multiStatus as r,WriteResponse as n}from"../lib/expressFormatedResponses.js";import{getCompiledRegex as o,escapeDnValue as h,escapeRegex as d,validateDnValue as c,isDummyMemberDn as l,normalizeDn as u,namesByDn as m,getParentDn as p,isDirectChildOf as b,assertRfc4514Escapes as f,assertedRdnValue as w,escapeLdapFilter as g,substringSearchFilter as y}from"../lib/utils.js";import{neverReturnAttributes as A,assertClientMaySet as $,modifiedAttributeNames as v,toDirectoryEntry as E}from"../config/schema.js";import{applyFixedDefaults as j,EntityValidator as N}from"../lib/entitySchema/validator.js";import{ConflictError as x,BadRequestError as D,NotFoundError as O,HttpError as k,isAuthzRefusal as C,UnprocessableEntityError as S}from"../lib/errors.js";import{extractLdapCode as P}from"../lib/ldapCodes.js";import{loadSchemaFile as L}from"../lib/schemaFile.js";import _ from"./plugin.js";import{defineRoute as q}from"../lib/route.js";const z=t.Object({match:t.Optional(t.String()),attribute:t.Optional(t.String()),attributes:t.Optional(t.String()),limit:t.Optional(t.Integer({minimum:1,maximum:Number.MAX_SAFE_INTEGER}))}),F=t.Object({id:t.String({minLength:1})}),I=t.Object({targetOrgDn:t.String({minLength:1})}),M=t.Object({success:t.Boolean(),departmentPath:t.String(),departmentLink:t.String()}),H=t.Object({newId:t.String({minLength:1})});class LdapFlat extends _{base;ldap;mainAttribute;objectClass;defaultAttributes;schema;singularName;pluralName;hookPrefix;ownHooks;constructor(t,e){if(super(t),this.ldap=t.ldap,this.base=e.base,this.mainAttribute=e.mainAttribute,this.objectClass=e.objectClass,this.defaultAttributes=e.defaultAttributes||{},this.singularName=e.singularName,this.pluralName=e.pluralName,this.hookPrefix=e.hookPrefix,this.ownHooks=this.hookBus.prefixed(this.hookPrefix),!this.base)throw new Error(`LDAP base is not defined for ${this.singularName}`);if(this.ldap.declareObjectClasses(this.base,this.objectClass),e.schemaPath)try{this.schema=L(e.schemaPath,{config:this.config}),this.ldap.knowAttributeNames(Object.keys(this.schema.attributes)),this.logger.info(`${this.singularName} schema loaded from ${e.schemaPath}`)}catch(t){this.logger.error(`Failed to load ${this.singularName} schema from ${e.schemaPath}: ${t}`)}}hiddenAttributes(){return A([this.schema])}entryAttributes(){return["*",...Object.keys(this.schema?.attributes??{})]}project(t){const e=this.hiddenAttributes();if(0===e.size)return t;const i={};for(const[a,s]of Object.entries(t))e.has(a.split(";")[0].toLowerCase())||(i[a]=s);return i}projectList(t){if(0===this.hiddenAttributes().size)return t;const e={};for(const[i,a]of Object.entries(t))e[i]=this.project(a);return e}rejectForbiddenInput(t){$(this.schema,t,this.mainAttribute)}async generateMainAttribute(t){const e=this.deriveMainAttribute(t),i=this.schema?.attributes[this.mainAttribute]?.generatedFrom;if(void 0!==e&&i){if(!await this.rdnExists(e))return e;if("suffix"!==i.onCollision)throw new x(`${this.singularName} "${e}" already exists (generated from ${i.attribute})`);for(let t=2;t<1e3;t++){const i=`${e}-${t}`;if(!await this.rdnExists(i))return i}throw new x(`Cannot generate a free "${this.mainAttribute}" from "${i.attribute}"`)}}deriveMainAttribute(t){const e=this.schema?.attributes[this.mainAttribute],i=e?.generatedFrom;if(!i||!e.generated)return;const a=t[i.attribute],s=Array.isArray(a)?a[0]:a;if(null==s||""===s)throw new D(`Attribute "${i.attribute}" is required to generate "${this.mainAttribute}"`);let r=String(s);if(i.extract){const t=o(i.extract).exec(r);t&&(r=void 0!==t[1]?t[1]:t[0])}if(i.lowercase&&(r=r.toLowerCase()),i.strip&&(r=r.replace(o(i.strip,"g"),"")),!r)throw new D(`Cannot generate "${this.mainAttribute}" from "${i.attribute}"`);return r}candidateEntry(t,e){const i={...this.defaultAttributes,[this.mainAttribute]:t,...e,objectClass:this.objectClass};return j(this.schema,i),i}async rdnExists(t){try{const e=await this.ldap.system.search({paged:!1,scope:"base"},`${this.mainAttribute}=${h(t)},${this.base}`);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}validator(){return new N(this.schema,{entryExists:async t=>{try{return(await this.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}},loadedPlugins:this.server.loadedPlugins,singularName:this.singularName})}api(t){q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,query:z,handler:async({req:t,res:e,query:i})=>{const a={};i.match&&i.attribute&&(a.filter=y(i.match,i.attribute)),i.attributes&&(a.attributes=i.attributes.split(","));const s=i.limit;let r;try{r=await this.listEntriesLimited({...a,limit:s,req:t})}catch(t){if(void 0!==s||4!==P(t))throw t;throw new S("Too many entries to list at once: the directory's size limit was exceeded. Narrow the search, or pass `limit`.")}r.truncated&&e.set("X-Result-Truncated","true"),e.json(this.projectList(r.entries))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,params:F,handler:async({req:t,res:e})=>this.apiGet(t,e)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,handler:async({req:t,res:e})=>this.apiAdd(t,e)}),q(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,params:F,responses:{200:n},handler:async({req:t,res:e})=>this.apiDelete(t,e)}),q(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,params:F,responses:{200:n},handler:async({req:t,res:e})=>this.apiModify(t,e)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`,params:F,body:I,responses:{200:M},handler:async({req:t,res:e})=>this.apiMove(t,e)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/rename`,params:F,body:H,handler:async({req:t,res:e})=>this.apiRename(t,e)})}async apiGet(t,e){const i=t.params.id;try{const a=this.resolveDn(i),s=await this.ldap.search({paged:!1,scope:"base",attributes:this.entryAttributes()},a,t);if(0===s.searchEntries.length)throw new O(`${this.singularName} not found`);e.json(this.project(s.searchEntries[0]))}catch(t){if(t.code&&32===t.code)throw new O(`${this.singularName} not found`);throw t}}async apiAdd(t,e){const s=this.schema?.attributes[this.mainAttribute],r=Boolean(s?.generated&&s.generatedFrom),n=r?i(t,e):i(t,e,this.mainAttribute);if(!n)return;this.rejectForbiddenInput(Object.keys(n));const o={...n};delete o[this.mainAttribute],delete o.dn;const h=r?this.deriveMainAttribute(n):void 0;void 0!==h&&await this.ldap.authorize("ldapaddrequest",[this.resolveDn(h),this.candidateEntry(h,o),t]);const d=r?await this.generateMainAttribute(n):n[this.mainAttribute];await this.addEntry(d,o,t);const c=await this.ldap.system.search({paged:!1,scope:"base",attributes:[this.mainAttribute]},this.creationOf(d).dn);return a(e,this.project(c.searchEntries[0]))}async apiDelete(t,e){const i=t.params.id;await s(e,this.deleteEntry.bind(this),i,t)}async apiModify(t,a){const r=i(t,a);if(!r)return;e(r),this.rejectForbiddenInput(v(r));const n=t.params.id;await s(a,this.modifyEntry.bind(this),n,r,t)}async apiMove(t,e){const i=t.body,a=t.params.id,{targetOrgDn:s}=i,r=await this.moveEntry(a,s,t);e.json({success:!0,...r})}async apiRename(t,e){const i=t.body,a=i.newId;if(this.rejectForbiddenInput(Object.keys(i).filter(t=>"newId"!==t)),new RegExp(`^${d(this.mainAttribute)}=`,"i").test(a))throw new D(`newId must be a ${this.mainAttribute} value, not a DN`);try{c(a,this.mainAttribute)}catch(t){throw new D(t instanceof Error?t.message:"Invalid newId")}await this.validator().validateValue(this.mainAttribute,a);const s=t.params.id,n=this.resolveDn(s),o=this.resolveDn(a),h=this.config.group_dummy_user;if(h&&(l(n,h)||l(o,h)))throw new x(`${h} is the configured placeholder member and cannot be renamed`);await this.ldap.authorize("ldaprenamerequest",[n,o,t]);const m=u(n)===u(o),p=await this.entryExists(n),b=m?p:await this.entryExists(o),f=this.requirePlugin("ldapEnterpriseRules");let w=[];if(m){if(!p)throw new O(`${this.singularName} not found`)}else if(p){if(b)throw new x(`${this.singularName} "${a}" already exists`);w=await(f?.findReferences(n))||[],await this.renameEntry(n,o,t)}else{if(!b)throw new O(`${this.singularName} not found`);w=await(f?.findReferences(n))||[]}const g=f&&w.length>0?await f.rewriteReferences(w,n,o,t):{updated:0,failed:[]},y={success:0===g.failed.length,dn:o,referencesUpdated:g.updated};0!==g.failed.length?r(e,{...y,referencesFailed:g.failed}):e.json(y)}async entryExists(t){try{const e=await this.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}resolveDn(t){if(m(t,this.mainAttribute)){const e=p(t),i=e!==t;if(!b(t,this.base))throw new D(`DN must be a direct child of "${this.base}". Provided DN "${t}" has parent "${i?e:"<none>"}"`);return t}return`${this.mainAttribute}=${h(t)},${this.base}`}creationOf(t){if(!m(t,this.mainAttribute))return{name:t,dn:this.resolveDn(t)};f(t,`${this.mainAttribute} DN`),this.resolveDn(t);const e=w(t,this.mainAttribute,this.base);if(null===e)throw new D(`${this.mainAttribute} must be a value, or a "${this.mainAttribute}=…,${this.base}" DN`);return{name:e,dn:`${this.mainAttribute}=${h(e)},${this.base}`}}async addEntry(t,e={},i){if("string"!=typeof t)throw new D(`${this.mainAttribute} must be a string`);await this.ldap.canonicalAttributes(e);const a=this.creationOf(t);t=a.name;let s=a.dn;try{c(t,this.mainAttribute)}catch(t){throw new D(t instanceof Error?t.message:"Invalid identifier")}let r=this.candidateEntry(t,e);await this.ldap.authorize("ldapaddrequest",[s,{...r},i]);const n=await this.validateNewEntry(s,{[this.mainAttribute]:t,...e,objectClass:this.objectClass});r={...this.candidateEntry(t,{}),...n},E(this.schema,r),this.schema?this.logger.debug("Schema loaded, attributes:",Object.keys(this.schema.attributes)):this.logger.warn("No schema available"),[s,r]=await this.ownHooks.chain("add",[s,r]),this.logger.debug("Adding LDAP entry:",{dn:s,entry:JSON.stringify(r,null,2)}),this.logger.debug("Entry attributes breakdown:");for(const[t,e]of Object.entries(r))this.logger.debug(` ${t}: ${typeof e} = ${JSON.stringify(e)}`);let o;try{o=await this.ldap.add(s,r,i)}catch(t){const e=t instanceof k&&t.statusCode<500;if(this.logger[e?"debug":"error"]("LDAP add failed:",{dn:s,entry:JSON.stringify(r,null,2),error:t}),t instanceof k||C(t))throw t;throw new Error(`Failed to add ${this.singularName} ${s}: ${String(t)}`,{cause:t})}return this.ownHooks.notify("adddone",s,r),o}async modifyEntry(t,e,i){await this.ldap.canonicalChanges(e);let a=this.resolveDn(t);const s=this.opNumber();if([a,e]=await this.ownHooks.chain("modify",[a,e,s]),e.add&&e.add[this.mainAttribute])throw new x(`${this.mainAttribute} attribute is unique, cannot add`);if(e.delete){if(e.delete instanceof Object&&e.delete[this.mainAttribute])throw new D(`Cannot delete ${this.mainAttribute} attribute`);if(Array.isArray(e.delete)&&e.delete.includes(this.mainAttribute))throw new D(`Cannot delete ${this.mainAttribute} attribute`)}if(e.replace&&e.replace[this.mainAttribute])throw new D(`Use dedicated API to change ${this.mainAttribute} attribute`);await this.ldap.authorize("ldapmodifyrequest",[a,e,s,i]),await this.validateChanges(a,e);const r=await this.ldap.modify(a,e,i);return this.ownHooks.notify("modifydone",a,e,s),r}async renameEntry(t,e,i){/,/.test(t)||c(t,this.mainAttribute),/,/.test(e)||c(e,this.mainAttribute);let a=this.resolveDn(t),s=this.resolveDn(e);[a,s]=await this.ownHooks.chain("rename",[a,s]);const r=await this.ldap.rename(a,s,i);return this.ownHooks.notify("renamedone",a,s),r}async deleteEntry(t,e){let i=this.resolveDn(t);i=await this.ownHooks.chain("delete",i);const a=await this.ldap.delete(i,e);return this.ownHooks.notify("deletedone",i),a}async moveEntry(t,e,i){f(e,"targetOrgDn");const a=this.resolveDn(t),s=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",r=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";if(!this.schema?.attributes[s]||!this.schema?.attributes[r])throw new D(`Schema for ${this.singularName} does not support move operation (missing ${s} or ${r})`);let n;try{n=await this.ldap.search({paged:!1,scope:"base",attributes:[s]},a,i)}catch(t){if(32===P(t))throw new O(`Entry ${a} not found`);throw t}if(!n.searchEntries||0===n.searchEntries.length)throw new O(`Entry ${a} not found`);const o=await this.getDepartmentPath(e,i);[,e]=await this.ownHooks.chain("move",[a,e,i]);const h={replace:{[s]:e,[r]:o}};return await this.modifyEntry(t,h,i),{departmentPath:o,departmentLink:e}}async getDepartmentPath(t,e){const i=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";try{const a=await this.ldap.search({paged:!1,scope:"base",attributes:[i,"ou","o"]},t,e);if(!a.searchEntries||0===a.searchEntries.length)throw new O(`Organization ${t} not found`);const s=a.searchEntries[0];if(s[i]){const t=s[i];return Array.isArray(t)?String(t[0]):String(t)}const r=s.ou||s.o;if(r){return`/${Array.isArray(r)?String(r[0]):String(r)}`}return this.logger.warn(`Organization ${t} has no ${i} attribute, using DN`),t}catch(e){if(e instanceof O)throw e;const i=P(e);if(32===i)throw new O(`Organization ${t} not found`);if(34===i)throw new D(`${t} is not a valid DN`);throw new Error(`Failed to fetch organization ${t}: ${String(e)}`,{cause:e})}}async listEntries(t){return(await this.listEntriesLimited(t)).entries}async listEntriesLimited({filter:t,attributes:e,req:i,limit:a}){const s={paged:void 0===a||{pageSize:Math.min(a+2,100)},filter:t||"(objectClass=*)"};if(e&&e.length>0){const t=this.mainAttribute.toLowerCase();s.attributes=e.some(e=>e.toLowerCase()===t)?e:[...e,this.mainAttribute]}const r=function(t,e){const i={},a=new Set;return{entries:i,add(s){for(const r of s){const s=r[t],n=Array.isArray(s)?s[0]:s;if(null==n||""===n)continue;const o=String(n);if(!a.has(o)){if(void 0!==e&&a.size>=e)return!1;a.add(o)}i[o]=r}return!0}}}(this.mainAttribute,a);try{const t=await this.ldap.search(s,this.base,i);for await(const e of t)if(!r.add(e.searchEntries))return{entries:r.entries,truncated:!0};return{entries:r.entries,truncated:!1}}catch(t){if(void 0===a||4!==P(t))throw t}try{const t=await this.ldap.search({...s,paged:!1,sizeLimit:Math.min(a+1,2147483647)},this.base,i);r.add(t.searchEntries)}catch(t){if(4!==P(t))throw t}return{entries:r.entries,truncated:!0}}async searchEntriesByName(t,e=!1,i=[this.mainAttribute]){const a=g(t),s=e?`(${this.mainAttribute}=*${a}*)`:`(${this.mainAttribute}=${a})`;return await this.listEntries({filter:s,attributes:i})}async validateNewEntry(t,e){return await this.validator().validateNew(e)}async validateChanges(t,e){return await this.validator().validateChanges(e),!0}}export{F as FlatEntryParams,z as FlatListQuery,I as FlatMoveBody,M as FlatMoveResponse,H as FlatRenameBody,LdapFlat as default};
1
+ import{Type as t}from"@sinclair/typebox";import{assertModifyRequest as e}from"../lib/ldapActions.js";import{jsonBody as i,created as a,tryMethod as s,multiStatus as r,WriteResponse as n}from"../lib/expressFormatedResponses.js";import{getCompiledRegex as o,escapeDnValue as h,escapeRegex as d,validateDnValue as c,isDummyMemberDn as l,normalizeDn as u,namesByDn as m,getParentDn as p,isDirectChildOf as b,assertRfc4514Escapes as f,assertedRdnValue as w,escapeLdapFilter as g,substringSearchFilter as y}from"../lib/utils.js";import{neverReturnAttributes as A,assertClientMaySet as $,modifiedAttributeNames as v,toDirectoryEntry as E}from"../config/schema.js";import{applyFixedDefaults as j,EntityValidator as N}from"../lib/entitySchema/validator.js";import{ConflictError as x,BadRequestError as D,NotFoundError as O,HttpError as k,isAuthzRefusal as C,UnprocessableEntityError as S}from"../lib/errors.js";import{extractLdapCode as P}from"../lib/ldapCodes.js";import{loadSchemaFile as L}from"../lib/schemaFile.js";import _ from"./plugin.js";import{defineRoute as q}from"../lib/route.js";const z=t.Object({match:t.Optional(t.String()),attribute:t.Optional(t.String()),attributes:t.Optional(t.String()),limit:t.Optional(t.Integer({minimum:1,maximum:Number.MAX_SAFE_INTEGER}))}),F=t.Object({id:t.String({minLength:1})}),I=t.Object({targetOrgDn:t.String({minLength:1})}),M=t.Object({success:t.Boolean(),departmentPath:t.String(),departmentLink:t.String()}),H=t.Object({newId:t.String({minLength:1})});class LdapFlat extends _{base;ldap;mainAttribute;objectClass;defaultAttributes;schema;singularName;pluralName;hookPrefix;ownHooks;constructor(t,e){if(super(t),this.ldap=t.ldap,this.base=e.base,this.mainAttribute=e.mainAttribute,this.objectClass=e.objectClass,this.defaultAttributes=e.defaultAttributes||{},this.singularName=e.singularName,this.pluralName=e.pluralName,this.hookPrefix=e.hookPrefix,this.ownHooks=this.hookBus.prefixed(this.hookPrefix),!this.base)throw new Error(`LDAP base is not defined for ${this.singularName}`);if(this.ldap.declareObjectClasses(this.base,this.objectClass),e.schemaPath)try{this.schema=L(e.schemaPath,{config:this.config}),this.ldap.knowAttributeNames(Object.keys(this.schema.attributes)),this.logger.info(`${this.singularName} schema loaded from ${e.schemaPath}`)}catch(t){this.logger.error(`Failed to load ${this.singularName} schema from ${e.schemaPath}: ${t}`)}}hiddenAttributes(){return A([this.schema])}entryAttributes(){return["*",...Object.keys(this.schema?.attributes??{})]}project(t){const e=this.hiddenAttributes();if(0===e.size)return t;const i={};for(const[a,s]of Object.entries(t))e.has(a.split(";")[0].toLowerCase())||(i[a]=s);return i}projectList(t){if(0===this.hiddenAttributes().size)return t;const e={};for(const[i,a]of Object.entries(t))e[i]=this.project(a);return e}rejectForbiddenInput(t){$(this.schema,t,this.mainAttribute)}async generateMainAttribute(t){const e=this.deriveMainAttribute(t),i=this.schema?.attributes[this.mainAttribute]?.generatedFrom;if(void 0!==e&&i){if(!await this.rdnExists(e))return e;if("suffix"!==i.onCollision)throw new x(`${this.singularName} "${e}" already exists (generated from ${i.attribute})`);for(let t=2;t<1e3;t++){const i=`${e}-${t}`;if(!await this.rdnExists(i))return i}throw new x(`Cannot generate a free "${this.mainAttribute}" from "${i.attribute}"`)}}deriveMainAttribute(t){const e=this.schema?.attributes[this.mainAttribute],i=e?.generatedFrom;if(!i||!e.generated)return;const a=t[i.attribute],s=Array.isArray(a)?a[0]:a;if(null==s||""===s)throw new D(`Attribute "${i.attribute}" is required to generate "${this.mainAttribute}"`);let r=String(s);if(i.extract){const t=o(i.extract).exec(r);t&&(r=void 0!==t[1]?t[1]:t[0])}if(i.lowercase&&(r=r.toLowerCase()),i.strip&&(r=r.replace(o(i.strip,"g"),"")),!r)throw new D(`Cannot generate "${this.mainAttribute}" from "${i.attribute}"`);return r}candidateEntry(t,e){const i={...this.defaultAttributes,[this.mainAttribute]:t,...e,objectClass:this.objectClass};return j(this.schema,i),i}async rdnExists(t){try{const e=await this.ldap.system.search({paged:!1,scope:"base"},`${this.mainAttribute}=${h(t)},${this.base}`);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}validator(){return new N(this.schema,{entryExists:async t=>{try{return(await this.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}},loadedPlugins:this.server.loadedPlugins,singularName:this.singularName})}api(t){q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,query:z,handler:async({req:t,res:e,query:i})=>{const a={};i.match&&i.attribute&&(a.filter=y(i.match,i.attribute)),i.attributes&&(a.attributes=i.attributes.split(","));const s=i.limit;let r;try{r=await this.listEntriesLimited({...a,limit:s,req:t})}catch(t){if(void 0!==s||4!==P(t))throw t;throw new S("Too many entries to list at once: the directory's size limit was exceeded. Narrow the search, or pass `limit`.")}r.truncated&&e.set("X-Result-Truncated","true"),e.json(this.projectList(r.entries))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,params:F,handler:async({req:t,res:e})=>this.apiGet(t,e)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}`,handler:async({req:t,res:e})=>this.apiAdd(t,e)}),q(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,params:F,responses:{200:n},handler:async({req:t,res:e})=>this.apiDelete(t,e)}),q(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`,params:F,responses:{200:n},handler:async({req:t,res:e})=>this.apiModify(t,e)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`,params:F,body:I,responses:{200:M},handler:async({req:t,res:e})=>this.apiMove(t,e)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/rename`,params:F,body:H,handler:async({req:t,res:e})=>this.apiRename(t,e)})}async apiGet(t,e){const i=t.params.id;try{const a=this.resolveDn(i),s=await this.ldap.search({paged:!1,scope:"base",attributes:this.entryAttributes()},a,t);if(0===s.searchEntries.length)throw new O(`${this.singularName} not found`);e.json(this.project(s.searchEntries[0]))}catch(t){if(t.code&&32===t.code)throw new O(`${this.singularName} not found`);throw t}}async apiAdd(t,e){const s=this.schema?.attributes[this.mainAttribute],r=Boolean(s?.generated&&s.generatedFrom),n=r?i(t,e):i(t,e,this.mainAttribute);if(!n)return;this.rejectForbiddenInput(Object.keys(n));const o={...n};delete o[this.mainAttribute],delete o.dn;const h=r?this.deriveMainAttribute(n):void 0;void 0!==h&&await this.ldap.authorize("ldapaddrequest",[this.resolveDn(h),this.candidateEntry(h,o),t]);const d=r?await this.generateMainAttribute(n):n[this.mainAttribute];await this.addEntry(d,o,t);const c=await this.ldap.system.search({paged:!1,scope:"base",attributes:[this.mainAttribute]},this.creationOf(d).dn);return a(e,this.project(c.searchEntries[0]))}async apiDelete(t,e){const i=t.params.id;await s(e,this.deleteEntry.bind(this),i,t)}async apiModify(t,a){const r=i(t,a);if(!r)return;e(r),this.rejectForbiddenInput(v(r));const n=t.params.id;await s(a,this.modifyEntry.bind(this),n,r,t)}async apiMove(t,e){const i=t.body,a=t.params.id,{targetOrgDn:s}=i,r=await this.moveEntry(a,s,t);e.json({success:!0,...r})}async apiRename(t,e){const i=t.body,a=i.newId;if(this.rejectForbiddenInput(Object.keys(i).filter(t=>"newId"!==t)),new RegExp(`^${d(this.mainAttribute)}=`,"i").test(a))throw new D(`newId must be a ${this.mainAttribute} value, not a DN`);try{c(a,this.mainAttribute)}catch(t){throw new D(t instanceof Error?t.message:"Invalid newId")}await this.validator().validateValue(this.mainAttribute,a);const s=t.params.id,n=this.resolveDn(s),o=this.resolveDn(a),h=this.config.group_dummy_user;if(h&&(l(n,h)||l(o,h)))throw new x(`${h} is the configured placeholder member and cannot be renamed`);await this.ldap.authorize("ldaprenamerequest",[n,o,t]);const m=u(n)===u(o),p=await this.entryExists(n),b=m?p:await this.entryExists(o),f=this.requirePlugin("ldapEnterpriseRules");let w=[];if(m){if(!p)throw new O(`${this.singularName} not found`)}else if(p){if(b)throw new x(`${this.singularName} "${a}" already exists`);w=await(f?.findReferences(n))||[],await this.renameEntry(n,o,t)}else{if(!b)throw new O(`${this.singularName} not found`);w=await(f?.findReferences(n))||[]}const g=f&&w.length>0?await f.rewriteReferences(w,n,o,t):{updated:0,failed:[]},y={success:0===g.failed.length,dn:o,referencesUpdated:g.updated};0!==g.failed.length?r(e,{...y,referencesFailed:g.failed}):e.json(y)}async entryExists(t){try{const e=await this.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t);return(e?.searchEntries?.length??0)>0}catch(t){return!1}}resolveDn(t){if(m(t,this.mainAttribute)){const e=p(t),i=e!==t;if(!b(t,this.base))throw new D(`DN must be a direct child of "${this.base}". Provided DN "${t}" has parent "${i?e:"<none>"}"`);return t}return`${this.mainAttribute}=${h(t)},${this.base}`}creationOf(t){if(!m(t,this.mainAttribute))return{name:t,dn:this.resolveDn(t)};f(t,`${this.mainAttribute} DN`),this.resolveDn(t);const e=w(t,this.mainAttribute,this.base);if(null===e)throw new D(`${this.mainAttribute} must be a value, or a "${this.mainAttribute}=…,${this.base}" DN`);return{name:e,dn:`${this.mainAttribute}=${h(e)},${this.base}`}}async addEntry(t,e={},i){if("string"!=typeof t)throw new D(`${this.mainAttribute} must be a string`);await this.ldap.canonicalAttributes(e);const a=this.creationOf(t);t=a.name;let s=a.dn;try{c(t,this.mainAttribute)}catch(t){throw new D(t instanceof Error?t.message:"Invalid identifier")}let r=this.candidateEntry(t,e);await this.ldap.authorize("ldapaddrequest",[s,{...r},i]);const n=await this.validateNewEntry(s,{[this.mainAttribute]:t,...e,objectClass:this.objectClass});r={...this.candidateEntry(t,{}),...n},E(this.schema,r),this.schema?this.logger.debug("Schema loaded, attributes:",Object.keys(this.schema.attributes)):this.logger.warn("No schema available"),[s,r]=await this.ownHooks.chain("add",[s,r]),this.logger.debug("Adding LDAP entry:",{dn:s,entry:JSON.stringify(r,null,2)}),this.logger.debug("Entry attributes breakdown:");for(const[t,e]of Object.entries(r))this.logger.debug(` ${t}: ${typeof e} = ${JSON.stringify(e)}`);let o;try{o=await this.ldap.add(s,r,i)}catch(t){const e=t instanceof k&&t.statusCode<500;if(this.logger[e?"debug":"error"]("LDAP add failed:",{dn:s,entry:JSON.stringify(r,null,2),error:t}),t instanceof k||C(t))throw t;throw new Error(`Failed to add ${this.singularName} ${s}: ${String(t)}`,{cause:t})}return this.ownHooks.notify("adddone",s,r),o}async modifyEntry(t,e,i){await this.ldap.canonicalChanges(e);let a=this.resolveDn(t);const s=this.opNumber();if([a,e]=await this.ownHooks.chain("modify",[a,e,s]),e.add&&e.add[this.mainAttribute])throw new x(`${this.mainAttribute} attribute is unique, cannot add`);if(e.delete){if(e.delete instanceof Object&&e.delete[this.mainAttribute])throw new D(`Cannot delete ${this.mainAttribute} attribute`);if(Array.isArray(e.delete)&&e.delete.includes(this.mainAttribute))throw new D(`Cannot delete ${this.mainAttribute} attribute`)}if(e.replace&&e.replace[this.mainAttribute])throw new D(`Use dedicated API to change ${this.mainAttribute} attribute`);await this.ldap.authorize("ldapmodifyrequest",[a,e,s,i]),await this.validateChanges(a,e);const r=await this.ldap.modify(a,e,i);return this.ownHooks.notify("modifydone",a,e,s),r}async renameEntry(t,e,i){/,/.test(t)||c(t,this.mainAttribute),/,/.test(e)||c(e,this.mainAttribute);let a=this.resolveDn(t),s=this.resolveDn(e);[a,s]=await this.ownHooks.chain("rename",[a,s]);const r=await this.ldap.rename(a,s,i);return this.ownHooks.notify("renamedone",a,s),r}async deleteEntry(t,e){let i=this.resolveDn(t);i=await this.ownHooks.chain("delete",i);const a=await this.ldap.delete(i,e);return this.ownHooks.notify("deletedone",i),a}async moveEntry(t,e,i){f(e,"targetOrgDn");const a=this.resolveDn(t),s=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",r=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";if(!this.schema?.attributes[s]||!this.schema?.attributes[r])throw new D(`Schema for ${this.singularName} does not support move operation (missing ${s} or ${r})`);let n;try{n=await this.ldap.search({paged:!1,scope:"base",attributes:[s]},a,i)}catch(t){if(32===P(t))throw new O(`Entry ${a} not found`);throw t}if(!n.searchEntries||0===n.searchEntries.length)throw new O(`Entry ${a} not found`);const o=await this.getDepartmentPath(e);[,e]=await this.ownHooks.chain("move",[a,e,i]);const h={replace:{[s]:e,[r]:o}};return await this.modifyEntry(t,h,i),{departmentPath:o,departmentLink:e}}async getDepartmentPath(t){const e=this.config.ldap_organization_path_attribute||"twakeDepartmentPath";try{const i=await this.ldap.system.search({paged:!1,scope:"base",attributes:[e,"ou","o"]},t);if(!i.searchEntries||0===i.searchEntries.length)throw new O(`Organization ${t} not found`);const a=i.searchEntries[0];if(a[e]){const t=a[e];return Array.isArray(t)?String(t[0]):String(t)}const s=a.ou||a.o;if(s){return`/${Array.isArray(s)?String(s[0]):String(s)}`}return this.logger.warn(`Organization ${t} has no ${e} attribute, using DN`),t}catch(e){if(e instanceof O)throw e;const i=P(e);if(32===i)throw new O(`Organization ${t} not found`);if(34===i)throw new D(`${t} is not a valid DN`);throw new Error(`Failed to fetch organization ${t}: ${String(e)}`,{cause:e})}}async listEntries(t){return(await this.listEntriesLimited(t)).entries}async listEntriesLimited({filter:t,attributes:e,req:i,limit:a}){const s={paged:void 0===a||{pageSize:Math.min(a+2,100)},filter:t||"(objectClass=*)"};if(e&&e.length>0){const t=this.mainAttribute.toLowerCase();s.attributes=e.some(e=>e.toLowerCase()===t)?e:[...e,this.mainAttribute]}const r=function(t,e){const i={},a=new Set;return{entries:i,add(s){for(const r of s){const s=r[t],n=Array.isArray(s)?s[0]:s;if(null==n||""===n)continue;const o=String(n);if(!a.has(o)){if(void 0!==e&&a.size>=e)return!1;a.add(o)}i[o]=r}return!0}}}(this.mainAttribute,a);try{const t=await this.ldap.search(s,this.base,i);for await(const e of t)if(!r.add(e.searchEntries))return{entries:r.entries,truncated:!0};return{entries:r.entries,truncated:!1}}catch(t){if(void 0===a||4!==P(t))throw t}try{const t=await this.ldap.search({...s,paged:!1,sizeLimit:Math.min(a+1,2147483647)},this.base,i);r.add(t.searchEntries)}catch(t){if(4!==P(t))throw t}return{entries:r.entries,truncated:!0}}async searchEntriesByName(t,e=!1,i=[this.mainAttribute]){const a=g(t),s=e?`(${this.mainAttribute}=*${a}*)`:`(${this.mainAttribute}=${a})`;return await this.listEntries({filter:s,attributes:i})}async validateNewEntry(t,e){return await this.validator().validateNew(e)}async validateChanges(t,e){return await this.validator().validateChanges(e),!0}}export{F as FlatEntryParams,z as FlatListQuery,I as FlatMoveBody,M as FlatMoveResponse,H as FlatRenameBody,LdapFlat as default};
@@ -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 g}from"../utils.js";import{authzFor as w,servesRequest as p}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=g(i).length;t>s&&([r,s]=[n,t])}return r}class AuthzBase extends t{static widenedForLink=Symbol("widened for the link");static topOrganizationRead=Symbol("top organization");roles=["authz"];cacheTTL;static UNRESOLVED_POLICIES=["deny","allow"];constructor(t){super(t),r(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 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)}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||!p(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),g=new Set;let w=h(r);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 ${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()}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),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(),g=f&&u&&this.inTransit(r),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 ${s.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 ${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(!g&&!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;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];if(t===this.config.ldap_top_organization&&"base"===e.scope)return[t,{...e,[AuthzBase.topOrganizationRead]:!0},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}}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()){if(r?.[AuthzBase.topOrganizationRead])return s;const n=await this.ruleBranches(i),a=[],o=new Map,c=new Set(n.map(d));for(const r of t.searchEntries){const t=String(r.dn),s=d(h(t)),n=c.has(d(t));let u=n?void 0:o.get(s);void 0===u&&(u=!0===(await this.getUserPermissions(i,t)).read,n||o.set(s,u)),u?a.push(r):this.logger.debug(`${this.name}: hiding ${t} from ${e.user}, refused by a narrower branch rule`)}return t.searchEntries=a,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)},f=[];for(const r of t.searchEntries){const t=r[a],s=null==t?[]:(Array.isArray(t)?t:[t]).map(String);if(0===s.length){f.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))?f.push(r):this.logger.debug(`${this.name}: hiding ${String(r.dn)} from ${e.user}, attached to ${s[0]}`)}if(t.searchEntries=f,r?.[AuthzBase.widenedForLink])for(const e of t.searchEntries)delete e[a];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),n=await this.storedParent(e),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),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 w}from"../utils.js";import{authzFor as g,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=w(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),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 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)}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)}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;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{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 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),w=new Set;let g=h(s);const p=new Set;for(;g&&!p.has(d(g));){const t=d(g);if(p.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 m=u.filter(t=>!w.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.resolutionCache.clear()}hooks={ldaprenamedone:()=>{this.resolutionCache.clear()},ldapdeletedone:()=>{this.resolutionCache.clear()},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),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(),w=f&&u&&this.inTransit(s),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 ${r.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 ${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(!w&&!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];let i;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(r,i)).write)throw new o(`User ${s.user} does not have write permission for branch ${i}`);const c=await this.refusingRule(r,h,["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(r,s,t,AuthzBase.valuesOf(e,u),u,!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,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 s=[].concat(e.objectClass??[]).map(t=>String(t).toLowerCase());return w.some(t=>s.includes(t))},g=[];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}g.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))?g.push(s):this.logger.debug(`${this.name}: hiding ${String(s.dn)} from ${e.user}, attached to ${r[0]}`)}if(t.searchEntries=g,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),n=await this.storedParent(e),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),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};
@@ -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_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:""}),base_url:t.string({env:"DM_BASE_URL",default:""})}),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})}),f=_({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,f 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"})}),o=_({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})}),s=_({authz_local_admin_attribute:t.string({env:"DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE",default:"twakeLocalAdminLink"}),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:""}),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(o,_)}function rateLimitOptionsOf(_){return e(A,_)}function crowdsecOptionsOf(_){return e(c,_)}export{c as crowdsecOptions,crowdsecOptionsOf,o as dynamicOptions,dynamicOptionsOf,a as fakeOptions,i as hmacOptions,hmacOptionsOf,s 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"../../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};
@@ -1 +1 @@
1
- import{randomUUID as e}from"crypto";import o from"express-openid-connect";import s from"../../lib/auth/base.js";import{serverError as t,unauthorized as i}from"../../lib/expressFormatedResponses.js";import{openidconnectOptions as n}from"./auth.options.js";const{auth:r,requiresAuth:a}=o,c=["/login","/logout","/callback","/backchannel-logout"],d=/end_session_endpoint/,u=/^\/logout\/?$/i,h="dmSession",sessionClaims=e=>{const{oidc:o,appSession:s}=e,t=o?.idTokenClaims;if(!t)return;const i=s?.[h];return"string"==typeof i?{...t,session:i}:t},subjectOf=e=>{try{const o=JSON.parse(Buffer.from(String(e).split(".")[1],"base64url").toString());return"string"==typeof o.sub?o.sub:void 0}catch{return}};class OpenIDConnect extends s{static optionDefinitions=n;name="openidconnect";configApiKey="openidconnect";roles=["auth","configurable"];router;warnedNoEndSession=!1;warnedNoRecord=!1;warnedUnnamed=!1;constructor(e){super(e);for(const e of["oidc_server","oidc_client_id","oidc_client_secret","base_url"])if(!this.config[e])throw new Error(`Missing config parameter ${e}`)}buildConfig(){const e=this.options(n);return{authRequired:!1,issuerBaseURL:e.oidc_server,clientID:e.oidc_client_id,secret:e.oidc_client_secret,baseURL:e.base_url,clientSecret:e.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},afterCallback:async(e,o,s)=>({...s,[h]:await this.sessionId(e,subjectOf(s.id_token))}),backchannelLogout:{onLogoutToken:async e=>{this.server.hookBus.has("oidclogouttoken")?await this.hookBus.notifyStrict("oidclogouttoken",e):this.logger.warn("openidconnect: a logout token arrived but no Back-Channel Logout plugin is loaded, so nothing records it")},onLogin:async e=>{if(!this.server.hookBus.has("oidclogin"))return;const o=e.oidc?.idTokenClaims;if(o)try{await this.hookBus.notifyStrict("oidclogin",o)}catch(o){const s=e.appSession;if(s)for(const e of Object.keys(s))delete s[e];throw o}},isLoggedOut:e=>this.loggedOut(e)}}}identitySource(){const e=this.options(n).oidc_username_claim;return`req.user: the OIDC sub, an opaque provider identifier; req.userName: the "${e}" claim`+("sub"===e?" — the same value, until --oidc-username-claim names another":"")}getConfigApiData(){return{enabled:!0,endpoints:{logout:"/logout"}}}get pathPrefixes(){const e=super.pathPrefixes;return 0===e.length?e:[...e,...c]}api(e){this.router=r(this.buildConfig()),super.api(e)}authMethod(e,o,s){if(!this.router)return t(o,new Error(`${this.name}: api() has not built the router`));this.router(e,o,r=>{if(r&&u.test(e.path)&&this.noEndSession(r))return o.redirect(this.options(n).base_url);if(r)return t(o,r);if(("GET"===e.method||"HEAD"===e.method)&&u.test(e.path))return void this.endHere(e).then(()=>o.oidc.logout({logoutParams:{post_logout_redirect_uri:void 0}}));const guard=()=>{a()(e,o,t=>{if(t)return i(o);const r=e.oidc.user,a=this.options(n).oidc_username_claim,c=r[a];"sub"!==a&&"string"!=typeof c&&this.logger.warn(`${this.name}: no "${a}" claim on this session, so req.userName falls back to the sub. Check --oidc-username-claim against the scopes the provider is asked for`),this.publishIdentity(e,String(r.sub),"string"==typeof c?c:String(r.sub)),s()})},c=this.renewExpired(e);c?c.then(guard):guard()})}renewExpired(e){const o=e.oidc,s=o.accessToken;if(!s?.isExpired())return;const end=()=>{e.appSession=void 0};if(o.refreshToken)return s.refresh().then(()=>{},e=>{this.logger.info(`${this.name}: the provider refused to renew an access token, so the session ends: ${String(e)}`),end()});end()}async loggedOut(e){if(!this.server.hookBus.has("oidcsessionvalid"))return!1;const o=sessionClaims(e),[,s]=await this.hookBus.chain("oidcsessionvalid",[o,!0]);return!s}async sessionId(o,s){const{oidc:t,appSession:i}=o,n=i?.[h];return"string"==typeof n&&void 0!==s&&t?.isAuthenticated()&&t.idTokenClaims?.sub===s&&!await this.loggedOut(o).catch(()=>!0)?n:e()}async endHere(e){if(!e.oidc?.isAuthenticated())return;const o=sessionClaims(e);if(!o)return;const{iss:s,sid:t,session:i}=o,n=i?{iss:s,session:i}:t?{iss:s,sid:t}:void 0;if(n)if(this.server.hookBus.has("oidclogouttoken"))try{await this.hookBus.notifyStrict("oidclogouttoken",n)}catch(e){this.logger.error(`${this.name}: /logout could not record the session it ends, so a copy of its cookie stays valid until its tokens expire: ${String(e)}`)}else this.warnedNoRecord||(this.warnedNoRecord=!0,this.logger.warn(`${this.name}: no Back-Channel Logout plugin is loaded, so /logout drops the browser's cookie and nothing else: a copy of it stays valid until its tokens expire. Load core/bcl to end the session here as well`));else this.warnedUnnamed||(this.warnedUnnamed=!0,this.logger.warn(`${this.name}: /logout ended a session nothing names — one established before this version, from a provider that sends no sid — so a copy of its cookie stays valid until its tokens expire`))}noEndSession(e){return!!(e instanceof Error&&d.test(e.message))&&(this.warnedNoEndSession||(this.warnedNoEndSession=!0,this.logger.warn(`${this.name}: the provider publishes no end_session_endpoint, so /logout ends the session here only, and the provider may log the caller back in`)),!0)}}export{OpenIDConnect as default};
1
+ import{randomUUID as e}from"crypto";import o from"express-openid-connect";import t from"../../lib/auth/base.js";import{forbidden as s,serverError as i,unauthorized as n}from"../../lib/expressFormatedResponses.js";import{openidconnectOptions as r}from"./auth.options.js";const{auth:a,requiresAuth:c}=o,d=["/login","/logout","/callback","/backchannel-logout"],h=/end_session_endpoint/,u=/^\/logout\/?$/i,l="dmSession",sessionClaims=e=>{const{oidc:o,appSession:t}=e,s=o?.idTokenClaims;if(!s)return;const i=t?.[l];return"string"==typeof i?{...s,session:i}:s},subjectOf=e=>{try{const o=JSON.parse(Buffer.from(String(e).split(".")[1],"base64url").toString());return"string"==typeof o.sub?o.sub:void 0}catch{return}};class OpenIDConnect extends t{static optionDefinitions=r;name="openidconnect";configApiKey="openidconnect";roles=["auth","configurable"];router;warnedNoEndSession=!1;warnedNoRecord=!1;warnedUnnamed=!1;constructor(e){super(e);for(const e of["oidc_server","oidc_client_id","oidc_client_secret","base_url"])if(!this.config[e])throw new Error(`Missing config parameter ${e}`);for(const e of this.options(r).oidc_logout_origins)try{new URL(e)}catch{throw new Error(`${this.name}: --oidc-logout-origins holds "${e}", which is not a URL`)}}buildConfig(){const e=this.options(r);return{authRequired:!1,issuerBaseURL:e.oidc_server,clientID:e.oidc_client_id,secret:e.oidc_client_secret,baseURL:e.base_url,clientSecret:e.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},afterCallback:async(e,o,t)=>({...t,[l]:await this.sessionId(e,subjectOf(t.id_token))}),backchannelLogout:{onLogoutToken:async e=>{this.server.hookBus.has("oidclogouttoken")?await this.hookBus.notifyStrict("oidclogouttoken",e):this.logger.warn("openidconnect: a logout token arrived but no Back-Channel Logout plugin is loaded, so nothing records it")},onLogin:async e=>{if(!this.server.hookBus.has("oidclogin"))return;const o=e.oidc?.idTokenClaims;if(o)try{await this.hookBus.notifyStrict("oidclogin",o)}catch(o){const t=e.appSession;if(t)for(const e of Object.keys(t))delete t[e];throw o}},isLoggedOut:e=>this.loggedOut(e)}}}identitySource(){const e=this.options(r).oidc_username_claim;return`req.user: the OIDC sub, an opaque provider identifier; req.userName: the "${e}" claim`+("sub"===e?" — the same value, until --oidc-username-claim names another":"")}getConfigApiData(){return{enabled:!0,endpoints:{logout:"/logout"}}}get pathPrefixes(){const e=super.pathPrefixes;return 0===e.length?e:[...e,...d]}api(e){this.router=a(this.buildConfig()),super.api(e)}logoutOrigins(){const{base_url:e,oidc_logout_origins:o}=this.options(r),t=new Set;for(const s of[e,...o])try{t.add(new URL(s).origin)}catch{}return[...t]}crossSite(e){const o=e.headers["sec-fetch-site"];if("same-origin"===o||"none"===o)return!1;const t=this.logoutOrigins();let s=!1;for(const o of["origin","referer"]){const i=e.headers[o];if("string"==typeof i){s=!0;try{if(t.includes(new URL(i).origin))return!1}catch{return!0}}}return"string"==typeof o||s}authMethod(e,o,t){return("GET"===e.method||"HEAD"===e.method)&&u.test(e.path)&&this.crossSite(e)?s(o,"Cross-site logout refused"):this.router?void this.router(e,o,s=>{if(s&&u.test(e.path)&&this.noEndSession(s))return o.redirect(this.options(r).base_url);if(s)return i(o,s);if(("GET"===e.method||"HEAD"===e.method)&&u.test(e.path))return void this.endHere(e).then(()=>o.oidc.logout({logoutParams:{post_logout_redirect_uri:void 0}}));const guard=()=>{c()(e,o,s=>{if(s)return n(o);const i=e.oidc.user,a=this.options(r).oidc_username_claim,c=i[a];"sub"!==a&&"string"!=typeof c&&this.logger.warn(`${this.name}: no "${a}" claim on this session, so req.userName falls back to the sub. Check --oidc-username-claim against the scopes the provider is asked for`),this.publishIdentity(e,String(i.sub),"string"==typeof c?c:String(i.sub)),t()})},a=this.renewExpired(e);a?a.then(guard):guard()}):i(o,new Error(`${this.name}: api() has not built the router`))}renewExpired(e){const o=e.oidc,t=o.accessToken;if(!t?.isExpired())return;const end=()=>{e.appSession=void 0};if(o.refreshToken)return t.refresh().then(()=>{},e=>{this.logger.info(`${this.name}: the provider refused to renew an access token, so the session ends: ${String(e)}`),end()});end()}async loggedOut(e){if(!this.server.hookBus.has("oidcsessionvalid"))return!1;const o=sessionClaims(e),[,t]=await this.hookBus.chain("oidcsessionvalid",[o,!0]);return!t}async sessionId(o,t){const{oidc:s,appSession:i}=o,n=i?.[l];return"string"==typeof n&&void 0!==t&&s?.isAuthenticated()&&s.idTokenClaims?.sub===t&&!await this.loggedOut(o).catch(()=>!0)?n:e()}async endHere(e){if(!e.oidc?.isAuthenticated())return;const o=sessionClaims(e);if(!o)return;const{iss:t,sid:s,session:i}=o,n=i?{iss:t,session:i}:s?{iss:t,sid:s}:void 0;if(n)if(this.server.hookBus.has("oidclogouttoken"))try{await this.hookBus.notifyStrict("oidclogouttoken",n)}catch(e){this.logger.error(`${this.name}: /logout could not record the session it ends, so a copy of its cookie stays valid until its tokens expire: ${String(e)}`)}else this.warnedNoRecord||(this.warnedNoRecord=!0,this.logger.warn(`${this.name}: no Back-Channel Logout plugin is loaded, so /logout drops the browser's cookie and nothing else: a copy of it stays valid until its tokens expire. Load core/bcl to end the session here as well`));else this.warnedUnnamed||(this.warnedUnnamed=!0,this.logger.warn(`${this.name}: /logout ended a session nothing names — one established before this version, from a provider that sends no sid — so a copy of its cookie stays valid until its tokens expire`))}noEndSession(e){return!!(e instanceof Error&&h.test(e.message))&&(this.warnedNoEndSession||(this.warnedNoEndSession=!0,this.logger.warn(`${this.name}: the provider publishes no end_session_endpoint, so /logout ends the session here only, and the provider may log the caller back in`)),!0)}}export{OpenIDConnect as default};
@@ -1 +1 @@
1
- import{Type as t}from"@sinclair/typebox";import i from"../../abstract/plugin.js";import{assertModifyRequest as a}from"../../lib/ldapActions.js";import{jsonBody as e,tryMethod as s,wantJson as n,tryMethodData as r,WriteResponse as o}from"../../lib/expressFormatedResponses.js";import{validateDnValue as h,escapeDnValue as d,getRdn as c,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as u,escapeLdapFilter as m,assertRfc4514Escapes as w,isChildOf as f,substringSearchFilter as z}from"../../lib/utils.js";import{BadRequestError as y,NotFoundError as b,ConflictError as $,isAuthzRefusal as _,HttpError as v}from"../../lib/errors.js";import{extractLdapCode as O}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as E}from"../../lib/schemaFile.js";import{staticSchemaUrl as A}from"../../lib/staticFiles.js";import{assertClientMaySet as L,modifiedAttributeNames as k}from"../../config/schema.js";import{applyFixedDefaults as j,EntityValidator as C}from"../../lib/entitySchema/validator.js";import{organizationOptions as S}from"./ldap.options.js";import{defineRoute as q}from"../../lib/route.js";const x=t.Object({dn:t.String({minLength:1})}),D=t.Object({q:t.String({minLength:1})});class LdapOrganizations extends i{static optionDefinitions=S;name="ldapOrganizations";roles=["api","consistency","configurable"];settings;pathAttr;linkAttr;schema;constructor(t){if(super(t),this.settings=this.options(S),!this.settings.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.settings.ldap_organization_path_attribute,this.linkAttr=this.settings.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.settings.ldap_top_organization,this.settings.ldap_organization_class,!0),this.settings.organization_schema){const t=this.settings.organization_schema;E(t,{config:this.config}).then(t=>{this.schema=t,this.server.ldap.knowAttributeNames(Object.keys(t.attributes)),this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/top`,handler:async({req:t,res:i})=>{await r(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,params:x,handler:async({req:t,params:i,res:a})=>{const e=i.dn;await r(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,params:x,handler:async({req:t,params:i,res:a})=>{const e=i.dn;await r(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,params:x,query:D,handler:async({req:t,params:i,query:a,res:e})=>{const s=i.dn;await r(e,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(s,a.q,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,params:x,query:D,handler:async({req:t,params:i,query:a,res:e})=>{const s=i.dn;await r(e,async()=>this.hideNeverReturn(await this.searchOrganisationTree(s,a.q,t)))}}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations`,responses:{200:o},handler:async({req:t,res:i})=>this.apiAdd(t,i)}),q(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiModify(t,i)}),q(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiDelete(t,i)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiMove(t,i)})}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;L(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const n=a.parentDn||this.settings.ldap_top_organization,r=`ou=${d(a.ou)},${n}`,o={ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t)),objectClass:this.settings.ldap_organization_class};await s(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const n=e(t,i);if(!n)return;a(n);const r=t.params.dn;if(!r)throw new y("dn is required");L(this.schema,k(n)),await s(i,this.modifyOrganization.bind(this),r,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=t.params.dn;if(!a)throw new y("dn is required");await s(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!n(t,i))return;const a=e(t,i,"targetOrgDn");if(!a)return;const s=t.params.dn;if(!s)throw new y("dn is required");const{targetOrgDn:o}=a;if(!o||"string"!=typeof o)throw new y("Missing or invalid targetOrgDn in request body");await r(i,this.moveOrganization.bind(this),s,o,t)}hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let s,n={},r={};const checkIsOu=async()=>{if(void 0!==s)return s;if(i.replace?.objectClass)s=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)s=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.system.search({paged:!1,scope:"base"},t);s=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return s};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new y("An organization link cannot be deleted");if(e)throw new y("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(n={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(n={...n,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(n).length>0){await checkIsOu()||await this.checkDeptLink(n)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^\s*ou\s*=/i.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]};async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.system.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new b(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.settings.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new y(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,s=this.settings.ldap_organization_path_separator,n=this.settings.ldap_top_organization,r=i&&n&&/^ou=/i.test(c(i))&&l(i,n)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let d=e;if((this.isOu(t)||void 0!==r)&&h){const t=n?g(n):"";if(t&&e.startsWith(h+s)&&e.endsWith(s+t))return;if(e===h){const t=n?p(n):"";if(!i||!t||!l(i,n)||p(i)===t||p(u(i))===t)return;throw new y(`Organization path "${e}" names no parent, but ${i} is not directly under ${n}`)}if(!e.endsWith(s+h))throw new y(`Organization path must end with its own name, preceded by separator "${s}"`);d=e.slice(0,e.length-s.length-h.length)}const w=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${m(d)})`,attributes:["dn"]},this.settings.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new y(`Invalid organization path ${e}: no organization holds "${d}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.system.search({paged:!1,filter:`(${this.settings.ldap_organization_link_attribute}=${m(t)})`})).searchEntries.length>0)throw new $(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.settings.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.settings.ldap_top_organization)throw new y("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.settings.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new b("Top organization not found");const a=i.searchEntries[0],[,e]=await this.hookBus.chain("getOrganisationTop",[t,a]);return e}async getOrganisationByDn(t,i){let a;try{a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i)}catch(i){const a=O(i);if(32===a)throw new b(`Organization ${t} not found`);if(34===a)throw new y(`${t} is not a valid DN`);throw i}if(1!==a.searchEntries.length)throw new b(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.settings.ldap_organization_max_subnodes,s=i?.query?.objectClass;s&&"organizationalUnit"!==s||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const n=this.settings.ldap_top_organization,r=u(n);let o=`(${this.settings.ldap_organization_link_attribute}=${m(t)})`;return s&&(o=`(&${o}(objectClass=${m(s)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}sizeLimitWarned=new Set;static SIZE_LIMIT_WARNED_MAX=500;warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),s=[];for await(const t of a)s.push(...t.searchEntries);return this.server.logger.debug(`Found ${s.length} sub-OUs for ${t}`),s}catch(i){const a=O(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(34===a)throw new y(`${t} is not a valid DN`);if(4!==a)throw i}const s=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${s.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",s.length)]}async linkedEntities(t,i,a,e,s){const n=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,s);for await(const t of e){r+=t.searchEntries.length;const i=a-n.length;i>0&&n.push(...t.searchEntries.slice(0,i))}}catch(n){if(t!==e&&(_(n)||403===n?.statusCode))return this.linkedEntities(e,i,a,e,s);if(4!==O(n))throw n;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,s);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=r.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${n.length}`),r<=a?n:[...n,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return j(this.schema,i),await this.server.ldap.authorize("ldapaddrequest",[t,i,a]),i=await this.validator().validateNew(i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.server.ldap.authorize("ldapmodifyrequest",[t,i,this.opNumber(),a]),await this.validator().validateChanges(i),await this.server.ldap.modify(t,i,a)}validator(){return new C(this.schema,{entryExists:t=>this.entryExists(t),loadedPlugins:this.server.loadedPlugins,singularName:"organization"})}async entryExists(t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){w(i,"targetOrgDn");const e=`${c(t)},${i}`;let s,n;await this.server.ldap.authorize("ldaprenamerequest",[t,e,a]);try{s=await this.server.ldap.system.search({paged:!1,scope:"base"},i)}catch(t){throw new b(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new b(`Target organization ${i} not found`);if(!this.isOu(s.searchEntries[0]))throw new y(`Target ${i} is not an organizational unit`);try{n=await this.server.ldap.system.search({paged:!1,scope:"base"},t)}catch(t){throw new b(`Source organization not found: ${t}`)}if(!n.searchEntries||0===n.searchEntries.length)throw new b(`Source organization ${t} not found`);if(e===t)throw new y("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new y("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,e,a),this.logger.info(`Moved organization from ${t} to ${e}`)}catch(t){if(t instanceof v||_(t))throw t;throw new Error(`Failed to move organization: ${String(t)}`,{cause:t})}return{newDn:e}}async deleteOrganization(t,i){try{return await this.server.ldap.delete(t,i)}catch(i){if(32===O(i))throw new b(`Organization ${t} not found`);throw i}}async searchOrganisationSubnodes(t,i,a){const e=[],s=this.settings.ldap_organization_max_subnodes;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${z(i,"ou,description")})`,s,a));const n=this.settings.ldap_top_organization,r=u(n),o=m(t),h=this.config.display_name_attribute||"displayName",d=`(&(${this.settings.ldap_organization_link_attribute}=${o})${z(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${d} in ${r}`),e.push(...await this.linkedEntities(r,d,s,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.settings.ldap_organization_max_subnodes,s=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",n=`(&(objectClass=organizationalUnit)${z(i,s)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:n},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const s=O(i);if(32===s)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(34===s)throw new y(`${t} is not a valid DN`);if(4!==s)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:n,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=e)return r;const h=o-e;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.settings.organization_schema?A(this.server,this.settings.organization_schema):void 0;return{enabled:!0,topOrganization:this.settings.ldap_top_organization,organizationClass:this.settings.ldap_organization_class,linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.settings.ldap_organization_path_separator,maxSubnodes:this.settings.ldap_organization_max_subnodes,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{x as OrganizationParams,D as OrganizationSearchQuery,LdapOrganizations as default};
1
+ import{Type as t}from"@sinclair/typebox";import i from"../../abstract/plugin.js";import{assertModifyRequest as a}from"../../lib/ldapActions.js";import{jsonBody as e,tryMethod as s,wantJson as n,tryMethodData as r,WriteResponse as o}from"../../lib/expressFormatedResponses.js";import{validateDnValue as h,escapeDnValue as d,getRdn as c,isDnInBranch as l,rdnValue as g,normalizeDn as p,getParentDn as u,escapeLdapFilter as m,assertRfc4514Escapes as w,isChildOf as f,substringSearchFilter as z}from"../../lib/utils.js";import{BadRequestError as y,NotFoundError as b,ConflictError as $,isAuthzRefusal as _,HttpError as v}from"../../lib/errors.js";import{extractLdapCode as O}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as E}from"../../lib/schemaFile.js";import{staticSchemaUrl as A}from"../../lib/staticFiles.js";import{assertClientMaySet as L,modifiedAttributeNames as k}from"../../config/schema.js";import{applyFixedDefaults as j,EntityValidator as C}from"../../lib/entitySchema/validator.js";import{organizationOptions as S}from"./ldap.options.js";import{defineRoute as q}from"../../lib/route.js";const x=t.Object({dn:t.String({minLength:1})}),D=t.Object({q:t.String({minLength:1})});class LdapOrganizations extends i{static optionDefinitions=S;name="ldapOrganizations";roles=["api","consistency","configurable"];settings;pathAttr;linkAttr;schema;constructor(t){if(super(t),this.settings=this.options(S),!this.settings.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.settings.ldap_organization_path_attribute,this.linkAttr=this.settings.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.settings.ldap_top_organization,this.settings.ldap_organization_class,!0),this.settings.organization_schema){const t=this.settings.organization_schema;E(t,{config:this.config}).then(t=>{this.schema=t,this.server.ldap.knowAttributeNames(Object.keys(t.attributes)),this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/top`,handler:async({req:t,res:i})=>{await r(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,params:x,handler:async({req:t,params:i,res:a})=>{const e=i.dn;await r(a,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,params:x,handler:async({req:t,params:i,res:a})=>{const e=i.dn;await r(a,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,params:x,query:D,handler:async({req:t,params:i,query:a,res:e})=>{const s=i.dn;await r(e,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(s,a.q,t)))}}),q(t,{method:"get",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,params:x,query:D,handler:async({req:t,params:i,query:a,res:e})=>{const s=i.dn;await r(e,async()=>this.hideNeverReturn(await this.searchOrganisationTree(s,a.q,t)))}}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations`,responses:{200:o},handler:async({req:t,res:i})=>this.apiAdd(t,i)}),q(t,{method:"put",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiModify(t,i)}),q(t,{method:"delete",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiDelete(t,i)}),q(t,{method:"post",path:`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,responses:{200:o},params:x,handler:async({req:t,res:i})=>this.apiMove(t,i)})}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;L(this.schema,Object.keys(a),"ou"),h(a.ou,"ou");const n=a.parentDn||this.settings.ldap_top_organization,r=`ou=${d(a.ou)},${n}`,o={ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t)),objectClass:this.settings.ldap_organization_class};await s(i,this.addOrganization.bind(this),r,o,t)}async apiModify(t,i){const n=e(t,i);if(!n)return;a(n);const r=t.params.dn;if(!r)throw new y("dn is required");L(this.schema,k(n)),await s(i,this.modifyOrganization.bind(this),r,n,t)}async apiDelete(t,i){if(!n(t,i))return;const a=t.params.dn;if(!a)throw new y("dn is required");await s(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!n(t,i))return;const a=e(t,i,"targetOrgDn");if(!a)return;const s=t.params.dn;if(!s)throw new y("dn is required");const{targetOrgDn:o}=a;if(!o||"string"!=typeof o)throw new y("Missing or invalid targetOrgDn in request body");await r(i,this.moveOrganization.bind(this),s,o,t)}hooks={ldapaddrequest:async([t,i,a])=>(this.isOu(i)||await this.checkDeptLink(i),this.isOu(i)&&await this.checkDeptPath(i,t),void 0!==a?[t,i,a]:[t,i]),ldapmodifyrequest:async([t,i,a,e])=>{let s,n={},r={};const checkIsOu=async()=>{if(void 0!==s)return s;if(i.replace?.objectClass)s=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)s=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.system.search({paged:!1,scope:"base"},t);s=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return s};if(i.delete){const t=(Array.isArray(i.delete)?i.delete.map(String):Object.keys(i.delete)).map(t=>t.split(";")[0].toLowerCase()),a=t.includes(this.linkAttr.toLowerCase()),e=t.includes(this.pathAttr.toLowerCase());if(a||e){if(!await checkIsOu()&&a)throw new y("An organization link cannot be deleted");if(e)throw new y("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(n={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(n={...n,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(n).length>0){await checkIsOu()||await this.checkDeptLink(n)}if(Object.keys(r).length>0){await checkIsOu()&&await this.checkDeptPath(r,t)}return[t,i,a,e]},ldapdeleterequest:async([t,i])=>{const a=Array.isArray(t)?t:[t];for(const t of a)/^\s*ou\s*=/i.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]};async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.system.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new b(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.settings.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new y(`Entry ${a} isn't in top organization branch`)}}async checkDeptPath(t,i){if(t[this.pathAttr]){const a=t[this.pathAttr],e=Array.isArray(a)?a[0]:a,s=this.settings.ldap_organization_path_separator,n=this.settings.ldap_top_organization,r=i&&n&&/^ou=/i.test(c(i))&&l(i,n)?g(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let d=e;if((this.isOu(t)||void 0!==r)&&h){const t=n?g(n):"";if(t&&e.startsWith(h+s)&&e.endsWith(s+t))return;if(e===h){const t=n?p(n):"";if(!i||!t||!l(i,n)||p(i)===t||p(u(i))===t)return;throw new y(`Organization path "${e}" names no parent, but ${i} is not directly under ${n}`)}if(!e.endsWith(s+h))throw new y(`Organization path must end with its own name, preceded by separator "${s}"`);d=e.slice(0,e.length-s.length-h.length)}const w=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${m(d)})`,attributes:["dn"]},this.settings.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new y(`Invalid organization path ${e}: no organization holds "${d}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.system.search({paged:!1,filter:`(${this.settings.ldap_organization_link_attribute}=${m(t)})`})).searchEntries.length>0)throw new $(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.settings.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.settings.ldap_top_organization)throw new y("No top organization configured");const i=await this.server.ldap.system.search({paged:!1,scope:"base"},this.settings.ldap_top_organization);if(1!==i.searchEntries.length)throw new b("Top organization not found");const a=i.searchEntries[0],[,e]=await this.hookBus.chain("getOrganisationTop",[t,a]);return e}async getOrganisationByDn(t,i){let a;try{a=await this.server.ldap.search({paged:!1,scope:"base",filter:"(objectClass=organizationalUnit)"},t,i)}catch(i){const a=O(i);if(32===a)throw new b(`Organization ${t} not found`);if(34===a)throw new y(`${t} is not a valid DN`);throw i}if(1!==a.searchEntries.length)throw new b(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.settings.ldap_organization_max_subnodes,s=i?.query?.objectClass;s&&"organizationalUnit"!==s||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const n=this.settings.ldap_top_organization,r=u(n);let o=`(${this.settings.ldap_organization_link_attribute}=${m(t)})`;return s&&(o=`(&${o}(objectClass=${m(s)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}sizeLimitWarned=new Set;static SIZE_LIMIT_WARNED_MAX=500;warnSizeLimit(t,i){this.sizeLimitWarned.has(t)?this.server.logger.debug(i):(this.sizeLimitWarned.size<LdapOrganizations.SIZE_LIMIT_WARNED_MAX&&this.sizeLimitWarned.add(t),this.server.logger.warn(i))}static moreIndicator(t,i,a,e){return{dn:t,cn:[i],objectClass:["moreIndicator"],_isMoreIndicator:"true",...void 0===e?{}:{_totalCount:e.toString()},_displayedCount:a.toString()}}async childOrganizations(t,i,a,e){try{const a=await this.server.ldap.search({paged:!0,scope:"one",filter:i},t,e),s=[];for await(const t of a)s.push(...t.searchEntries);return this.server.logger.debug(`Found ${s.length} sub-OUs for ${t}`),s}catch(i){const a=O(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(34===a)throw new y(`${t} is not a valid DN`);if(4!==a)throw i}const s=(await this.server.ldap.search({paged:!1,scope:"one",filter:i,sizeLimit:a+1},t,e)).searchEntries.slice(0,a);return this.warnSizeLimit(`children:${t}`,`Organization ${t} holds more child organizations than the directory will list in one answer, so ${s.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to list them all.`),[...s,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",s.length)]}async linkedEntities(t,i,a,e,s){const n=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,s);for await(const t of e){r+=t.searchEntries.length;const i=a-n.length;i>0&&n.push(...t.searchEntries.slice(0,i))}}catch(n){if(t!==e&&(_(n)||403===n?.statusCode))return this.linkedEntities(e,i,a,e,s);if(4!==O(n))throw n;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,s);this.warnSizeLimit(`linked:${e}`,`More entries are attached to ${e} than the directory will list in one answer. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to count them all.`);const o=r.searchEntries.slice(0,a);return[...o,LdapOrganizations.moreIndicator(`more-${e}`,"... more elements than the directory will list",o.length)]}return this.server.logger.debug(`Found ${r} linked entities for ${e}, returning ${n.length}`),r<=a?n:[...n,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return j(this.schema,i),await this.server.ldap.authorize("ldapaddrequest",[t,i,a]),i=await this.validator().validateNew(i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.server.ldap.authorize("ldapmodifyrequest",[t,i,this.opNumber(),a]),await this.validator().validateChanges(i),await this.server.ldap.modify(t,i,a)}validator(){return new C(this.schema,{entryExists:t=>this.entryExists(t),loadedPlugins:this.server.loadedPlugins,singularName:"organization"})}async entryExists(t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){w(i,"targetOrgDn");const e=`${c(t)},${i}`;let s,n;await this.server.ldap.authorize("ldaprenamerequest",[t,e,a]);try{s=await this.server.ldap.system.search({paged:!1,scope:"base"},i)}catch(t){throw new b(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new b(`Target organization ${i} not found`);if(!this.isOu(s.searchEntries[0]))throw new y(`Target ${i} is not an organizational unit`);try{n=await this.server.ldap.system.search({paged:!1,scope:"base"},t)}catch(t){throw new b(`Source organization not found: ${t}`)}if(!n.searchEntries||0===n.searchEntries.length)throw new b(`Source organization ${t} not found`);if(e===t)throw new y("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||f(i,t))throw new y("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,e,a),this.logger.info(`Moved organization from ${t} to ${e}`)}catch(t){if(t instanceof v||_(t))throw t;throw new Error(`Failed to move organization: ${String(t)}`,{cause:t})}return{newDn:e}}async deleteOrganization(t,i){try{return await this.server.ldap.delete(t,i)}catch(i){if(32===O(i))throw new b(`Organization ${t} not found`);throw i}}async searchOrganisationSubnodes(t,i,a){const e=[],s=this.settings.ldap_organization_max_subnodes;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${z(i,"ou,description")})`,s,a));const n=this.settings.ldap_top_organization,r=u(n),o=m(t),h=this.config.display_name_attribute||"displayName",d=`(&(${this.settings.ldap_organization_link_attribute}=${o})${z(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${d} in ${r}`),e.push(...await this.linkedEntities(r,d,s,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.settings.ldap_organization_max_subnodes,s=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",n=`(&(objectClass=organizationalUnit)${z(i,s)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:n},t,a);for await(const t of i){o+=t.searchEntries.length;const i=e-r.length;i>0&&r.push(...t.searchEntries.slice(0,i))}}catch(i){const s=O(i);if(32===s)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(34===s)throw new y(`${t} is not a valid DN`);if(4!==s)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:n,sizeLimit:e+1},t,a)).searchEntries.slice(0,e);return this.warnSizeLimit(`search:${t}`,`A search under ${t} matched more organizations than the directory will list in one answer, so ${r.length} are returned and the rest are hidden. Raise the size limit for the account ldap-rest binds as (olcLimits, or olcSizeLimit) to search them all.`),[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",r.length)]}if(o<=e)return r;const h=o-e;return[...r,LdapOrganizations.moreIndicator(`more-organizations-${t}`,`... ${h} more ${1===h?"organization":"organizations"}, narrow the search`,e,o)]}getConfigApiData(){const t=this.config.api_prefix||"/api",i=this.settings.organization_schema?A(this.server,this.settings.organization_schema):void 0;return{enabled:!0,topOrganization:this.settings.ldap_top_organization,organizationClass:this.settings.ldap_organization_class,linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.settings.ldap_organization_path_separator,maxSubnodes:this.settings.ldap_organization_max_subnodes,schema:this.schema,schemaUrl:i,endpoints:{getTop:`${t}/v1/ldap/organizations/top`,get:`${t}/v1/ldap/organizations/:dn`,getSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes`,searchSubnodes:`${t}/v1/ldap/organizations/:dn/subnodes/search`,search:`${t}/v1/ldap/organizations/:dn/search`,create:`${t}/v1/ldap/organizations`,update:`${t}/v1/ldap/organizations/:dn`,move:`${t}/v1/ldap/organizations/:dn/move`,delete:`${t}/v1/ldap/organizations/:dn`}}}}export{x as OrganizationParams,D as OrganizationSearchQuery,LdapOrganizations as default};
@@ -46,13 +46,8 @@ export default abstract class AuthzBase extends DmPlugin {
46
46
  * An object spread copies the key; a set would no longer know the copy.
47
47
  */
48
48
  private static readonly widenedForLink;
49
- /**
50
- * The search of the top organization by itself, which every caller may
51
- * make (`getOrganisationTop`): granted by `ldapsearchrequest` whatever the
52
- * rules say, it is not filtered by them either. A symbol key, for the
53
- * reason given at {@link widenedForLink}.
54
- */
55
- private static readonly topOrganizationRead;
49
+ /** Searches widened to carry `objectClass`, by the same means and for the same reason. */
50
+ private static readonly widenedForObjectClass;
56
51
  roles: Role[];
57
52
  cacheTTL: number;
58
53
  /** What `--authz-unresolved-user` accepts. */
@@ -100,13 +95,13 @@ export default abstract class AuthzBase extends DmPlugin {
100
95
  * the judgement of `ldapsearchrequest`, for `authzScope` to ask it of a
101
96
  * branch without searching it.
102
97
  *
103
- * Filtering, an administrator reads the directory whole — the
104
- * organization tree, the groups, the nomenclatures — and what is held to
105
- * a branch is the accounts, recognised one entry at a time by
106
- * `ldapsearchfilter`; only a global branch asks for the branch itself.
107
- * Otherwise the branch decides. Where a branch is a tenant rather than a
108
- * department, letting a listing cross it is letting a customer read
109
- * another.
98
+ * Filtering, a search is granted broadly — the groups, the nomenclatures,
99
+ * the entries attached to the caller's branches — and what is held to a
100
+ * branch is judged one entry at a time by `ldapsearchfilter`: an account by
101
+ * its link, an organization by the branch it sits in. Only a global branch
102
+ * asks for the branch itself. Otherwise the branch decides. Where a branch
103
+ * is a tenant rather than a department, letting a listing cross it is
104
+ * letting a customer read another.
110
105
  *
111
106
  * @param user the caller's DN
112
107
  * @param base the base of the search
@@ -61,6 +61,7 @@ export declare const openidconnectOptions: {
61
61
  oidc_client_id: import("../../config/options").StringOption;
62
62
  oidc_client_secret: import("../../config/options").StringOption;
63
63
  base_url: import("../../config/options").StringOption;
64
+ oidc_logout_origins: import("../../config/options").ArrayOption;
64
65
  };
65
66
  export type OpenidconnectOptions = OptionsOf<typeof openidconnectOptions>;
66
67
  export declare const rateLimitOptions: {
@@ -10,6 +10,7 @@ export default class OpenIDConnect extends AuthBase {
10
10
  oidc_client_id: import("../../config/options").StringOption;
11
11
  oidc_client_secret: import("../../config/options").StringOption;
12
12
  base_url: import("../../config/options").StringOption;
13
+ oidc_logout_origins: import("../../config/options").ArrayOption;
13
14
  };
14
15
  name: string;
15
16
  configApiKey: string;
@@ -78,6 +79,26 @@ export default class OpenIDConnect extends AuthBase {
78
79
  * @param app the express application
79
80
  */
80
81
  api(app: Express): void;
82
+ /**
83
+ * Origins a logout may come from: `--base-url`, and the consoles named in
84
+ * `--oidc-logout-origins` when they are served elsewhere.
85
+ */
86
+ private logoutOrigins;
87
+ /**
88
+ * Whether a browser reports the request as coming from another site.
89
+ *
90
+ * `/logout` is a `GET` a link reaches, so a page on another site can end
91
+ * the session without a token. The origin is what tells the caller's own
92
+ * link apart from someone else's. `Sec-Fetch-Site` answers where a browser
93
+ * sends it; `Origin` and `Referer` cover the others, and name the console
94
+ * when it is served from another origin. A client sending none of them — a
95
+ * script, a server — is not a browser navigating from another site, and is
96
+ * let through.
97
+ *
98
+ * @param req incoming request
99
+ * @returns true when the request comes from another site
100
+ */
101
+ private crossSite;
81
102
  /**
82
103
  * Read the session, then require one.
83
104
  *
@@ -375,6 +375,7 @@ Development only, refused with `NODE_ENV=production` — see [Fake Authenticatio
375
375
  | `--oidc-client-id` | `DM_OIDC_CLIENT_ID` | | OIDC Client ID |
376
376
  | `--oidc-client-secret` | `DM_OIDC_CLIENT_SECRET` | | OIDC Client Secret |
377
377
  | `--oidc-username-claim` | `DM_OIDC_USERNAME_CLAIM` | `sub` | Claim naming the caller, published as `req.userName` (see [OIDC](plugins/auth/oidc.md)) |
378
+ | `--oidc-logout-origins` | `DM_OIDC_LOGOUT_ORIGINS` | `[]` | Other origins a console may log out from (see [OIDC](plugins/auth/oidc.md#logout)) |
378
379
  | `--base-url` | `DM_BASE_URL` | | Public URL for callbacks |
379
380
 
380
381
  #### `core/bcl`
@@ -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`).
@@ -58,11 +58,16 @@ belongs to nobody's branch, and no administrator could list or change one.
58
58
  - Listings of accounts and groups drop the entries attached to an
59
59
  organization outside the caller's branches. A single read of one answers
60
60
  `404`.
61
- - Entries attached to nothing — the organizations, the nomenclature values, a
62
- group or an account with no `twakeDepartmentLink` — are readable by every
63
- administrator: the organization tree is reference data. Writes to them are
64
- judged on their parent. The tree a console draws still starts at the
65
- caller's own branches: see [getOrganisationTop Hook](#getorganisationtop-hook).
61
+ - An organization is judged by the branch it sits in. The tree under the top
62
+ organization carries every branch's name, description and local
63
+ administrators, so a listing returns the caller's branches, and a direct
64
+ read of another branch answers `404`. `organizations/top` still answers one
65
+ of their branches: see [getOrganisationTop Hook](#getorganisationtop-hook).
66
+ - Entries attached to nothing — a nomenclature value, a group or an account
67
+ with no `twakeDepartmentLink` — are readable by every administrator:
68
+ reference data. Writes to them are judged on their parent. The transit
69
+ branch's own entry stays readable too: the console opens its card from the
70
+ scope banner.
66
71
  - A caller named in no `twakeLocalAdminLink` is refused every read (`403`)
67
72
  but the top organization's own entry, which `organizations/top` answers.
68
73
 
@@ -113,6 +113,17 @@ browser is sent to the provider's `end_session_endpoint` with the id token as
113
113
  No `post_logout_redirect_uri` is sent: the caller ends on the provider's own
114
114
  logout page, and nothing has to be registered there.
115
115
 
116
+ A request a browser reports as coming from another site — `Sec-Fetch-Site`
117
+ other than `same-origin` or `none`, or an `Origin` or `Referer` naming another
118
+ origin — is refused (`403`) and leaves the session alone. `/logout` is a `GET`
119
+ a link reaches and asks for no token, so without that check any page could end
120
+ an administrator's session. A console served from another origin than
121
+ `--base-url` names that origin in `--oidc-logout-origins`
122
+ (`DM_OIDC_LOGOUT_ORIGINS`, comma-separated), and is then the only other origin
123
+ a logout is accepted from. A link from such a console is recognized by its
124
+ `Referer`: a page setting `Referrer-Policy: no-referrer`, or a link marked
125
+ `noreferrer`, sends neither `Referer` nor `Origin`, and its logout is refused.
126
+
116
127
  Ending the cookie alone would not log anyone out: every guarded path goes
117
128
  through the provider, which still holds its session and logs the caller back
118
129
  in without asking anything.
@@ -4,6 +4,40 @@ What to check before deploying, newest first. Only releases that need a
4
4
  decision or a configuration change appear here; see the
5
5
  [CHANGELOG](../../CHANGELOG.md) for everything else.
6
6
 
7
+ ## From 2.0.2
8
+
9
+ ### A console on another origin must be trusted for logout
10
+
11
+ **Who is affected:** a deployment whose console is served from an origin
12
+ other than `--base-url`, for example a front-end calling the API with its own
13
+ `apiBaseUrl`.
14
+
15
+ `/logout` refuses a request a browser reports as coming from another site.
16
+ List the console's origin in `--oidc-logout-origins` (or
17
+ `DM_OIDC_LOGOUT_ORIGINS`, comma-separated); otherwise its sign-out link
18
+ answers `403` and the session stays. A console served by ldap-rest itself,
19
+ from `--base-url`, needs nothing.
20
+
21
+ ### The organization tree is held to branches
22
+
23
+ **Who is affected:** a deployment using `core/auth/authzLinid1`, or
24
+ `core/auth/authzPerBranch` with `--authz-filter-attached-entries`.
25
+
26
+ An administrator no longer reads the organizations of another branch: a
27
+ single read answers `404`, and a subnode listing or a search returns only the
28
+ branches they hold. The tree names every branch, its description and its
29
+ local administrators, which an administrator of one branch has no business
30
+ reading. `organizations/top` still answers one of their branches — or the
31
+ top organization for a caller named in no `twakeLocalAdminLink` — and the
32
+ transit branch's own entry stays readable so that the console can open its
33
+ card from the scope banner. Naming the tree top directly is refused like any
34
+ organization outside their branches.
35
+
36
+ A console drawing the whole tree from an administrator's session draws their
37
+ branches only. A client that listed the tree to let one administrator choose
38
+ another branch as a destination loses that choice; the write was refused on
39
+ the destination branch anyway.
40
+
7
41
  ## From 1.3.0
8
42
 
9
43
  ### A plugin with its own `service` member
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-rest",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Light directory manager",
5
5
  "keywords": [
6
6
  "Identity-Manager"