ldap-rest 0.26.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/abstract/ldapFlat.js +1 -1
  2. package/dist/abstract/twakePlugin.js +1 -1
  3. package/dist/bin/index.js +1 -1
  4. package/dist/config/args.js +1 -1
  5. package/dist/lib/authz/base.js +1 -1
  6. package/dist/lib/authz/composition.js +1 -1
  7. package/dist/lib/ldapActions.js +1 -1
  8. package/dist/lib/mailDomains.js +1 -0
  9. package/dist/lib/requestContext.js +1 -0
  10. package/dist/lib/utils.js +1 -1
  11. package/dist/plugins/auth/authzDynamic.js +1 -1
  12. package/dist/plugins/auth/authzLinid1.js +1 -1
  13. package/dist/plugins/auth/authzPerBranch.js +1 -1
  14. package/dist/plugins/auth/authzScope.js +1 -1
  15. package/dist/plugins/auth/openidconnect.js +1 -1
  16. package/dist/plugins/bcl.js +1 -1
  17. package/dist/plugins/configApi.js +1 -1
  18. package/dist/plugins/ldap/bulkImport.js +1 -1
  19. package/dist/plugins/ldap/departmentSync.js +1 -1
  20. package/dist/plugins/ldap/enterpriseRules.js +1 -1
  21. package/dist/plugins/ldap/externalUsersInGroups.js +1 -1
  22. package/dist/plugins/ldap/groups.js +1 -1
  23. package/dist/plugins/ldap/onChange.js +1 -1
  24. package/dist/plugins/ldap/organizations.js +1 -1
  25. package/dist/plugins/ldap/passwordPolicy.js +1 -1
  26. package/dist/plugins/ldap/raw.js +1 -1
  27. package/dist/plugins/ldap/trash.js +1 -1
  28. package/dist/plugins/rabbitmq.js +1 -1
  29. package/dist/plugins/scim/groups.js +1 -1
  30. package/dist/plugins/scim/users.js +1 -1
  31. package/dist/plugins/storage/ldap.js +1 -1
  32. package/dist/plugins/storage/postgres.js +1 -1
  33. package/dist/plugins/storage/valkey.js +1 -1
  34. package/dist/plugins/twake/appAccountsApi.js +1 -1
  35. package/dist/plugins/twake/appAccountsConsistency.js +1 -1
  36. package/dist/plugins/twake/clouderyProvision.js +1 -1
  37. package/dist/plugins/twake/groups.js +1 -1
  38. package/dist/plugins/twake/instances.js +1 -1
  39. package/dist/plugins/twake/james.js +1 -1
  40. package/dist/plugins/twake/lifecycleEvents.js +1 -1
  41. package/dist/plugins/twake/spaces.js +1 -1
  42. package/dist/plugins/twake/tombstone.js +1 -1
  43. package/dist/src/abstract/ldapFlat.d.ts +25 -6
  44. package/dist/src/abstract/plugin.d.ts +1 -1
  45. package/dist/src/bin/index.d.ts +12 -0
  46. package/dist/src/hooks.d.ts +12 -3
  47. package/dist/src/lib/authz/base.d.ts +87 -4
  48. package/dist/src/lib/authz/composition.d.ts +21 -0
  49. package/dist/src/lib/ldapActions.d.ts +139 -18
  50. package/dist/src/lib/mailDomains.d.ts +41 -0
  51. package/dist/src/lib/requestContext.d.ts +23 -0
  52. package/dist/src/lib/utils.d.ts +41 -2
  53. package/dist/src/plugins/auth/authzDynamic.d.ts +5 -12
  54. package/dist/src/plugins/auth/authzLinid1.d.ts +3 -1
  55. package/dist/src/plugins/auth/authzPerBranch.d.ts +5 -9
  56. package/dist/src/plugins/auth/openidconnect.d.ts +47 -0
  57. package/dist/src/plugins/bcl.d.ts +2 -1
  58. package/dist/src/plugins/ldap/accountLifecycle.d.ts +2 -2
  59. package/dist/src/plugins/ldap/bulkImport.d.ts +30 -5
  60. package/dist/src/plugins/ldap/enterpriseRules.d.ts +11 -1
  61. package/dist/src/plugins/ldap/externalUsersInGroups.d.ts +20 -3
  62. package/dist/src/plugins/ldap/groups.d.ts +48 -1
  63. package/dist/src/plugins/ldap/organizations.d.ts +4 -4
  64. package/dist/src/plugins/twake/appAccountsApi.d.ts +3 -67
  65. package/dist/src/plugins/twake/groups.d.ts +46 -2
  66. package/dist/src/plugins/twake/james.d.ts +0 -11
  67. package/docs/README.md +25 -0
  68. package/docs/plugin-development/README.md +61 -15
  69. package/docs/plugin-development/dependencies.md +8 -2
  70. package/docs/plugin-development/hooks.md +4 -5
  71. package/docs/usage/configuration.md +11 -8
  72. package/docs/usage/plugins/auth/README.md +22 -3
  73. package/docs/usage/plugins/auth/authz-linid1.md +14 -5
  74. package/docs/usage/plugins/auth/authz-per-branch.md +60 -9
  75. package/docs/usage/plugins/auth/back-channel-logout.md +28 -0
  76. package/docs/usage/plugins/auth/oidc.md +8 -0
  77. package/docs/usage/plugins/integrations/app-accounts.md +5 -0
  78. package/docs/usage/plugins/integrations/groups.md +36 -3
  79. package/docs/usage/plugins/integrations/scim.md +7 -0
  80. package/docs/usage/plugins/integrations/spaces.md +3 -1
  81. package/docs/usage/plugins/ldap/bulk-import.md +20 -0
  82. package/docs/usage/plugins/ldap/enterprise-rules.md +12 -2
  83. package/docs/usage/plugins/ldap/external-users.md +8 -3
  84. package/docs/usage/plugins/ldap/flat-generic.md +3 -0
  85. package/docs/usage/plugins/ldap/groups.md +6 -0
  86. package/docs/usage/plugins/ldap/organizations.md +17 -0
  87. package/docs/usage/plugins/ldap/raw.md +1 -1
  88. package/docs/usage/upgrading.md +228 -0
  89. package/package.json +1 -1
  90. package/static/schemas/example/groups.json +1 -0
  91. package/static/schemas/example/organizations.json +1 -0
  92. package/static/schemas/example/users.json +3 -0
  93. package/static/schemas/twake/groups.json +2 -0
  94. package/static/schemas/twake/organizations.json +1 -0
  95. package/static/schemas/twake/teamMailboxes.json +3 -1
  96. package/static/schemas/twake/users.json +3 -0
@@ -1 +1 @@
1
- import{AsyncLocalStorage as t}from"async_hooks";import e from"../../lib/auth/base.js";import{runTogether as i}from"../../lib/authz/composition.js";import{ForbiddenError as n}from"../../lib/errors.js";import{unauthorized as s,ok as r}from"../../lib/expressFormatedResponses.js";import{getParentDn as a,asyncHandler as o}from"../../lib/utils.js";import{verifyLdapPassword as h}from"./authzDynamicHash.js";const u=new t;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 e{constructor(t){if(super(t),this.name="authzDynamic",this.roles=["auth","authz","configurable"],this.tokens=[],this.lastLoad=0,this.lastFailure=0,this.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]}},this.base=this.config.authz_dynamic_base||"",this.cacheTtlMs=1e3*(this.config.authz_dynamic_cache_ttl||60),this.failureBackoffMs=Math.max(Math.floor(this.cacheTtlMs/4),5e3),this.tokenAttr=this.config.authz_dynamic_token_attribute||"userPassword",this.configAttr=this.config.authz_dynamic_config_attribute||"description",this.tenantAttr=this.config.authz_dynamic_tenant_attribute||"cn",this.reloadEndpoint=Boolean(this.config.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";t.post(`${e}/v1/authz-dynamic/reload`,o(async(t,e)=>{t.authzToken?(await this.reload(),r(e,{success:!0,tokens:this.tokens.length})):s(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.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 r,a=[];const o=asString(t[this.configAttr]);if(o)try{const t=JSON.parse(o);t&&"string"==typeof t.tenant&&t.tenant&&(r=t.tenant),t&&Array.isArray(t.bases)&&(a=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=r||h;i.push({dn:e,cn:n,tenant:u,hash:s,bases:a})}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){const e=this.config.authz_dynamic_bypass||[];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")&&i(this,{name:t.name,pathPrefixes:t.pathPrefixes||[]})).map(t=>t.name);if(0===t.length)return;const e=this.config.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===e.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 ${e.join(", ")} (--authz-dynamic-bypass), and checked against its token ACLs otherwise.`))}afterChain(t,e,i){const n=t;if(!n.authzDynamicPending)return i();n.authzDynamicPending=!1;const r=this.bypassReason(n);if(r)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${r})`),i();this.logger.warn(n.authzDynamicPendingReason||"authzDynamic: no token on a request no bypass covers"),s(e)}refuseOrDefer(t,e,i,n){const r=this.bypassReason(t);if(r)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${r})`),void this.passThrough(t,i);if(!t.user&&(this.config.authz_dynamic_bypass||[]).length>0){const e=t;return e.authzDynamicPending=!0,e.authzDynamicPendingReason=n,void this.passThrough(t,i)}this.logger.warn(n),s(e)}authMethod(t,e,i){const n=this.bypassReason(t);if(n)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${n})`),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");const s=n.slice(n.indexOf(" ")+1).trim();if(!s)return void this.refuseOrDefer(t,e,i,"authzDynamic: empty bearer token");let r;for(const t of this.tokens)if(h(s,t.hash)){r=t;break}if(!r){const n=s.length>8?`${s.substring(0,8)}...`:"***";return void this.refuseOrDefer(t,e,i,`authzDynamic: unauthorized token ${n}`)}t.user||this.publishIdentity(t,r.tenant),t.authzToken=r,u.run(r,i)}).catch(t=>{this.logger.error(`authzDynamic: authMethod error: ${String(t)}`),s(e)})}activeToken(t){return u.getStore()||t?.authzToken}requireBranchPermission(t,e,i){for(const n of t.bases)if(isAtOrUnder(e,n.dn)&&n[i])return;throw this.logger.debug(`authzDynamic: token "${t.tenant}" denied ${i} on ${e}`),new n(`[authz-forbidden] Token does not have ${i} 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{u as authzContext,AuthzDynamic as default};
1
+ import t from"../../lib/auth/base.js";import{runTogether as e}from"../../lib/authz/composition.js";import{ForbiddenError as i}from"../../lib/errors.js";import{unauthorized as s,ok as n}from"../../lib/expressFormatedResponses.js";import{getParentDn as r,asyncHandler as a}from"../../lib/utils.js";import{verifyLdapPassword as o}from"./authzDynamicHash.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(),s=e.toLowerCase().trim();return!(!i||!s)&&(i===s||i.endsWith(","+s))}class AuthzDynamic extends t{constructor(t){if(super(t),this.name="authzDynamic",this.roles=["auth","authz","configurable"],this.tokens=[],this.lastLoad=0,this.lastFailure=0,this.hooks={ldapsearchrequest:([t,e,i])=>{const s=this.activeToken(i);return s?(this.requireBranchPermission(s,t,"read"),[t,e,i]):[t,e,i]},ldapaddrequest:([t,e,i])=>{const s=this.activeToken(i);return s?(this.requireBranchPermission(s,r(t),"write"),[t,e,i]):[t,e,i]},ldapmodifyrequest:([t,e,i,s])=>{const n=this.activeToken(s);return n?(this.requireBranchPermission(n,r(t),"write"),[t,e,i,s]):[t,e,i,s]},ldapdeleterequest:([t,e])=>{const i=this.activeToken(e);if(!i)return[t,e];const s=Array.isArray(t)?t:[t];for(const t of s)this.requireBranchPermission(i,r(t),"delete");return[t,e]},ldaprenamerequest:([t,e,i])=>{const s=this.activeToken(i);return s?(this.requireBranchPermission(s,r(t),"read"),this.requireBranchPermission(s,r(e),"write"),[t,e,i]):[t,e,i]}},this.base=this.config.authz_dynamic_base||"",this.cacheTtlMs=1e3*(this.config.authz_dynamic_cache_ttl||60),this.failureBackoffMs=Math.max(Math.floor(this.cacheTtlMs/4),5e3),this.tokenAttr=this.config.authz_dynamic_token_attribute||"userPassword",this.configAttr=this.config.authz_dynamic_config_attribute||"description",this.tenantAttr=this.config.authz_dynamic_tenant_attribute||"cn",this.reloadEndpoint=Boolean(this.config.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";t.post(`${e}/v1/authz-dynamic/reload`,a(async(t,e)=>{t.authzToken?(await this.reload(),n(e,{success:!0,tokens:this.tokens.length})):s(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),s=asString(t.cn)||"",n=asString(t[this.tokenAttr])||"";if(!n)continue;let r,a=[];const o=asString(t[this.configAttr]);if(o)try{const t=JSON.parse(o);t&&"string"==typeof t.tenant&&t.tenant&&(r=t.tenant),t&&Array.isArray(t.bases)&&(a=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?s:asString(t[this.tenantAttr])||s,u=r||h;i.push({dn:e,cn:s,tenant:u,hash:n,bases:a})}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){const e=this.config.authz_dynamic_bypass||[];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.config.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 n=t;if(!n.authzDynamicPending)return i();n.authzDynamicPending=!1;const r=this.bypassReason(n);if(r)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${r})`),i();this.logger.warn(n.authzDynamicPendingReason||"authzDynamic: no token on a request no bypass covers"),s(e)}refuseOrDefer(t,e,i,n){const r=this.bypassReason(t);if(r)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${r})`),void this.passThrough(t,i);if(!t.user&&(this.config.authz_dynamic_bypass||[]).length>0){const e=t;return e.authzDynamicPending=!0,e.authzDynamicPendingReason=n,void this.passThrough(t,i)}this.logger.warn(n),s(e)}authMethod(t,e,i){const n=this.bypassReason(t);if(n)return this.logger.info(`authzDynamic: request allowed past the token ACLs (${n})`),void this.passThrough(t,i);this.ensureFresh().then(()=>{const s=t.headers.authorization;if(!s||!/^Bearer\s+/.test(s))return void this.refuseOrDefer(t,e,i,"authzDynamic: missing or invalid Authorization header");const n=s.slice(s.indexOf(" ")+1).trim();if(!n)return void this.refuseOrDefer(t,e,i,"authzDynamic: empty bearer token");let r;for(const t of this.tokens)if(o(n,t.hash)){r=t;break}if(!r){const s=n.length>8?`${n.substring(0,8)}...`:"***";return void this.refuseOrDefer(t,e,i,`authzDynamic: unauthorized token ${s}`)}t.user||this.publishIdentity(t,r.tenant),t.authzToken=r,i()}).catch(t=>{this.logger.error(`authzDynamic: authMethod error: ${String(t)}`),s(e)})}activeToken(t){return t?.authzToken}requireBranchPermission(t,e,s){for(const i of t.bases)if(isAtOrUnder(e,i.dn)&&i[s])return;throw this.logger.debug(`authzDynamic: token "${t.tenant}" denied ${s} on ${e}`),new i(`[authz-forbidden] Token does not have ${s} 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 e from"../../lib/authz/base.js";import{isDnInBranch as t,escapeLdapFilter as i}from"../../lib/utils.js";class AuthzLinid1 extends e{constructor(e){super(e),this.name="authzLinid1",this.permissionsCache=new Map,this.cacheTTL=3e5;const i=this.config.authz_local_admin_attribute||"twakeLocalAdminLink";this.logger.info(`AuthzLinid1: Authorization based on ${i} attribute enabled`);const s=this.globalOrganization(),r=s&&this.globalBranches().find(e=>t(s,e));if(r)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global branch ${r}`);const n=s&&this.globalWriteBranches().find(e=>t(s,e));if(n)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global write branch ${n}`);for(const e of this.globalWriteBranches()){const i=this.globalBranches().find(i=>t(e,i)||t(i,e));if(i)throw new Error(`AuthzLinid1: the global branch ${i} and the global write branch ${e} overlap`)}}async mayLinkAnyDomain(e){if(await super.mayLinkAnyDomain(e))return!0;const t=this.globalOrganization();return!!t&&!0===(await this.getUserPermissions(e,t)).write}globalOrganization(){return this.config.authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.config.authz_global_branch??[]}globalWriteBranches(){return this.config.authz_global_write_branch??[]}isGlobalWrite(e){return this.globalWriteBranches().some(i=>t(e,i))}sourcePermission(e){return this.isGlobalWrite(e)?"delete":"read"}async resolveUser(e){return this.getUserDn(e)}async getUserDn(e){return this.findUserDn(e)}async getUserPermissions(e,i){const s=this.globalOrganization();if(this.globalBranches().some(e=>t(i,e)))return s&&!t(s,i)?this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(i)){const r=s&&!t(s,i)?await this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};return{read:r.read||(await this.getAuthorizedBranches(e)).length>0,write:r.write,delete:r.delete}}return this.ownPermissions(e,i)}async ownPermissions(e,t){const i=Date.now(),s=this.permissionsCache.get(e);if(s&&i-s.timestamp<this.cacheTTL){const e=this.findBranchPermissions(s.branches,t);if(e)return e}await this.refreshUserPermissions(e);const r=this.permissionsCache.get(e);if(r){const e=this.findBranchPermissions(r.branches,t);if(e)return e}return{read:!1,write:!1,delete:!1}}async refreshUserPermissions(e){const t=new Map;try{const s=`(${this.config.authz_local_admin_attribute||"twakeLocalAdminLink"}=${i(e)})`,r=this.config.ldap_top_organization||this.config.ldap_base||"",n=await this.server.ldap.search({paged:!1,filter:s,attributes:["dn"],scope:"sub"},r);if(n.searchEntries)for(const e of n.searchEntries){const i="string"==typeof e.dn?e.dn:String(e.dn);t.set(i,{read:!0,write:!0,delete:!0})}}catch(t){this.logger.error(`Failed to refresh permissions for user ${e}: ${t}`)}this.permissionsCache.set(e,{branches:t,timestamp:Date.now()})}async getAuthorizedBranches(e){const t=Date.now(),i=this.permissionsCache.get(e);(!i||t-i.timestamp>=this.cacheTTL)&&await this.refreshUserPermissions(e);const s=this.permissionsCache.get(e);return s?Array.from(s.branches.keys()):[]}findBranchPermissions(e,t){if(e.has(t))return e.get(t)||null;for(const[i,s]of e.entries())if(t.toLowerCase().endsWith(`,${i.toLowerCase()}`))return s;return null}}export{AuthzLinid1 as default};
1
+ import e,{mostSpecificBranchRule as t}from"../../lib/authz/base.js";import{isDnInBranch as i,escapeLdapFilter as s}from"../../lib/utils.js";class AuthzLinid1 extends e{constructor(e){super(e),this.name="authzLinid1",this.permissionsCache=new Map,this.cacheTTL=3e5;const t=this.config.authz_local_admin_attribute||"twakeLocalAdminLink";this.logger.info(`AuthzLinid1: Authorization based on ${t} attribute enabled`);const s=this.globalOrganization(),r=s&&this.globalBranches().find(e=>i(s,e));if(r)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global branch ${r}`);const n=s&&this.globalWriteBranches().find(e=>i(s,e));if(n)throw new Error(`AuthzLinid1: --authz-global-organization ${s} lies in the global write branch ${n}`);for(const e of this.globalWriteBranches()){const t=this.globalBranches().find(t=>i(e,t)||i(t,e));if(t)throw new Error(`AuthzLinid1: the global branch ${t} and the global write branch ${e} overlap`)}}async mayLinkAnyDomain(e){if(await super.mayLinkAnyDomain(e))return!0;const t=this.globalOrganization();return!!t&&!0===(await this.getUserPermissions(e,t)).write}globalOrganization(){return this.config.authz_global_organization||this.config.ldap_top_organization||void 0}filtersAttachedEntries(){return!0}globalBranches(){return this.config.authz_global_branch??[]}globalWriteBranches(){return this.config.authz_global_write_branch??[]}isGlobalWrite(e){return this.globalWriteBranches().some(t=>i(e,t))}sourcePermission(e){return this.isGlobalWrite(e)?"delete":"read"}async resolveUser(e){return this.getUserDn(e)}async getUserDn(e){return this.findUserDn(e)}async getUserPermissions(e,t){const s=this.globalOrganization();if(this.globalBranches().some(e=>i(t,e)))return s&&!i(s,t)?this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};if(this.isGlobalWrite(t)){const r=s&&!i(s,t)?await this.getUserPermissions(e,s):{read:!1,write:!1,delete:!1};return{read:r.read||(await this.getAuthorizedBranches(e)).length>0,write:r.write,delete:r.delete}}return this.ownPermissions(e,t)}async ownPermissions(e,t){const i=Date.now(),s=this.permissionsCache.get(e);if(s&&i-s.timestamp<this.cacheTTL){const e=this.findBranchPermissions(s.branches,t);if(e)return e}await this.refreshUserPermissions(e);const r=this.permissionsCache.get(e);if(r){const e=this.findBranchPermissions(r.branches,t);if(e)return e}return{read:!1,write:!1,delete:!1}}async refreshUserPermissions(e){const t=new Map;try{const i=`(${this.config.authz_local_admin_attribute||"twakeLocalAdminLink"}=${s(e)})`,r=this.config.ldap_top_organization||this.config.ldap_base||"",n=await this.server.ldap.system.search({paged:!1,filter:i,attributes:["dn"],scope:"sub"},r);if(n.searchEntries)for(const e of n.searchEntries){const i="string"==typeof e.dn?e.dn:String(e.dn);t.set(i,{read:!0,write:!0,delete:!0})}}catch(t){this.logger.error(`Failed to refresh permissions for user ${e}: ${t}`)}this.permissionsCache.set(e,{branches:t,timestamp:Date.now()})}async getAuthorizedBranches(e){const t=Date.now(),i=this.permissionsCache.get(e);(!i||t-i.timestamp>=this.cacheTTL)&&await this.refreshUserPermissions(e);const s=this.permissionsCache.get(e);return s?Array.from(s.branches.keys()):[]}ruleBranches(e){return this.getAuthorizedBranches(e)}findBranchPermissions(e,i){return t(e.entries(),i)}}export{AuthzLinid1 as default};
@@ -1 +1 @@
1
- import e from"../../lib/authz/base.js";import{AmbiguousIdentityError as t}from"../../lib/errors.js";import{normalizeDn as r,escapeLdapFilter as s}from"../../lib/utils.js";import{warnUnmatchedRuleKeys as i}from"../../lib/auth/base.js";class AuthzPerBranch extends e{constructor(e){super(e),this.name="authzPerBranch",this.groupCache=new Map,this.pendingGroups=new Map,this.groupGeneration=0,this.cacheTTL=1e3*(this.config.authz_per_branch_cache_ttl||60),this.authConfig=this.config.authz_per_branch_config,this.authConfig&&this.logger.info("Authorization config loaded");const t=this.hooks,forget=()=>this.forgetGroups();this.hooks={...t,ldapadddone:forget,ldapmodifydone:([,e])=>{this.touchesMembership(e)&&forget()},ldapdeletedone:()=>{t.ldapdeletedone(),forget()},ldaprenamedone:()=>{t.ldaprenamedone(),forget()}}}touchesMembership(e){const t=[this.config.ldap_group_member_attribute||"member",this.config.ldap_user_main_attribute||"uid"].map(e=>e.toLowerCase());return[...Object.keys(e.add??{}),...Object.keys(e.replace??{}),...Array.isArray(e.delete)?e.delete:Object.keys(e.delete??{})].some(e=>t.includes(e.split(";")[0].toLowerCase()))}forgetGroups(){this.groupGeneration++,this.groupCache.clear(),this.pendingGroups.clear()}resolveUser(e){return Promise.resolve(e)}afterLoad(){i(Object.keys(this.authConfig?.users??{}),this.server.loadedPlugins,this.name,this.logger)}shouldSkipAuthorization(e){return super.shouldSkipAuthorization(e)||!this.authConfig}async getUserPermissions(e,t){if(!this.authConfig)return{read:!0,write:!0,delete:!0};let r={read:this.authConfig.default?.read??!1,write:this.authConfig.default?.write??!1,delete:this.authConfig.default?.delete??!1};if(this.authConfig.users?.[e]){const s=this.findBranchPermissions(this.authConfig.users[e],t);s&&(r=this.mergePermissions(r,s))}const s=await this.getUserGroups(e);for(const e of s){const s=this.groupRules(e);if(s){const e=this.findBranchPermissions(s,t);e&&(r=this.mergePermissions(r,e))}}return r}async getAuthorizedBranches(e){return this.getAuthorizedBranchesForPermission(e,"read")}async getAuthorizedBranchesForPermission(e,t){if(!this.authConfig)return[];const r=[];if(this.authConfig.users?.[e])for(const[s,i]of Object.entries(this.authConfig.users[e]))i[t]&&r.push(s);const s=await this.getUserGroups(e);for(const e of s){const s=this.groupRules(e);if(s)for(const[e,i]of Object.entries(s))i[t]&&!r.includes(e)&&r.push(e)}return r}findBranchPermissions(e,t){if(e[t])return e[t];for(const[r,s]of Object.entries(e))if(t.toLowerCase().endsWith(`,${r.toLowerCase()}`))return s;return null}mergePermissions(e,t){return{read:e.read||t.read||!1,write:e.write||t.write||!1,delete:e.delete||t.delete||!1}}buildBranchFilter(e,t){if(t.some(t=>e.toLowerCase()===t.toLowerCase()||e.toLowerCase().endsWith(`,${t.toLowerCase()}`)))return null;if(1===t.length)return`(entryDN=*,${t[0]})`;if(t.length>1){return`(|${t.map(e=>`(entryDN=*,${e})`).join("")})`}return null}groupRules(e){const t=this.authConfig?.groups;if(!t)return;if(this.groupRulesIndex?.source!==t){const e=new Map;for(const[s,i]of Object.entries(t)){let t;try{t=r(s)}catch{this.logger.warn(`${this.name}: group "${s}" is not a valid DN, compared as text`),t=s.toLowerCase()}e.has(t)&&this.logger.warn(`${this.name}: several groups entries name ${t}; only the last one applies`),e.set(t,i)}this.groupRulesIndex={source:t,rules:e}}let s;try{s=r(e)}catch{s=e.toLowerCase()}return this.groupRulesIndex.rules.get(s)}async getUserGroups(e){const t=Date.now(),r=this.groupCache.get(e);if(r&&t-r.timestamp<this.cacheTTL)return r.groups;const s=this.pendingGroups.get(e);if(s)return s;const i=this.groupGeneration,o=this.lookupGroups(e).then(t=>(i===this.groupGeneration&&this.groupCache.set(e,{groups:t,timestamp:Date.now()}),t)).finally(()=>{this.pendingGroups.get(e)===o&&this.pendingGroups.delete(e)});return this.pendingGroups.set(e,o),o}async lookupGroups(e){let r;try{r=await this.findUserDn(e)}catch(r){if(!(r instanceof t))throw r;return this.logger.warn(`${this.name}: ${e} names ${r.count} directory entries, so no group rule applies to them`),[]}if(!r)return[];const i=`(${this.config.ldap_group_member_attribute||"member"}=${s(r)})`,o=await this.server.ldap.search({paged:!1,filter:i,attributes:["dn"]},this.server.ldap.base),n=[];for(const e of o.searchEntries??[])e.dn&&n.push("string"==typeof e.dn?e.dn:String(e.dn));return n}}export{AuthzPerBranch as default};
1
+ import e,{mostSpecificBranchRule as t}from"../../lib/authz/base.js";import{AmbiguousIdentityError as s}from"../../lib/errors.js";import{normalizeDn as r,escapeLdapFilter as o}from"../../lib/utils.js";import{warnUnmatchedRuleKeys as i}from"../../lib/auth/base.js";class AuthzPerBranch extends e{constructor(e){super(e),this.name="authzPerBranch",this.groupCache=new Map,this.pendingGroups=new Map,this.groupGeneration=0,this.cacheTTL=1e3*(this.config.authz_per_branch_cache_ttl||60),this.authConfig=this.config.authz_per_branch_config,this.authConfig&&this.logger.info("Authorization config loaded");const t=this.hooks,forget=()=>this.forgetGroups();this.hooks={...t,ldapadddone:forget,ldapmodifydone:([,e])=>{this.touchesMembership(e)&&forget()},ldapdeletedone:()=>{t.ldapdeletedone(),forget()},ldaprenamedone:()=>{t.ldaprenamedone(),forget()}}}touchesMembership(e){const t=[this.config.ldap_group_member_attribute||"member",this.config.ldap_user_main_attribute||"uid"].map(e=>e.toLowerCase());return[...Object.keys(e.add??{}),...Object.keys(e.replace??{}),...Array.isArray(e.delete)?e.delete:Object.keys(e.delete??{})].some(e=>t.includes(e.split(";")[0].toLowerCase()))}forgetGroups(){this.groupGeneration++,this.groupCache.clear(),this.pendingGroups.clear()}resolveUser(e){return Promise.resolve(e)}afterLoad(){i(Object.keys(this.authConfig?.users??{}),this.server.loadedPlugins,this.name,this.logger)}shouldSkipAuthorization(e){return super.shouldSkipAuthorization(e)||!this.authConfig}async getUserPermissions(e,t){if(!this.authConfig)return{read:!0,write:!0,delete:!0};const s=[];if(this.authConfig.users?.[e]){const r=this.findBranchPermissions(this.authConfig.users[e],t);r&&s.push(r)}const r=await this.getUserGroups(e);for(const e of r){const r=this.groupRules(e);if(r){const e=this.findBranchPermissions(r,t);e&&s.push(e)}}const decide=e=>{const t=s.map(t=>t[e]).filter(e=>void 0!==e);return t.length>0?t.some(Boolean):this.authConfig?.default?.[e]??!1};return{read:decide("read"),write:decide("write"),delete:decide("delete")}}async getAuthorizedBranches(e){return this.getAuthorizedBranchesForPermission(e,"read")}async ruleBranches(e){if(!this.authConfig)return[];const t=Object.keys(this.authConfig.users?.[e]??{});for(const s of await this.getUserGroups(e))t.push(...Object.keys(this.groupRules(s)??{}));return[...new Set(t)]}async getAuthorizedBranchesForPermission(e,t){if(!this.authConfig)return[];const s=[];if(this.authConfig.users?.[e])for(const[r,o]of Object.entries(this.authConfig.users[e]))o[t]&&s.push(r);const r=await this.getUserGroups(e);for(const e of r){const r=this.groupRules(e);if(r)for(const[e,o]of Object.entries(r))o[t]&&!s.includes(e)&&s.push(e)}return s}findBranchPermissions(e,s){return t(Object.entries(e),s)}groupRules(e){const t=this.authConfig?.groups;if(!t)return;if(this.groupRulesIndex?.source!==t){const e=new Map;for(const[s,o]of Object.entries(t)){let t;try{t=r(s)}catch{this.logger.warn(`${this.name}: group "${s}" is not a valid DN, compared as text`),t=s.toLowerCase()}e.has(t)&&this.logger.warn(`${this.name}: several groups entries name ${t}; only the last one applies`),e.set(t,o)}this.groupRulesIndex={source:t,rules:e}}let s;try{s=r(e)}catch{s=e.toLowerCase()}return this.groupRulesIndex.rules.get(s)}async getUserGroups(e){const t=Date.now(),s=this.groupCache.get(e);if(s&&t-s.timestamp<this.cacheTTL)return s.groups;const r=this.pendingGroups.get(e);if(r)return r;const o=this.groupGeneration,i=this.lookupGroups(e).then(t=>(o===this.groupGeneration&&this.groupCache.set(e,{groups:t,timestamp:Date.now()}),t)).finally(()=>{this.pendingGroups.get(e)===i&&this.pendingGroups.delete(e)});return this.pendingGroups.set(e,i),i}async lookupGroups(e){let t;try{t=await this.findUserDn(e)}catch(t){if(!(t instanceof s))throw t;return this.logger.warn(`${this.name}: ${e} names ${t.count} directory entries, so no group rule applies to them`),[]}if(!t)return[];const r=`(${this.config.ldap_group_member_attribute||"member"}=${o(t)})`,i=await this.server.ldap.system.search({paged:!1,filter:r,attributes:["dn"]},this.server.ldap.base),n=[];for(const e of i.searchEntries??[])e.dn&&n.push("string"==typeof e.dn?e.dn:String(e.dn));return n}}export{AuthzPerBranch as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{identityFor as s}from"../../lib/auth/base.js";import{servesRequest as t,overlap as i,EVERYONE as a,authzFor as r}from"../../lib/authz/composition.js";import{asyncHandler as o}from"../../lib/utils.js";import{UnauthorizedError as n}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";class AuthzScope extends e{constructor(){super(...arguments),this.name="authzScope",this.roles=["api"]}api(e){e.get(`${this.config.api_prefix}/v1/authz/scope`,o(async(e,s)=>this.scope(e,s)))}static canDescribe(e){if(!e.roles?.includes("authz"))return!1;const s=e;return"function"==typeof s.resolveUser&&"function"==typeof s.getAuthorizedBranches&&"function"==typeof s.getUserPermissions}describers(){return Object.values(this.server.loadedPlugins).filter(e=>AuthzScope.canDescribe(e))}static judges(e,s){return e.roles?.includes("auth")?Boolean(s.authenticators?.includes(e.name)):Boolean(s.user)&&t(s,e.config)}assertComposition(){const e=this.config.authz_scope_source;if(!e)return;const s=this.describers().map(e=>e.name);if(!s.includes(e))throw new Error(`${this.name}: --authz-scope-source names ${e}, which is not a loaded plugin able to describe a scope`+(s.length>0?` (loaded: ${s.join(", ")})`:""))}afterLoad(){if(this.config.authz_scope_source)return;const e=this.describers(),s=this.server.authenticators,population=e=>r(e.config,e.name)??a;for(let t=0;t<e.length;t++)for(let a=t+1;a<e.length;a++){const r=e[t],o=e[a];if(i(population(r),population(o),s))return void this.logger.warn(`${this.name}: ${r.name} and ${o.name} can both describe the scope of one caller, and ${r.name} answers because it loaded first. Set --authz-scope-source to choose; the answer lists both under \`sources\``)}}entities(){const e=(this.config.ldap_organization_link_attribute||"").toLowerCase(),linkable=s=>Boolean(e)&&(!s?.attributes||Object.keys(s.attributes).some(s=>s.toLowerCase()===e)),s=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const e of t?.instances||[])s.push({name:e.pluralName,base:e.base,linkable:linkable(e.schema)});const i=this.server.loadedPlugins.ldapGroups;return i?.base&&s.push({name:"groups",base:i.base,linkable:linkable(i.schema)}),this.server.loadedPlugins.ldapOrganizations&&this.config.ldap_top_organization&&s.push({name:"organizations",base:this.config.ldap_top_organization,linkable:!1}),s}async scope(e,t){const i=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new n("No authenticated user");const a=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),r=i.filter(e=>a.includes(e)),o=this.config.authz_scope_source,c=r.find(e=>e.name===o)??r[0],u=a.map(e=>e.name);if(!c)return 0===a.length?void t.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],linksAnyDomain:!0,...this.passwordPolicies(!0),entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void t.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:u,branches:[],linksAnyDomain:!1,...this.passwordPolicies(!1),entities:[]});const l=s(e,c.config).value,h=await c.resolveUser(l);if(!h)throw new n(`Unknown user ${l}`);const d=await c.getAuthorizedBranches(h),p=[],f=new Map,permissionsOn=async e=>{let s=f.get(e);return s||(s=await c.getUserPermissions(h,e),f.set(e,s)),s};for(const e of d){const s=await permissionsOn(e);p.push({dn:e,...s,...await this.branchLabel(e)})}const g=d.some(e=>!0===f.get(e)?.write),m=this.config.ldap_top_organization,b=[];for(const{name:e,base:s,linkable:t}of this.entities()){const i=m&&s===m?!0===(await permissionsOn(m)).write:t?g:!0===(await permissionsOn(s)).write;b.push({name:e,base:s,create:i})}t.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:u,branches:p,transit:c.transitBranch?.()??null,linksAnyDomain:!0===await(c.mayLinkAnyDomain?.(h)),...this.passwordPolicies(await this.readsPolicies(c,h)),entities:b})}policyPlugin(){return this.server.loadedPlugins.ldapPasswordPolicy}passwordPolicies(e){return this.policyPlugin()?{passwordPolicies:e}:{}}async readsPolicies(e,s){const t=this.policyPlugin();return!(!t||!e.searchRefusal)&&void 0===await e.searchRefusal(s,t.policyBase())}async branchLabel(e){const s=this.server.loadedPlugins.ldapOrganizations,t=c(s?.schema,"organizationPath")||this.config.ldap_organization_path_attribute;try{const s=await this.server.ldap.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),i=s?.searchEntries?.[0];if(!i)return{};const first=e=>{const s=Array.isArray(e)?e[0]:e;return"string"==typeof s?s||void 0:Buffer.isBuffer(s)&&s.toString("utf8")||void 0};return{name:first(i.ou)||first(i.o)||first(i.cn),path:t?first(i[t]):void 0}}catch(e){return{}}}}export{AuthzScope as default};
1
+ import e from"../../abstract/plugin.js";import{identityFor as s}from"../../lib/auth/base.js";import{servesRequest as t,overlap as i,EVERYONE as a,authzFor as r}from"../../lib/authz/composition.js";import{asyncHandler as o}from"../../lib/utils.js";import{UnauthorizedError as n}from"../../lib/errors.js";import{roleAttribute as c}from"../../config/schema.js";class AuthzScope extends e{constructor(){super(...arguments),this.name="authzScope",this.roles=["api"]}api(e){e.get(`${this.config.api_prefix}/v1/authz/scope`,o(async(e,s)=>this.scope(e,s)))}static canDescribe(e){if(!e.roles?.includes("authz"))return!1;const s=e;return"function"==typeof s.resolveUser&&"function"==typeof s.getAuthorizedBranches&&"function"==typeof s.getUserPermissions}describers(){return Object.values(this.server.loadedPlugins).filter(e=>AuthzScope.canDescribe(e))}static judges(e,s){return e.roles?.includes("auth")?Boolean(s.authenticators?.includes(e.name)):Boolean(s.user)&&t(s,e.config)}assertComposition(){const e=this.config.authz_scope_source;if(!e)return;const s=this.describers().map(e=>e.name);if(!s.includes(e))throw new Error(`${this.name}: --authz-scope-source names ${e}, which is not a loaded plugin able to describe a scope`+(s.length>0?` (loaded: ${s.join(", ")})`:""))}afterLoad(){if(this.config.authz_scope_source)return;const e=this.describers(),s=this.server.authenticators,population=e=>r(e.config,e.name)??a;for(let t=0;t<e.length;t++)for(let a=t+1;a<e.length;a++){const r=e[t],o=e[a];if(i(population(r),population(o),s))return void this.logger.warn(`${this.name}: ${r.name} and ${o.name} can both describe the scope of one caller, and ${r.name} answers because it loaded first. Set --authz-scope-source to choose; the answer lists both under \`sources\``)}}entities(){const e=(this.config.ldap_organization_link_attribute||"").toLowerCase(),linkable=s=>Boolean(e)&&(!s?.attributes||Object.keys(s.attributes).some(s=>s.toLowerCase()===e)),s=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const e of t?.instances||[])s.push({name:e.pluralName,base:e.base,linkable:linkable(e.schema)});const i=this.server.loadedPlugins.ldapGroups;return i?.base&&s.push({name:"groups",base:i.base,linkable:linkable(i.schema)}),this.server.loadedPlugins.ldapOrganizations&&this.config.ldap_top_organization&&s.push({name:"organizations",base:this.config.ldap_top_organization,linkable:!1}),s}async scope(e,t){const i=this.describers();if(Object.values(this.server.loadedPlugins).some(e=>e.roles?.includes("authz"))&&!e.user)throw new n("No authenticated user");const a=Object.values(this.server.loadedPlugins).filter(s=>s.roles?.includes("authz")&&AuthzScope.judges(s,e)),r=i.filter(e=>a.includes(e)),o=this.config.authz_scope_source,c=r.find(e=>e.name===o)??r[0],u=a.map(e=>e.name);if(!c)return 0===a.length?void t.json({user:e.user??null,unrestricted:!0,described:!0,source:null,sources:[],branches:[],linksAnyDomain:!0,...this.passwordPolicies(!0),entities:this.entities().map(({name:e,base:s})=>({name:e,base:s,create:!0}))}):void t.json({user:e.user??null,unrestricted:!1,described:!1,source:null,sources:u,branches:[],linksAnyDomain:!1,...this.passwordPolicies(!1),entities:[]});const l=s(e,c.config).value,h=await c.resolveUser(l);if(!h)throw new n(`Unknown user ${l}`);const d=await c.getAuthorizedBranches(h),p=[],f=new Map,permissionsOn=async e=>{let s=f.get(e);return s||(s=await c.getUserPermissions(h,e),f.set(e,s)),s};for(const e of d){const s=await permissionsOn(e);p.push({dn:e,...s,...await this.branchLabel(e)})}const g=d.some(e=>!0===f.get(e)?.write),m=this.config.ldap_top_organization,b=[];for(const{name:e,base:s,linkable:t}of this.entities()){const i=m&&s===m?!0===(await permissionsOn(m)).write:t?g:!0===(await permissionsOn(s)).write;b.push({name:e,base:s,create:i})}t.json({user:h,unrestricted:!1,described:!0,source:c.name,sources:u,branches:p,transit:c.transitBranch?.()??null,linksAnyDomain:!0===await(c.mayLinkAnyDomain?.(h)),...this.passwordPolicies(await this.readsPolicies(c,h)),entities:b})}policyPlugin(){return this.server.loadedPlugins.ldapPasswordPolicy}passwordPolicies(e){return this.policyPlugin()?{passwordPolicies:e}:{}}async readsPolicies(e,s){const t=this.policyPlugin();return!(!t||!e.searchRefusal)&&void 0===await e.searchRefusal(s,t.policyBase())}async branchLabel(e){const s=this.server.loadedPlugins.ldapOrganizations,t=c(s?.schema,"organizationPath")||this.config.ldap_organization_path_attribute;try{const s=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:["ou","o","cn",...t?[t]:[]]},e),i=s?.searchEntries?.[0];if(!i)return{};const first=e=>{const s=Array.isArray(e)?e[0]:e;return"string"==typeof s?s||void 0:Buffer.isBuffer(s)&&s.toString("utf8")||void 0};return{name:first(i.ou)||first(i.o)||first(i.cn),path:t?first(i[t]):void 0}}catch(e){return{}}}}export{AuthzScope as default};
@@ -1 +1 @@
1
- import e from"express-openid-connect";import o from"../../lib/auth/base.js";import{launchHooksChained as i}from"../../lib/utils.js";import{serverError as t,unauthorized as s}from"../../lib/expressFormatedResponses.js";const{auth:n,requiresAuth:r}=e,c=["/login","/logout","/callback","/backchannel-logout"],a=/^end_session_endpoint must be configured/,h=/^\/logout\/?$/i,walkSubscribers=async(e,o)=>{let i;for(const t of e??[])if(t)try{await t(o)}catch(e){i??(i=e instanceof Error?e:new Error(String(e)))}if(i)throw i};class OpenIDConnect extends o{constructor(e){super(e),this.name="openidconnect",this.configApiKey="openidconnect",this.roles=["auth","configurable"],this.warnedNoEndSession=!1;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(){return{authRequired:!1,issuerBaseURL:this.config.oidc_server,clientID:this.config.oidc_client_id,secret:this.config.oidc_client_secret,baseURL:this.config.base_url,clientSecret:this.config.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},backchannelLogout:{onLogoutToken:async e=>{this.server.hooks.oidclogouttoken?await walkSubscribers(this.server.hooks.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.hooks.oidclogin)return;const o=e.oidc?.idTokenClaims;if(o)try{await walkSubscribers(this.server.hooks.oidclogin,o)}catch(o){const i=e.appSession;if(i)for(const e of Object.keys(i))delete i[e];throw o}},isLoggedOut:async e=>{if(!this.server.hooks.oidcsessionvalid)return!1;const o=e.oidc.idTokenClaims,[,t]=await i(this.server.hooks.oidcsessionvalid,[o,!0]);return!t}}}}identitySource(){const e=this.config.oidc_username_claim||"sub";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=n(this.buildConfig()),super.api(e)}authMethod(e,o,i){if(!this.router)return t(o,new Error(`${this.name}: api() has not built the router`));this.router(e,o,n=>{if(n&&h.test(e.path)&&this.noEndSession(n))return o.redirect(this.config.base_url);if(n)return t(o,n);if(("GET"===e.method||"HEAD"===e.method)&&h.test(e.path))return void o.oidc.logout({logoutParams:{post_logout_redirect_uri:void 0}});const guard=()=>{r()(e,o,t=>{if(t)return s(o);const n=e.oidc.user,r=this.config.oidc_username_claim||"sub",c=n[r];"sub"!==r&&"string"!=typeof c&&this.logger.warn(`${this.name}: no "${r}" 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(n.sub),"string"==typeof c?c:String(n.sub)),i()})},c=this.renewExpired(e);c?c.then(guard):guard()})}renewExpired(e){const o=e.oidc,i=o.accessToken;if(!i?.isExpired())return;const end=()=>{e.appSession=void 0};if(o.refreshToken)return i.refresh().then(()=>{},e=>{this.logger.info(`${this.name}: the provider refused to renew an access token, so the session ends: ${String(e)}`),end()});end()}noEndSession(e){return!!(e instanceof Error&&a.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 s from"../../lib/auth/base.js";import{launchHooksChained as t}from"../../lib/utils.js";import{serverError as i,unauthorized as n}from"../../lib/expressFormatedResponses.js";const{auth:r,requiresAuth:a}=o,c=["/login","/logout","/callback","/backchannel-logout"],d=/^end_session_endpoint must be configured/,h=/^\/logout\/?$/i,u="dmSession",sessionClaims=e=>{const{oidc:o,appSession:s}=e,t=o?.idTokenClaims;if(!t)return;const i=s?.[u];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}},walkSubscribers=async(e,o)=>{let s;for(const t of e??[])if(t)try{await t(o)}catch(e){s??(s=e instanceof Error?e:new Error(String(e)))}if(s)throw s};class OpenIDConnect extends s{constructor(e){super(e),this.name="openidconnect",this.configApiKey="openidconnect",this.roles=["auth","configurable"],this.warnedNoEndSession=!1,this.warnedNoRecord=!1,this.warnedUnnamed=!1;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(){return{authRequired:!1,issuerBaseURL:this.config.oidc_server,clientID:this.config.oidc_client_id,secret:this.config.oidc_client_secret,baseURL:this.config.base_url,clientSecret:this.config.oidc_client_secret,authorizationParams:{response_type:"code",scope:"openid profile email"},idpLogout:!0,routes:{logout:!1},afterCallback:async(e,o,s)=>({...s,[u]:await this.sessionId(e,subjectOf(s.id_token))}),backchannelLogout:{onLogoutToken:async e=>{this.server.hooks.oidclogouttoken?await walkSubscribers(this.server.hooks.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.hooks.oidclogin)return;const o=e.oidc?.idTokenClaims;if(o)try{await walkSubscribers(this.server.hooks.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.config.oidc_username_claim||"sub";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 i(o,new Error(`${this.name}: api() has not built the router`));this.router(e,o,t=>{if(t&&h.test(e.path)&&this.noEndSession(t))return o.redirect(this.config.base_url);if(t)return i(o,t);if(("GET"===e.method||"HEAD"===e.method)&&h.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 n(o);const i=e.oidc.user,r=this.config.oidc_username_claim||"sub",a=i[r];"sub"!==r&&"string"!=typeof a&&this.logger.warn(`${this.name}: no "${r}" 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 a?a:String(i.sub)),s()})},r=this.renewExpired(e);r?r.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.hooks.oidcsessionvalid)return!1;const o=sessionClaims(e),[,s]=await t(this.server.hooks.oidcsessionvalid,[o,!0]);return!s}async sessionId(o,s){const{oidc:t,appSession:i}=o,n=i?.[u];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.hooks.oidclogouttoken)try{await walkSubscribers(this.server.hooks.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 +1 @@
1
- import t from"../abstract/plugin.js";const e="bcl";class Bcl extends t{constructor(t){super(t),this.name="bcl",this.dependencies={storage:"core/storage"},this.hooks={oidclogouttoken:async t=>{const s=Bcl.keys(t);if(0===s.length)return void this.logger.warn(`${this.name}: a logout token carried no sid nor sub`);const o=Date.now()+this.retention;await Promise.all(s.map(t=>this.store.set(e,t,"1",o)))},oidclogin:async t=>{await Promise.all(Bcl.keys(t).map(t=>this.store.delete(e,t)))},oidcsessionvalid:async([t,s])=>{if(!s)return[t,s];for(const s of Bcl.keys(t))if(null!==await this.store.get(e,s))return[t,!1];return[t,!0]}},this.retention=1e3*(t.config.bcl_retention||604800)}api(){const t=this.requirePlugin("storage");if(!t)throw new Error("bcl: needs core/storage loaded, and --storage-backend set. Without somewhere to keep what a logout killed, every session would outlive the logout that closed it and nothing would say so.");this.storage=t}get store(){if(!this.storage)throw new Error("bcl: the store was asked for before api() took hold of it. A plugin built outside registerPlugin has to call api() as it does.");return this.storage.store}static keys(t){const e=[];return t.sid&&e.push(`${t.iss}|sid|${t.sid}`),t.sub&&e.push(`${t.iss}|sub|${t.sub}`),e}}export{Bcl as default};
1
+ import s from"../abstract/plugin.js";const t="bcl";class Bcl extends s{constructor(s){super(s),this.name="bcl",this.dependencies={storage:"core/storage"},this.hooks={oidclogouttoken:async s=>{const e=Bcl.keys(s);if(0===e.length)return void this.logger.warn(`${this.name}: a logout token carried no sid nor sub`);const o=Date.now()+this.retention;await Promise.all(e.map(s=>this.store.set(t,s,"1",o)))},oidclogin:async s=>{await Promise.all(Bcl.keys(s).map(s=>this.store.delete(t,s)))},oidcsessionvalid:async([s,e])=>{if(!e)return[s,e];for(const e of Bcl.keys(s))if(null!==await this.store.get(t,e))return[s,!1];return[s,!0]}},this.retention=1e3*(s.config.bcl_retention||604800)}api(){const s=this.requirePlugin("storage");if(!s)throw new Error("bcl: needs core/storage loaded, and --storage-backend set. Without somewhere to keep what a logout killed, every session would outlive the logout that closed it and nothing would say so.");this.storage=s}get store(){if(!this.storage)throw new Error("bcl: the store was asked for before api() took hold of it. A plugin built outside registerPlugin has to call api() as it does.");return this.storage.store}static keys(s){const t=[];return s.sid&&t.push(`${s.iss}|sid|${s.sid}`),s.sub&&t.push(`${s.iss}|sub|${s.sub}`),s.session&&t.push(`${s.iss}|session|${s.session}`),t}}export{Bcl as default};
@@ -1 +1 @@
1
- import i from"../abstract/plugin.js";import{wantJson as o}from"../lib/expressFormatedResponses.js";class ConfigApi extends i{constructor(){super(...arguments),this.name="configApi",this.roles=["api"]}api(i){const t=this.config.api_prefix||"/api";i.get(`${t}/v1/config`,(i,e)=>{if(!o(i,e))return;const s={apiPrefix:t,ldapBase:this.config.ldap_base||"",features:this.collectPluginConfigs()};e.json(s)}),this.logger.info(`Configuration API registered at ${t}/v1/config`)}collectPluginConfigs(){const i={};for(const[o,t]of Object.entries(this.server.loadedPlugins))if("configApi"!==o&&t.roles?.includes("configurable")&&t.getConfigApiData)try{const e=t.getConfigApiData();e&&(i[t.configApiKey??o]=e)}catch(i){this.logger.warn(`Failed to get config from plugin ${o}: ${String(i)}`)}return i}}export{ConfigApi as default};
1
+ import i from"../abstract/plugin.js";import{wantJson as t}from"../lib/expressFormatedResponses.js";import{asyncHandler as o}from"../lib/utils.js";class ConfigApi extends i{constructor(){super(...arguments),this.name="configApi",this.roles=["api"]}api(i){const s=this.config.api_prefix||"/api";i.get(`${s}/v1/config`,o(async(i,o)=>{if(!t(i,o))return;const e={apiPrefix:s,ldapBase:this.config.ldap_base||"",features:await this.collectPluginConfigs()};o.json(e)})),this.logger.info(`Configuration API registered at ${s}/v1/config`)}async collectPluginConfigs(){const i={};for(const[t,o]of Object.entries(this.server.loadedPlugins))if("configApi"!==t&&o.roles?.includes("configurable")&&o.getConfigApiData)try{const s=await o.getConfigApiData();s&&(i[o.configApiKey??t]=s)}catch(i){this.logger.warn(`Failed to get config from plugin ${t}: ${String(i)}`)}return i}}export{ConfigApi as default};
@@ -1 +1 @@
1
- import t from"multer";import{parse as e}from"csv-parse/sync";import i from"../../abstract/plugin.js";import{badRequest as r,serverError as s}from"../../lib/expressFormatedResponses.js";import{HttpError as o}from"../../lib/errors.js";import{loadSchemaFile as n}from"../../lib/schemaFile.js";import{validateDnValue as a,escapeDnValue as c}from"../../lib/utils.js";const l=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)(;[A-Za-z0-9-]+)*$/;class LdapBulkImport extends i{constructor(e){super(e),this.name="ldapBulkImport",this.roles=["configurable"],this.resources=new Map;const i=parseInt(this.config.bulk_import_max_file_size,10)||10485760;this.upload=t({storage:t.memoryStorage(),limits:{fileSize:i},fileFilter:(t,e,i)=>{"text/csv"===e.mimetype||e.originalname.endsWith(".csv")?i(null,!0):i(new Error("Only CSV files are allowed"))}});const r=this.config.bulk_import_schemas;if(!r)return void this.logger.warn("No bulk import schemas configured");const s=r.split(",").map(t=>t.trim());for(const t of s){const[e,i]=t.split(":").map(t=>t.trim());if(e&&i)try{const t=n(i,{config:this.config});this.resources.set(e,{name:e,schema:t,base:t.base||this.config.ldap_base,mainAttribute:t.mainAttribute||"cn"}),this.logger.info(`Loaded bulk import resource: ${e} from ${i}`)}catch(t){this.logger.error(`Failed to load schema for ${e}: ${t.message}`)}else this.logger.warn(`Invalid schema entry: ${t}`)}}api(t){for(const[e,i]of this.resources)t.get(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}/template.csv`,(t,e)=>this.getTemplate(t,e,i)),t.post(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}`,this.upload.single("file"),async(t,e)=>this.bulkImport(t,e,i))}getTemplate(t,e,i){const r=this.getEditableAttributes(i.schema);r.push("organizationDn"),e.setHeader("Content-Type","text/csv; charset=utf-8"),e.setHeader("Content-Disposition",`attachment; filename="${i.name}-template.csv"`),e.send(r.join(",")+"\n")}async bulkImport(t,i,n){if(!t.file)return r(i,"No file uploaded");const a="true"===t.body.dryRun||!0===t.body.dryRun,c="true"===t.body.updateExisting||!0===t.body.updateExisting,l="false"!==t.body.continueOnError&&!1!==t.body.continueOnError,p=Date.now(),d={success:!0,total:0,created:0,updated:0,skipped:0,failed:0,errors:[],details:{duration:"",linesProcessed:0}};try{const r=t.file.buffer.toString("utf-8"),s=e(r,{columns:!0,skip_empty_lines:!0,trim:!0});d.total=s.length;for(let e=0;e<s.length;e++){const i=e+2,r=s[e];try{const{dn:e,entry:i}=await this.processRecord(r,n),s=await this.entryExists(e);if(s&&!c){d.skipped++;continue}if(a){d.created++;continue}s&&c?(await this.server.ldap.modify(e,{replace:i},t),d.updated++):(await this.server.ldap.add(e,i,t),d.created++)}catch(t){if(d.failed++,d.errors.push({line:i,identifier:r[n.mainAttribute],error:t.message,...t instanceof o?t.refusal:void 0}),!l)throw t}}d.details.linesProcessed=s.length,d.details.duration=`${((Date.now()-p)/1e3).toFixed(1)}s`,i.json(d)}catch(t){return s(i,t)}}async processRecord(t,e){const i={},r=e.schema,s=t.organizationDn;delete t.organizationDn;for(const[t,e]of Object.entries(r.properties||{}))if(!0===e.fixed){const r=e.default||e.const;void 0!==r&&(i[t]=r)}for(const[e,r]of Object.entries(t))if(r&&""!==r.trim()){if(!l.test(e))throw new Error(`Invalid column name: ${e}`);i[e]=r.includes(";")?r.split(";").map(t=>t.trim()):r}if(s){const t=this.findAttributeByRole(r,"organizationLink");t&&(i[t]=s);const e=this.findAttributeByRole(r,"organizationPath");if(e){const t=(await this.fetchOrganization(s))[e];i[e]=Array.isArray(t)?t[0]:t}}for(const[t,e]of Object.entries(r.properties||{}))if(!0===e.required&&!i[t])throw new Error(`Missing required attribute: ${t}`);const o=e.mainAttribute,n=i[o];if(!n)throw new Error(`Missing main attribute: ${o}`);const p=Array.isArray(n)?String(n[0]):String(n);a(p,o);return{dn:`${o}=${c(p)},${e.base}`,entry:i}}getEditableAttributes(t){const e=[];for(const[i,r]of Object.entries(t.properties||{}))!0!==r.fixed&&(r.role?.includes("organizationLink")||r.role?.includes("organizationPath")||e.push(i));return e}findAttributeByRole(t,e){for(const[i,r]of Object.entries(t.properties||{}))if(r.role?.includes(e))return i;return null}async fetchOrganization(t){const e=await this.server.ldap.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new Error(`Organization not found: ${t}`);return e.searchEntries[0]}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base"},t)).searchEntries.length>0}catch(t){return!1}}getConfigApiData(){const t=this.config.api_prefix||"/api",e=[];return this.resources.forEach((i,r)=>{e.push({name:r,mainAttribute:i.mainAttribute,base:i.base,maxFileSize:parseInt(this.config.bulk_import_max_file_size,10)||10485760,batchSize:parseInt(this.config.bulk_import_batch_size,10)||100,endpoints:{template:`${t}/v1/ldap/bulk-import/${r}/template.csv`,import:`${t}/v1/ldap/bulk-import/${r}`}})}),{enabled:!0,resources:e}}}export{LdapBulkImport as default};
1
+ import t from"multer";import{parse as e}from"csv-parse/sync";import r from"../../abstract/plugin.js";import{badRequest as i,serverError as s}from"../../lib/expressFormatedResponses.js";import{errorBody as o,AUTHZ_REFUSED as a,HttpError as n}from"../../lib/errors.js";import{loadSchemaFile as c}from"../../lib/schemaFile.js";import{isAuthzRefusal as l,validateDnValue as p,escapeDnValue as d}from"../../lib/utils.js";const u=/^([A-Za-z][A-Za-z0-9-]*|\d+(\.\d+)+)(;[A-Za-z0-9-]+)*$/;class LdapBulkImport extends r{constructor(e){super(e),this.name="ldapBulkImport",this.roles=["configurable"],this.resources=new Map;const r=parseInt(this.config.bulk_import_max_file_size,10)||10485760;this.upload=t({storage:t.memoryStorage(),limits:{fileSize:r},fileFilter:(t,e,r)=>{"text/csv"===e.mimetype||e.originalname.endsWith(".csv")?r(null,!0):r(new Error("Only CSV files are allowed"))}});const i=this.config.bulk_import_schemas;if(!i)return void this.logger.warn("No bulk import schemas configured");const s=i.split(",").map(t=>t.trim());for(const t of s){const[e,r]=t.split(":").map(t=>t.trim());if(e&&r)try{const t=c(r,{config:this.config});this.resources.set(e,{name:e,schema:t,base:t.base||this.config.ldap_base,mainAttribute:t.mainAttribute||"cn"}),this.logger.info(`Loaded bulk import resource: ${e} from ${r}`)}catch(t){this.logger.error(`Failed to load schema for ${e}: ${t.message}`)}else this.logger.warn(`Invalid schema entry: ${t}`)}}api(t){for(const[e,r]of this.resources)t.get(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}/template.csv`,(t,e)=>this.getTemplate(t,e,r)),t.post(`${this.config.api_prefix}/v1/ldap/bulk-import/${e}`,this.upload.single("file"),async(t,e)=>this.bulkImport(t,e,r))}getTemplate(t,e,r){const i=this.getEditableAttributes(r.schema);i.push("organizationDn"),e.setHeader("Content-Type","text/csv; charset=utf-8"),e.setHeader("Content-Disposition",`attachment; filename="${r.name}-template.csv"`),e.send(i.join(",")+"\n")}async bulkImport(t,r,c){if(!t.file)return i(r,"No file uploaded");const p="true"===t.body.dryRun||!0===t.body.dryRun,d="true"===t.body.updateExisting||!0===t.body.updateExisting,u="false"!==t.body.continueOnError&&!1!==t.body.continueOnError,h=Date.now(),m={success:!0,total:0,created:0,updated:0,skipped:0,failed:0,errors:[],details:{duration:"",linesProcessed:0}};try{const i=t.file.buffer.toString("utf-8"),s=e(i,{columns:!0,skip_empty_lines:!0,trim:!0});m.total=s.length;for(let e=0;e<s.length;e++){const r=e+2,i=s[e];try{const{dn:e,entry:r,orgDn:s}=this.buildRecord(i,c);await this.authorizeRow(e,r,t),await this.completeRecord(r,s,c);const o=await this.entryExists(e);if(o&&!d){m.skipped++;continue}if(p){o||await this.server.ldap.authorize("ldapaddrequest",[e,r,t]),o&&await this.server.ldap.authorize("ldapmodifyrequest",[e,{replace:r},this.opNumber(),t]),o?m.updated++:m.created++;continue}o&&d?(await this.server.ldap.modify(e,{replace:r},t),m.updated++):(await this.server.ldap.add(e,r,t),m.created++)}catch(t){if(m.failed++,m.errors.push({line:r,identifier:i[c.mainAttribute],...l(t)?o(t,a):{error:t.message,...t instanceof n?t.refusal:void 0}}),!u)throw t}}m.details.linesProcessed=s.length,m.details.duration=`${((Date.now()-h)/1e3).toFixed(1)}s`,r.json(m)}catch(t){return s(r,t)}}async authorizeRow(t,e,r){try{await this.server.ldap.authorize("ldapaddrequest",[t,e,r])}catch(i){if(!l(i))throw i;try{await this.server.ldap.authorize("ldapmodifyrequest",[t,{replace:e},this.opNumber(),r])}catch{throw i}}}buildRecord(t,e){const r={},i=e.schema,s=t.organizationDn;delete t.organizationDn;for(const[t,e]of Object.entries(i.properties||{}))if(!0===e.fixed){const i=e.default||e.const;void 0!==i&&(r[t]=i)}for(const[e,i]of Object.entries(t))if(i&&""!==i.trim()){if(!u.test(e))throw new Error(`Invalid column name: ${e}`);r[e]=i.includes(";")?i.split(";").map(t=>t.trim()):i}if(s){const t=this.findAttributeByRole(i,"organizationLink");t&&(r[t]=s)}const o=e.mainAttribute,a=r[o];if(!a)throw new Error(`Missing main attribute: ${o}`);const n=Array.isArray(a)?String(a[0]):String(a);p(n,o);return{dn:`${o}=${d(n)},${e.base}`,entry:r,orgDn:s}}async completeRecord(t,e,r){const i=r.schema;if(e){const r=this.findAttributeByRole(i,"organizationPath");if(r){const i=(await this.fetchOrganization(e))[r];t[r]=Array.isArray(i)?i[0]:i}}for(const[e,r]of Object.entries(i.properties||{}))if(!0===r.required&&!t[e])throw new Error(`Missing required attribute: ${e}`)}getEditableAttributes(t){const e=[];for(const[r,i]of Object.entries(t.properties||{}))!0!==i.fixed&&(i.role?.includes("organizationLink")||i.role?.includes("organizationPath")||e.push(r));return e}findAttributeByRole(t,e){for(const[r,i]of Object.entries(t.properties||{}))if(i.role?.includes(e))return r;return null}async fetchOrganization(t){const e=await this.server.ldap.system.search({paged:!1,scope:"base"},t);if(0===e.searchEntries.length)throw new Error(`Organization not found: ${t}`);return e.searchEntries[0]}async entryExists(t){try{return(await this.server.ldap.system.search({paged:!1,scope:"base"},t)).searchEntries.length>0}catch(t){return!1}}getConfigApiData(){const t=this.config.api_prefix||"/api",e=[];return this.resources.forEach((r,i)=>{e.push({name:i,mainAttribute:r.mainAttribute,base:r.base,maxFileSize:parseInt(this.config.bulk_import_max_file_size,10)||10485760,batchSize:parseInt(this.config.bulk_import_batch_size,10)||100,endpoints:{template:`${t}/v1/ldap/bulk-import/${i}/template.csv`,import:`${t}/v1/ldap/bulk-import/${i}`}})}),{enabled:!0,resources:e}}}export{LdapBulkImport as default};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{isDnInBranch as a,parseDn as r,normalizeDn as n,getParentDn as i,rdnValue as s,escapeLdapFilter as o}from"../../lib/utils.js";class LdapDepartmentSync extends t{constructor(t){super(t),this.name="ldapDepartmentSync",this.roles=["consistency"],this.hooks={ldaprenamedone:async([t,e])=>{if(/^ou=/.test(t)){this.logger.info(`Organization renamed from ${t} to ${e}, synchronizing linked resources...`);try{const a=this.config.ldap_top_organization.replace(/^ou=[^,]+,/,"");await this.updateOrganizationPaths(e);for(const r of this.linkedAttributes())await this.updateLinkedResources(t,e,a,r),await this.updateDescendantReferences(t,e,a,r)}catch(t){this.logger.error(`Error synchronizing department links after rename: ${t}`)}}}},this.linkAttr=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",this.pathAttr=this.config.ldap_organization_path_attribute||"twakeDepartmentPath"}organizationPathAttribute(){const t=this.server.loadedPlugins.ldapOrganizations;return e(t?.schema,"organizationPath")||this.pathAttr}linkedAttributes(){const t=[],a=this.server.loadedPlugins.ldapFlatGeneric;for(const e of a?.instances||[])t.push(e.schema);const r=this.server.loadedPlugins.ldapGroups;t.push(r?.schema);const n=[];for(const a of t){const t=e(a,"organizationLink");t&&n.push({link:t,path:e(a,"organizationPath")||this.pathAttr})}n.push({link:this.linkAttr,path:this.pathAttr});const i=new Set;return n.filter(({link:t,path:e})=>{const a=`${t.toLowerCase()} ${e.toLowerCase()}`;return!i.has(a)&&(i.add(a),!0)})}async updateOrganizationPaths(t){const e=this.organizationPathAttribute(),o=this.config.ldap_top_organization;if(!o||!a(t,o))return;const h=this.config.ldap_organization_path_separator||" / ",c=await this.server.ldap.search({paged:!0,scope:"sub",filter:`(${e}=*)`,attributes:[e]},t),d=[];for await(const t of c)for(const a of t.searchEntries){const t=String(a.dn);if(!/^ou=/i.test(t))continue;const r=a[e];d.push({dn:t,path:String(Array.isArray(r)?r[0]:r)})}d.sort((t,e)=>r(t.dn).length-r(e.dn).length);const l=n(o),g=new Map;let p=0;for(const{dn:t,path:a}of d){const r=i(t),o=n(r);let c;if(o!==l&&(c=g.get(o)??await this.readPath(r)??void 0,void 0===c)){this.logger.warn(`Organization ${r} has no ${e}: the path of ${t} is left as it is`);continue}const d=s(t),u=c?`${c}${h}${d}`:d;if(g.set(n(t),u),u!==a)try{await this.server.ldap.modify(t,{replace:{[e]:u}}),p++}catch(e){this.logger.error(`Failed to update the path of ${t} after organization rename: ${e}`)}}this.logger.info(`Updated the path of ${p} organizations below ${t}`)}async readPath(t){const e=this.organizationPathAttribute();try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:[e]},t),r=a.searchEntries[0]?.[e];return void 0===r?null:String(Array.isArray(r)?r[0]:r)}catch{return null}}async updateLinkedResources(t,e,a,r){const n=`(${r.link}=${o(t)})`;this.logger.debug(`Searching for resources directly linked to ${t}: ${n}`);const i=await this.server.ldap.search({paged:!0,filter:n,attributes:[r.link,r.path]},a);let s=0;for await(const t of i)for(const a of t.searchEntries){const t=String(a.dn);try{const a=await this.getDepartmentPath(e);await this.server.ldap.modify(t,{replace:{[r.link]:e,[r.path]:a}}),s++,this.logger.debug(`Updated ${t}: ${r.link}=${e}, ${r.path}=${a}`)}catch(e){this.logger.error(`Failed to update ${t} after organization rename: ${e}`)}}this.logger.info(`Updated ${s} resources directly linked to the renamed organization`)}async updateDescendantReferences(t,e,a,r){const n=`(${r.link}=*)`;this.logger.debug(`Searching for resources linked to descendants of ${t}: ${n}`);const i=await this.server.ldap.search({paged:!0,filter:n,attributes:[r.link,r.path]},a);let s=0;for await(const a of i)for(const n of a.searchEntries){const a=String(n.dn),i=n[r.link],o=Array.isArray(i)?String(i[0]):String(i);if(o!==t&&o.endsWith(`,${t}`))try{const n=o.replace(`,${t}`,`,${e}`),i=await this.getDepartmentPath(n);await this.server.ldap.modify(a,{replace:{[r.link]:n,[r.path]:i}}),s++,this.logger.debug(`Updated descendant link ${a}: ${r.link}=${n}, ${r.path}=${i}`)}catch(t){this.logger.error(`Failed to update descendant reference ${a}: ${t}`)}}this.logger.info(`Updated ${s} resources linked to descendants of the renamed organization`)}async getDepartmentPath(t){const e=this.organizationPathAttribute();try{const a=await this.server.ldap.search({paged:!1,scope:"base",attributes:[e,"ou","o"]},t);if(!a.searchEntries||0===a.searchEntries.length)throw new Error(`Organization ${t} not found`);const r=a.searchEntries[0];if(r[e]){const t=r[e];return Array.isArray(t)?String(t[0]):String(t)}const n=r.ou||r.o;if(n){return`/${Array.isArray(n)?String(n[0]):String(n)}`}return this.logger.warn(`Organization ${t} has no ${e} attribute, using DN`),t}catch(e){throw new Error(`Failed to fetch organization ${t}: ${e}`)}}}export{LdapDepartmentSync as default};
1
+ import t from"../../abstract/plugin.js";import{roleAttribute as e}from"../../config/schema.js";import{isDnInBranch as a,parseDn as r,normalizeDn as n,getParentDn as i,rdnValue as s,escapeLdapFilter as o}from"../../lib/utils.js";class LdapDepartmentSync extends t{constructor(t){super(t),this.name="ldapDepartmentSync",this.roles=["consistency"],this.hooks={ldaprenamedone:async([t,e])=>{if(/^ou=/.test(t)){this.logger.info(`Organization renamed from ${t} to ${e}, synchronizing linked resources...`);try{const a=this.config.ldap_top_organization.replace(/^ou=[^,]+,/,"");await this.updateOrganizationPaths(e);for(const r of this.linkedAttributes())await this.updateLinkedResources(t,e,a,r),await this.updateDescendantReferences(t,e,a,r)}catch(t){this.logger.error(`Error synchronizing department links after rename: ${t}`)}}}},this.linkAttr=this.config.ldap_organization_link_attribute||"twakeDepartmentLink",this.pathAttr=this.config.ldap_organization_path_attribute||"twakeDepartmentPath"}organizationPathAttribute(){const t=this.server.loadedPlugins.ldapOrganizations;return e(t?.schema,"organizationPath")||this.pathAttr}linkedAttributes(){const t=[],a=this.server.loadedPlugins.ldapFlatGeneric;for(const e of a?.instances||[])t.push(e.schema);const r=this.server.loadedPlugins.ldapGroups;t.push(r?.schema);const n=[];for(const a of t){const t=e(a,"organizationLink");t&&n.push({link:t,path:e(a,"organizationPath")||this.pathAttr})}n.push({link:this.linkAttr,path:this.pathAttr});const i=new Set;return n.filter(({link:t,path:e})=>{const a=`${t.toLowerCase()} ${e.toLowerCase()}`;return!i.has(a)&&(i.add(a),!0)})}async updateOrganizationPaths(t){const e=this.organizationPathAttribute(),o=this.config.ldap_top_organization;if(!o||!a(t,o))return;const h=this.config.ldap_organization_path_separator||" / ",c=await this.server.ldap.system.search({paged:!0,scope:"sub",filter:`(${e}=*)`,attributes:[e]},t),d=[];for await(const t of c)for(const a of t.searchEntries){const t=String(a.dn);if(!/^ou=/i.test(t))continue;const r=a[e];d.push({dn:t,path:String(Array.isArray(r)?r[0]:r)})}d.sort((t,e)=>r(t.dn).length-r(e.dn).length);const l=n(o),g=new Map;let p=0;for(const{dn:t,path:a}of d){const r=i(t),o=n(r);let c;if(o!==l&&(c=g.get(o)??await this.readPath(r)??void 0,void 0===c)){this.logger.warn(`Organization ${r} has no ${e}: the path of ${t} is left as it is`);continue}const d=s(t),u=c?`${c}${h}${d}`:d;if(g.set(n(t),u),u!==a)try{await this.server.ldap.system.modify(t,{replace:{[e]:u}}),p++}catch(e){this.logger.error(`Failed to update the path of ${t} after organization rename: ${e}`)}}this.logger.info(`Updated the path of ${p} organizations below ${t}`)}async readPath(t){const e=this.organizationPathAttribute();try{const a=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e]},t),r=a.searchEntries[0]?.[e];return void 0===r?null:String(Array.isArray(r)?r[0]:r)}catch{return null}}async updateLinkedResources(t,e,a,r){const n=`(${r.link}=${o(t)})`;this.logger.debug(`Searching for resources directly linked to ${t}: ${n}`);const i=await this.server.ldap.system.search({paged:!0,filter:n,attributes:[r.link,r.path]},a);let s=0;for await(const t of i)for(const a of t.searchEntries){const t=String(a.dn);try{const a=await this.getDepartmentPath(e);await this.server.ldap.system.modify(t,{replace:{[r.link]:e,[r.path]:a}}),s++,this.logger.debug(`Updated ${t}: ${r.link}=${e}, ${r.path}=${a}`)}catch(e){this.logger.error(`Failed to update ${t} after organization rename: ${e}`)}}this.logger.info(`Updated ${s} resources directly linked to the renamed organization`)}async updateDescendantReferences(t,e,a,r){const n=`(${r.link}=*)`;this.logger.debug(`Searching for resources linked to descendants of ${t}: ${n}`);const i=await this.server.ldap.system.search({paged:!0,filter:n,attributes:[r.link,r.path]},a);let s=0;for await(const a of i)for(const n of a.searchEntries){const a=String(n.dn),i=n[r.link],o=Array.isArray(i)?String(i[0]):String(i);if(o!==t&&o.endsWith(`,${t}`))try{const n=o.replace(`,${t}`,`,${e}`),i=await this.getDepartmentPath(n);await this.server.ldap.system.modify(a,{replace:{[r.link]:n,[r.path]:i}}),s++,this.logger.debug(`Updated descendant link ${a}: ${r.link}=${n}, ${r.path}=${i}`)}catch(t){this.logger.error(`Failed to update descendant reference ${a}: ${t}`)}}this.logger.info(`Updated ${s} resources linked to descendants of the renamed organization`)}async getDepartmentPath(t){const e=this.organizationPathAttribute();try{const a=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[e,"ou","o"]},t);if(!a.searchEntries||0===a.searchEntries.length)throw new Error(`Organization ${t} not found`);const r=a.searchEntries[0];if(r[e]){const t=r[e];return Array.isArray(t)?String(t[0]):String(t)}const n=r.ou||r.o;if(n){return`/${Array.isArray(n)?String(n[0]):String(n)}`}return this.logger.warn(`Organization ${t} has no ${e} attribute, using DN`),t}catch(e){throw new Error(`Failed to fetch organization ${t}: ${e}`)}}}export{LdapDepartmentSync as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as r,invalidValueMessage as n}from"../../config/schema.js";import{movedToTrash as i,normalizeDn as a,isDnInBranch as s,getParentDn as o,rdnValue as c,escapeLdapFilter as u,isDummyMemberDn as l}from"../../lib/utils.js";import{BadRequestError as f,ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){const r=t.toLowerCase();for(const[t,n]of Object.entries(e))if(t.split(";")[0].toLowerCase()===r)return valueList(n);return[]}function asDn(e){try{return a(e)}catch{return e}}const m={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return Math.round(e);const t=String(e).trim(),r=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!r)throw new f(`"${t}" is not a valid size`);const n=(r[2]||"B").toUpperCase();return Math.round(parseFloat(r[1])*m[n])}function fillsGeneratedAttribute(e,r,n){return!(void 0===r.default||!r.generated&&!r.normalize)||t(n,"organizationPath")===e}function parseDirectoryDate(e){const t=String(e).trim(),r=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})?(\d{2})?(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$/.exec(t);if(!r){const e=new Date(t);return isNaN(e.getTime())?null:e}const[,n,i,a,s,o,c,u,l]=r,[f,d,h,m,p,g]=[n,i,a,s,o,c].map(e=>void 0===e?0:Number(e));if(d<1||d>12||m>23||p>59||g>60)return null;let b=Date.UTC(f,d-1,h,m,p,g);const y=new Date(b);if(y.getUTCMonth()!==d-1||y.getUTCDate()!==h)return null;if(void 0!==u){const e=void 0!==c?1e3:void 0!==o?6e4:36e5;b+=Math.round(Number(`0.${u}`)*e)}if(l&&"Z"!==l){const e=l.slice(1).replace(":",""),t=Number(e.slice(0,2)),r=e.length>2?Number(e.slice(2,4)):0;if(t>23||r>59)return null;b-=("-"===l[0]?-1:1)*(60*t+r)*6e4}return new Date(b)}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,r=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const n=e.lastIndexOf("@");if(n<0)return!1;const i=e.slice(n+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return i===t||r&&i.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{constructor(e){super(e),this.name="ldapEnterpriseRules",this.roles=["consistency"],this.hooks={ldapaddrequest:async([e,t,r])=>{const n=this.resolveEntity(e);return n&&(await this.runChecks(n,e,t,null),await this.runTransformations(n,e,t,!0)),void 0!==r?[e,t,r]:[e,t]},ldapmodifyrequest:async([e,r,n,i])=>{const a=this.resolveEntity(e);if(!a)return[e,r,n,i];const s={...r.add||{},...r.replace||{}};if(0===Object.keys(s).length)return[e,r,n,i];const o=await this.readEntry(e),c=t(a.schema,"organizationLink"),u={...s};if(c&&c in s&&o)for(const[e,t]of Object.entries(a.schema.attributes))t.mailDomainScope&&(e in u||void 0!==o[e]&&(u[e]=o[e]));await this.runChecks(a,e,u,o),await this.runTransformations(a,e,s,!1);for(const[e,t]of Object.entries(s))r.replace&&e in r.replace?r.replace[e]=t:r.add&&e in r.add?r.add[e]=t:(r.replace=r.replace||{},r.replace[e]=t);return[e,r,n,i]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]},ldapdeletedone:async(e,t)=>{for(const r of Array.isArray(e)?e:[e])await this.removeReferences(r,t)},ldaprenamedone:async([e,t],r)=>{i(this.config.trash_base,e,t)&&await this.removeReferences(e,r)}},this.dummyMember=this.config.group_dummy_user,this.topOrganization=this.config.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,r){return fillsGeneratedAttribute(e,t,r)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const r of t?.instances||[])r.schema&&e.push({base:r.base,subtree:!1,schema:r.schema,label:r.singularName});const r=this.server.loadedPlugins.ldapGroups;r?.base&&r.schema&&e.push({base:r.base,subtree:!0,schema:r.schema,label:"group"});const n=this.server.loadedPlugins.ldapOrganizations;return n?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:n.schema,label:"organization"}),e}resolveEntity(e){const t=a(e);let r,n=-1;for(const i of this.bindings()){const c=a(i.base);if(t===c)continue;if(!s(e,i.base))continue;if(!i.subtree&&a(o(e))!==c)continue;const u=c.split(",").length;u>n&&(r=i,n=u)}return r}async runChecks(e,t,n,i){for(const[a,s]of Object.entries(n)){const o=e.schema.attributes[a];if(!o)continue;const c=valueList(s);0!==c.length&&(o.unique&&await this.checkUnique(e,a,o,c,i?t:null),o.mailDomainScope&&await this.checkMailDomains(e,a,o,c,n,i),r(o,"accountExpiry")&&this.checkNotInThePast(a,o,c))}}async runTransformations(e,r,i,a){for(const[t,r]of Object.entries(e.schema.attributes)){if(a&&void 0===i[t]&&void 0!==r.default&&(r.generated||r.normalize)&&(i[t]=Array.isArray(r.default)?r.default.map(e=>String(e)):String(r.default),"pointer"===r.type||"pointer"===r.items?.type))for(const e of valueList(i[t]))if(!await this.readEntry(e))throw new f(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===r.normalize&&void 0!==i[t]){const e=valueList(i[t]);try{i[t]=Array.isArray(i[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new f(n(t,r))}}}const s=t(e.schema,"organizationPath");if(!s)return;if(!e.schema.attributes[s]?.generated&&void 0!==i[s])return;const u=t(e.schema,"organizationLink");if(u){const e=valueList(i[u])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new f(`Organization ${e} has no ${s}, so "${s}" cannot be computed`);return void(i[s]=t)}if(!a)return;const l=valueList(i[t(e.schema,"identifier")||""])[0]||c(r);if(!l)return;const d=o(r),h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!h||d.toLowerCase().endsWith(`,${h}`)?await this.organizationPath(d):void 0,p=this.config.ldap_organization_path_separator||" / ";i[s]=m?`${m}${p}${l}`:l}async checkUnique(e,t,r,n,i){const a="object"==typeof r.unique?r.unique:{},s=[t,...a.attributes||[]],o=function(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),r=[];for(const n of[e,...t||[]]){const e=n.toLowerCase();if(!r.some(t=>contains(t.toLowerCase(),e))){for(let t=r.length-1;t>=0;t--)contains(e,r[t].toLowerCase())&&r.splice(t,1);r.push(n)}}return r}(e.base,a.branches);for(const r of n){if(void 0!==a.sentinel&&r===a.sentinel)continue;const n=s.map(e=>`(${e}=${u(r)})`).join("");let c=s.length>1?`(|${n})`:n;a.filter&&(c=`(&${a.filter}${c})`);for(const n of o){const a=await this.server.ldap.search({paged:!1,scope:"sub",filter:c,attributes:["dn"]},n);for(const n of a.searchEntries||[]){const a=String(n.dn);if(!i||a.toLowerCase()!==i.toLowerCase())throw this.logger.warn(`${t}="${r}" refused for ${i||"a new entry"}: already held by ${a}`),new d(`${e.label}: "${r}" is already used (${t} must be unique)`,{code:"unique",attribute:t,value:r})}}}}async checkMailDomains(e,t,r,n,i,a){const s="directory"===r.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,i,a);if(0!==s.length)for(const e of n)if(!mailInDomains(e,s,r.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${s.join(", ")})`,{code:"mailDomain",attribute:t,value:e,domains:s})}async organizationDomains(e,r,n){const i=t(e.schema,"organizationLink");if(!i)return[];const a=valueList(r[i])[0]||valueList(n?.[i])[0]||void 0;if(!a)return[];const s=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(s,"domainLink")||this.config.enterprise_domain_link_attribute;if(!c)return[];const u=[],l=new Set;let f=a;const d=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),h=this.domainNameAttribute();for(;f&&!l.has(f.toLowerCase());){l.add(f.toLowerCase());const e=await this.readEntry(f);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),r=valueList(e?.[h]);0===r.length&&this.logger.warn(`Organization ${f} links to domain ${t}, which has no "${h}": no mail domain restriction comes from it`),u.push(...r)}if(f.toLowerCase()===d)break;const t=o(f);f=t===f?void 0:t}return u.filter((e,t)=>u.findIndex(t=>t.toLowerCase()===e.toLowerCase())===t)}domainNameAttribute(){for(const e of this.bindings()){const r=t(e.schema,"domainName");if(r)return r}return this.config.enterprise_domain_name_attribute||"associatedDomain"}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),r=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),n=[];for(const e of r.searchEntries||[])n.push(...valueList(e[t]));return n}checkNotInThePast(e,t,r){const i=startOfUtcDay();for(const a of r){const r=parseDirectoryDate(a);if(!r)throw new f(n(e,t));if(r.getTime()<i)throw new f(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const r=this.bindings().find(e=>"organization"===e.label)?.schema,n=t(r,"organizationPath")||this.config.ldap_organization_path_attribute;if(!n)return;const i=await this.readEntry(e);return i?valueList(i[n])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[r,n]of Object.entries(t.schema.attributes)){if("nonEmpty"!==n.deleteGuard)continue;const i=await this.readEntry(e),a=valueList(i?.[r]).filter(e=>!l(e,this.dummyMember));if(a.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${a.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const r=(await this.searchReferrers(t,e))[0];if(r)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(r.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}async removeReferences(e,t){const r=asDn(e),n=new Set;for(const i of this.referringAttributes(e)){if("remove"!==i.attr.referentialIntegrity)continue;let a;try{a=await this.searchReferrers(i,e)}catch(t){32!==h(t)&&this.logger.error(`Delete of ${e}: could not look for ${i.name} references: ${String(t)}`);continue}for(const s of a){const a=String(s.dn),o=`${asDn(a)} ${i.name.toLowerCase()}`;if(n.has(o))continue;n.add(o);const c=entryValues(s,i.name).filter(e=>asDn(e)===r);if(0!==c.length)try{await this.server.ldap.modify(a,{delete:{[i.name]:c}},void 0,{context:t})}catch(t){if(16===h(t))continue;this.logger.error(`Delete of ${e}: could not remove it from ${i.name} of ${a}: ${String(t)}`)}}}}referringAttributes(e){const t=o(e),n=[];for(const e of this.bindings())for(const[i,a]of Object.entries(e.schema.attributes)){const o="pointer"===a.type?a:a.items,c=r(a,"members")||r(a,"owners");if(!o&&!c)continue;const u=o?.branch||[];u.length>0&&!u.some(e=>s(t,e))||n.push({binding:e,name:i,attr:a,pointer:o,multiValued:"array"===a.type})}return n}async searchReferrers(e,t){return(await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${e.name}=${u(t)})`,attributes:["dn",e.name]},e.binding.base)).searchEntries||[]}async findReferences(e){const t=[],r=new Set;return await Promise.all(this.referringAttributes(e).map(n=>this.server.ldap.queryLimit(async()=>{for(const i of await this.searchReferrers(n,e)){const e=String(i.dn),a=`${asDn(e)} ${n.name.toLowerCase()}`;r.has(a)||(r.add(a),t.push({dn:e,attribute:n.name,values:entryValues(i,n.name),multiValued:n.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,r,n){const i=asDn(t),a=asDn(r);if(i===a)return{updated:0,failed:[]};const s=n?this.server.ldap.forRequest(n):{modify:(e,t)=>this.server.ldap.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const u=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(s,e,i,a,r),c++}catch(n){this.logger.error(`Rename ${t} -> ${r}: could not rewrite ${e.attribute} of ${e.dn}: ${String(n)}`),u.set(e.attribute,(u.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...u].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,r,n,i){const a=this.rewrite(t,r,n,i);if(!a)return;try{return void await e.modify(t.dn,a)}catch(e){const r=e.code;if(20!==r&&16!==r)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${r}); reading it back`)}const s={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(s,r,n,i);o&&await e.modify(s.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const r=await this.server.ldap.search({paged:!1,scope:"base",attributes:[t]},e),n=r.searchEntries?.[0];return n?entryValues(n,t):[]}rewrite(e,t,r,n){const i=e.attribute;if(t===r)return;const a=e.values.filter(e=>asDn(e)===t);if(0===a.length)return;const s=e.values.some(e=>asDn(e)===r);if(!e.multiValued){const r=[];for(const i of e.values){const e=asDn(i)===t?n:i;r.some(t=>asDn(t)===asDn(e))||r.push(e)}return{replace:{[i]:r}}}return s?{delete:{[i]:a}}:{delete:{[i]:a},add:{[i]:n}}}async readEntry(e){try{const t=await this.server.ldap.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,parseDirectoryDate,startOfUtcDay};
1
+ import e from"../../abstract/plugin.js";import{roleAttribute as t,hasRole as n,invalidValueMessage as r}from"../../config/schema.js";import{movedToTrash as s,normalizeDn as i,isDnInBranch as a,getParentDn as o,rdnValue as c,escapeLdapFilter as l,isDummyMemberDn as u}from"../../lib/utils.js";import{BadRequestError as f,ConflictError as d}from"../../lib/errors.js";import{extractLdapCode as h}from"../../lib/ldapCodes.js";function valueList(e){return null==e?[]:Array.isArray(e)?e.map(e=>String(e)):[String(e)]}function entryValues(e,t){for(const[n,r]of Object.entries(e))if(spells(n,t))return valueList(r);return[]}function spells(e,t){return e.split(";")[0].toLowerCase()===t.toLowerCase()}function allValues(e,t){return Object.entries(e||{}).filter(([e])=>spells(e,t)).flatMap(([,e])=>valueList(e))}function respell(e,t){if(!t)return;const n=Object.keys(e.attributes);for(const e of Object.keys(t)){if(e.includes(";"))continue;const r=n.find(t=>t!==e&&spells(e,t));r&&!(r in t)&&(t[r]=t[e],delete t[e])}}function asDn(e){try{return i(e)}catch{return e}}function uniquenessBases(e,t){const contains=(e,t)=>t===e||t.endsWith(`,${e}`),n=[];for(const r of[e,...t||[]]){const e=r.toLowerCase();if(!n.some(t=>contains(t.toLowerCase(),e))){for(let t=n.length-1;t>=0;t--)contains(e,n[t].toLowerCase())&&n.splice(t,1);n.push(r)}}return n}const m={B:1,KB:1e3,MB:1e6,GB:1e9,TB:1e12};function parseByteSize(e){if("number"==typeof e)return Math.round(e);const t=String(e).trim(),n=/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)?$/i.exec(t);if(!n)throw new f(`"${t}" is not a valid size`);const r=(n[2]||"B").toUpperCase();return Math.round(parseFloat(n[1])*m[r])}function fillsGeneratedAttribute(e,n,r){return!(void 0===n.default||!n.generated&&!n.normalize)||t(r,"organizationPath")===e}function parseDirectoryDate(e){const t=String(e).trim(),n=/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})?(\d{2})?(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)?$/.exec(t);if(!n){const e=new Date(t);return isNaN(e.getTime())?null:e}const[,r,s,i,a,o,c,l,u]=n,[f,d,h,m,p,g]=[r,s,i,a,o,c].map(e=>void 0===e?0:Number(e));if(d<1||d>12||m>23||p>59||g>60)return null;let b=Date.UTC(f,d-1,h,m,p,g);const y=new Date(b);if(y.getUTCMonth()!==d-1||y.getUTCDate()!==h)return null;if(void 0!==l){const e=void 0!==c?1e3:void 0!==o?6e4:36e5;b+=Math.round(Number(`0.${l}`)*e)}if(u&&"Z"!==u){const e=u.slice(1).replace(":",""),t=Number(e.slice(0,2)),n=e.length>2?Number(e.slice(2,4)):0;if(t>23||n>59)return null;b-=("-"===u[0]?-1:1)*(60*t+n)*6e4}return new Date(b)}function startOfUtcDay(e=new Date){return Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate())}function mailInDomains(e,t,n=!1){if(0===t.length)return!0;if(t.includes("*"))return!0;const r=e.lastIndexOf("@");if(r<0)return!1;const s=e.slice(r+1).toLowerCase();return t.some(e=>{const t=e.toLowerCase();return s===t||n&&s.endsWith(`.${t}`)})}class LdapEnterpriseRules extends e{constructor(e){super(e),this.name="ldapEnterpriseRules",this.roles=["consistency"],this.hooks={ldapaddrequest:async([e,t,n])=>{const r=this.resolveEntity(e);return r&&(respell(r.schema,t),await this.runChecks(r,e,t,null),await this.runTransformations(r,e,t,!0)),void 0!==n?[e,t,n]:[e,t]},ldapmodifyrequest:async([e,n,r,s])=>{const i=this.resolveEntity(e);if(!i)return[e,n,r,s];respell(i.schema,n.add),respell(i.schema,n.replace);const a={...n.add||{},...n.replace||{}};if(0===Object.keys(a).length)return[e,n,r,s];const o=await this.readEntry(e),c=t(i.schema,"organizationLink"),l={...a},sent=e=>Object.keys(l).some(t=>spells(t,e));if(c&&sent(c)&&o)for(const[e,t]of Object.entries(i.schema.attributes)){if(!t.mailDomainScope)continue;if(sent(e))continue;const n=allValues(o,e);n.length&&(l[e]=n)}await this.runChecks(i,e,l,o),await this.runTransformations(i,e,a,!1);for(const[e,t]of Object.entries(a))n.replace&&e in n.replace?n.replace[e]=t:n.add&&e in n.add?n.add[e]=t:(n.replace=n.replace||{},n.replace[e]=t);return[e,n,r,s]},ldapdeleterequest:async([e,t])=>{for(const t of Array.isArray(e)?e:[e])await this.guardDelete(t);return[e,t]},ldapdeletedone:async(e,t)=>{for(const n of Array.isArray(e)?e:[e])await this.removeReferences(n,t)},ldaprenamedone:async([e,t],n)=>{s(this.config.trash_base,e,t)&&await this.removeReferences(e,n)}},this.missingBases=new Set,this.dummyMember=this.config.group_dummy_user,this.topOrganization=this.config.ldap_top_organization,this.logger.info("Enterprise directory rules enabled")}fillsGeneratedAttribute(e,t,n){return fillsGeneratedAttribute(e,t,n)}bindings(){const e=[],t=this.server.loadedPlugins.ldapFlatGeneric;for(const n of t?.instances||[])n.schema&&e.push({base:n.base,subtree:!1,schema:n.schema,label:n.singularName});const n=this.server.loadedPlugins.ldapGroups;n?.base&&n.schema&&e.push({base:n.base,subtree:!0,schema:n.schema,label:"group"});const r=this.server.loadedPlugins.ldapOrganizations;return r?.schema&&this.topOrganization&&e.push({base:this.topOrganization,subtree:!0,schema:r.schema,label:"organization"}),e}resolveEntity(e){const t=i(e);let n,r=-1;for(const s of this.bindings()){const c=i(s.base);if(t===c)continue;if(!a(e,s.base))continue;if(!s.subtree&&i(o(e))!==c)continue;const l=c.split(",").length;l>r&&(n=s,r=l)}return n}async runChecks(e,t,r,s){const i=Object.keys(e.schema.attributes);for(const[a,o]of Object.entries(r)){const c=i.find(e=>spells(a,e));if(!c)continue;const l=e.schema.attributes[c],u=valueList(o);0!==u.length&&(l.unique&&await this.checkUnique(e,c,l,u,t,s),l.mailDomainScope&&await this.checkMailDomains(e,c,l,u,r,s),n(l,"accountExpiry")&&this.checkNotInThePast(c,l,u))}}async runTransformations(e,n,s,i){for(const[t,n]of Object.entries(e.schema.attributes)){if(i&&void 0===s[t]&&void 0!==n.default&&(n.generated||n.normalize)&&(s[t]=Array.isArray(n.default)?n.default.map(e=>String(e)):String(n.default),"pointer"===n.type||"pointer"===n.items?.type))for(const e of valueList(s[t]))if(!await this.readEntry(e))throw new f(`Schema default for "${t}" points to a non-existent entry: ${e}`);if("byteSize"===n.normalize&&void 0!==s[t]){const e=valueList(s[t]);try{s[t]=Array.isArray(s[t])?e.map(e=>String(parseByteSize(e))):String(parseByteSize(e[0]))}catch{throw new f(r(t,n))}}}const a=t(e.schema,"organizationPath");if(!a)return;if(!e.schema.attributes[a]?.generated&&void 0!==s[a])return;const l=t(e.schema,"organizationLink");if(l){const e=valueList(s[l])[0];if(!e)return;const t=await this.organizationPath(e);if(void 0===t)throw new f(`Organization ${e} has no ${a}, so "${a}" cannot be computed`);return void(s[a]=t)}if(!i)return;const u=valueList(s[t(e.schema,"identifier")||""])[0]||c(n);if(!u)return;const d=o(n),h=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),m=!h||d.toLowerCase().endsWith(`,${h}`)?await this.organizationPath(d):void 0,p=this.config.ldap_organization_path_separator||" / ";s[a]=m?`${m}${p}${u}`:u}async checkUnique(e,t,n,r,s,i){const o="object"==typeof n.unique?n.unique:{},c=[t,...o.attributes||[]],u=i?s:null,f=uniquenessBases(e.base,o.branches),h=new Set(c.filter(n=>{if(n===t)return!0;const r=e.schema.attributes[n]?.unique;if(!r)return!1;const s="object"==typeof r?r:{},i=[n,...s.attributes||[]],l=uniquenessBases(e.base,s.branches);return(!s.filter||s.filter===o.filter)&&s.sentinel===o.sentinel&&c.every(e=>i.includes(e))&&f.every(e=>l.some(t=>a(e,t)))}).flatMap(e=>allValues(i,e)));for(const n of r){if(void 0!==o.sentinel&&n===o.sentinel)continue;if(h.has(n))continue;const r=c.map(e=>`(${e}=${l(n)})`).join("");let s=c.length>1?`(|${r})`:r;o.filter&&(s=`(&${o.filter}${s})`);for(const r of f){const i=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:s,attributes:["dn"]},r);for(const r of i.searchEntries||[]){const s=String(r.dn);if(!u||s.toLowerCase()!==u.toLowerCase())throw this.logger.warn(`${t}="${n}" refused for ${u||"a new entry"}: already held by ${s}`),new d(`${e.label}: "${n}" is already used (${t} must be unique)`,{code:"unique",attribute:t,value:n})}}}}async checkMailDomains(e,t,n,r,s,i){const a="directory"===n.mailDomainScope?await this.directoryDomains():await this.organizationDomains(e,s,i);if(0!==a.length)for(const e of r)if(!mailInDomains(e,a,n.allowSubdomains))throw new d(`The mail domain of "${e}" is not one of the authorised domains (${a.join(", ")})`,{code:"mailDomain",attribute:t,value:e,domains:a})}async organizationDomains(e,n,r){const s=t(e.schema,"organizationLink");if(!s)return[];const i=allValues(n,s)[0]||allValues(r,s)[0]||void 0;if(!i)return[];const a=this.bindings().find(e=>"organization"===e.label)?.schema,c=t(a,"domainLink")||this.config.enterprise_domain_link_attribute;if(!c)return[];const l=[],u=new Set;let f=i;const d=(this.topOrganization||this.config.ldap_base||"").toLowerCase(),h=this.domainNameAttribute();for(;f&&!u.has(f.toLowerCase());){u.add(f.toLowerCase());const e=await this.readEntry(f);for(const t of valueList(e?.[c])){const e=await this.readEntry(t),n=valueList(e?.[h]);0===n.length&&this.logger.warn(`Organization ${f} links to domain ${t}, which has no "${h}": no mail domain restriction comes from it`),l.push(...n)}if(f.toLowerCase()===d)break;const t=o(f);f=t===f?void 0:t}return l.filter((e,t)=>l.findIndex(t=>t.toLowerCase()===e.toLowerCase())===t)}domainNameAttribute(){for(const e of this.bindings()){const n=t(e.schema,"domainName");if(n)return n}return this.config.enterprise_domain_name_attribute||"associatedDomain"}async directoryDomains(){const e=this.config.ldap_base;if(!e)return[];const t=this.domainNameAttribute(),n=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${t}=*)`,attributes:[t]},e),r=[];for(const e of n.searchEntries||[])r.push(...valueList(e[t]));return r}checkNotInThePast(e,t,n){const s=startOfUtcDay();for(const i of n){const n=parseDirectoryDate(i);if(!n)throw new f(r(e,t));if(n.getTime()<s)throw new f(`Attribute "${e}" must not be earlier than today`)}}async organizationPath(e){const n=this.bindings().find(e=>"organization"===e.label)?.schema,r=t(n,"organizationPath")||this.config.ldap_organization_path_attribute;if(!r)return;const s=await this.readEntry(e);return s?valueList(s[r])[0]:void 0}async guardDelete(e){const t=this.resolveEntity(e);if(t)for(const[n,r]of Object.entries(t.schema.attributes)){if("nonEmpty"!==r.deleteGuard)continue;const s=await this.readEntry(e),i=valueList(s?.[n]).filter(e=>!u(e,this.dummyMember));if(i.length>0)throw new d(`Cannot delete ${t.label} ${e}: it still has ${i.length} member(s)`)}await this.guardReferences(e)}async guardReferences(e){for(const t of this.referringAttributes(e)){if(!t.pointer)continue;if("restrict"!==t.attr.referentialIntegrity)continue;const n=(await this.searchReferrers(t,e))[0];if(n)throw this.logger.warn(`Delete of ${e} refused: still referenced by ${String(n.dn)} (${t.name})`),new d(`Cannot delete ${e}: still referenced by another entry (${t.name})`)}}async removeReferences(e,t){const n=asDn(e),r=new Set;for(const s of this.referringAttributes(e)){if("remove"!==s.attr.referentialIntegrity)continue;let i;try{i=await this.searchReferrers(s,e)}catch(t){this.logger.error(`Delete of ${e}: could not look for ${s.name} references: ${String(t)}`);continue}for(const a of i){const i=String(a.dn),o=`${asDn(i)} ${s.name.toLowerCase()}`;if(r.has(o))continue;r.add(o);const c=entryValues(a,s.name).filter(e=>asDn(e)===n);if(0!==c.length)try{await this.server.ldap.system.modify(i,{delete:{[s.name]:c}},void 0,{context:t})}catch(t){if(16===h(t))continue;this.logger.error(`Delete of ${e}: could not remove it from ${s.name} of ${i}: ${String(t)}`)}}}}referringAttributes(e){const t=o(e),r=[];for(const e of this.bindings())for(const[s,i]of Object.entries(e.schema.attributes)){const o="pointer"===i.type?i:i.items,c=n(i,"members")||n(i,"owners");if(!o&&!c)continue;const l=o?.branch||[];l.length>0&&!l.some(e=>a(t,e))||r.push({binding:e,name:s,attr:i,pointer:o,multiValued:"array"===i.type})}return r}async searchReferrers(e,t){return(await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${e.name}=${l(t)})`,attributes:["dn",e.name]},e.binding.base).catch(t=>{if(32!==h(t))throw t;const n=e.binding.base;return this.missingBases.has(n)||(this.missingBases.add(n),this.logger.warn(`${this.name}: ${n} is not in the directory: no reference to an entry is looked for there`)),{searchEntries:[]}})).searchEntries||[]}async findReferences(e){const t=[],n=new Set;return await Promise.all(this.referringAttributes(e).map(r=>this.server.ldap.queryLimit(async()=>{for(const s of await this.searchReferrers(r,e)){const e=String(s.dn),i=`${asDn(e)} ${r.name.toLowerCase()}`;n.has(i)||(n.add(i),t.push({dn:e,attribute:r.name,values:entryValues(s,r.name),multiValued:r.multiValued}))}}))),t}static referenceKey(e){return`${asDn(e.dn)} ${e.attribute.toLowerCase()}`}async rewriteReferences(e,t,n,r){const s=asDn(t),i=asDn(n);if(s===i)return{updated:0,failed:[]};const a=r?this.server.ldap.forRequest(r):{modify:(e,t)=>this.server.ldap.system.modify(e,t)},o=new Map;for(const e of await this.findReferences(t))o.set(LdapEnterpriseRules.referenceKey(e),e);let c=e.filter(e=>!o.has(LdapEnterpriseRules.referenceKey(e))).length;const l=new Map;return await Promise.all([...o.values()].map(e=>this.server.ldap.queryLimit(async()=>{try{await this.applyRewrite(a,e,s,i,n),c++}catch(r){this.logger.error(`Rename ${t} -> ${n}: could not rewrite ${e.attribute} of ${e.dn}: ${String(r)}`),l.set(e.attribute,(l.get(e.attribute)||0)+1)}}))),{updated:c,failed:[...l].map(([e,t])=>({attribute:e,count:t}))}}async applyRewrite(e,t,n,r,s){const i=this.rewrite(t,n,r,s);if(!i)return;try{return void await e.modify(t.dn,i)}catch(e){const n=e.code;if(20!==n&&16!==n)throw e;this.logger.debug(`Rewrite of ${t.attribute} on ${t.dn} met a concurrent one (code ${n}); reading it back`)}const a={...t,values:await this.freshValues(t.dn,t.attribute)},o=this.rewrite(a,n,r,s);o&&await e.modify(a.dn,o)}async freshValues(e,t){this.server.ldap.invalidateCache(e);const n=await this.server.ldap.system.search({paged:!1,scope:"base",attributes:[t]},e),r=n.searchEntries?.[0];return r?entryValues(r,t):[]}rewrite(e,t,n,r){const s=e.attribute;if(t===n)return;const i=e.values.filter(e=>asDn(e)===t);if(0===i.length)return;const a=e.values.some(e=>asDn(e)===n);if(!e.multiValued){const n=[];for(const s of e.values){const e=asDn(s)===t?r:s;n.some(t=>asDn(t)===asDn(e))||n.push(e)}return{replace:{[s]:n}}}return a?{delete:{[s]:i}}:{delete:{[s]:i},add:{[s]:r}}}async readEntry(e){try{const t=await this.server.ldap.system.search({paged:!1,scope:"base"},e);return t?.searchEntries?.[0]||null}catch(e){return null}}}export{LdapEnterpriseRules as default,fillsGeneratedAttribute,mailInDomains,parseByteSize,parseDirectoryDate,startOfUtcDay};
@@ -1 +1 @@
1
- import r from"../../abstract/plugin.js";import{HttpError as e}from"../../lib/errors.js";import{launchHooksChained as s,launchHooks as t,parseDn as a,normalizeDn as n,unescapeDnValue as i}from"../../lib/utils.js";class ExternalUsersInGroups extends r{constructor(r){super(r),this.name="externalUsersInGroups",this.roles=["consistency","configurable"],this.dependencies={ldapGroups:"core/ldap/groups"},this.hooks={ldapgroupvalidatemembers:async([r,a])=>(await Promise.all(a.map(r=>this.server.ldap.queryLimit(async()=>{const a=this.externalAddress(r);if(null===a)return!1;try{return await this.ldap.search({paged:!1,scope:"base"},r),!0}catch{if(!a)throw new Error(`Malformed member ${r}`);const n=a.split("@")[1]?.toLowerCase();if(n&&this.managedDomains().includes(n))throw new Error(`Cannot create external user with managed domain: ${a}`);let i={objectClass:this.config.external_branch_class||this.config.user_class,mail:a,[this.config.ldap_groups_main_attribute]:a,sn:a};[r,i]=await s(this.registeredHooks.externaluserentry,[r,i]);try{return await this.ldap.add(r,i),t(this.registeredHooks.externaluseradded,r,i),!0}catch(s){if(s instanceof e)throw s;throw new Error(`Unable to insert ${r}: ${s instanceof Error?s.message:String(s)}`)}}}))),[r,a])},this.ldap=r.ldap}externalAddress(r){const e=this.config.external_members_branch;if(!e)return null;const[s,...t]=a(r);if(!s||n(t.join(","))!==n(e))return null;const o=s.indexOf("=");if(o<0||"mail"!==s.slice(0,o).trim().toLowerCase())return null;const l=s.slice(o+1).trim();return/(^|[^\\])(\\\\)*\+/.test(l)?null:i(l)}managedDomains(){const r=this.config.mail_domain;return Array.isArray(r)?r.map(r=>String(r).toLowerCase()):[]}getConfigApiData(){return{enabled:!0,branch:this.config.external_members_branch,rdnAttribute:"mail",managedDomains:this.managedDomains()}}}export{ExternalUsersInGroups as default};
1
+ import r from"../../abstract/plugin.js";import{HttpError as e}from"../../lib/errors.js";import{declaredMailDomains as t}from"../../lib/mailDomains.js";import{launchHooksChained as s,launchHooks as a,parseDn as n,normalizeDn as i,unescapeDnValue as o}from"../../lib/utils.js";const mailDomainOf=r=>{const e=r.lastIndexOf("@");return e<1?"":r.slice(e+1).trim().toLowerCase().replace(/\.$/,"")};class ExternalUsersInGroups extends r{constructor(r){super(r),this.name="externalUsersInGroups",this.roles=["consistency","configurable"],this.dependencies={ldapGroups:"core/ldap/groups"},this.hooks={ldapgroupvalidatemembers:async([r,t])=>{let n;return await Promise.all(t.map(r=>this.server.ldap.queryLimit(async()=>{const t=this.externalAddress(r);if(null===t)return!1;try{return await this.ldap.system.search({paged:!1,scope:"base"},r),!0}catch{if(!t)throw new Error(`Malformed member ${r}`);const i=mailDomainOf(t);if(!i)throw new Error(`Malformed member ${r}`);if(n??(n=this.managedDomains()),(await n).includes(i))throw new Error(`Cannot create external user with managed domain: ${t}`);let o={objectClass:this.config.external_branch_class||this.config.user_class,mail:t,[this.config.ldap_groups_main_attribute]:t,sn:t};[r,o]=await s(this.registeredHooks.externaluserentry,[r,o]);try{return await this.ldap.system.add(r,o),a(this.registeredHooks.externaluseradded,r,o),!0}catch(t){if(t instanceof e)throw t;throw new Error(`Unable to insert ${r}: ${t instanceof Error?t.message:String(t)}`)}}}))),[r,t]}},this.ldap=r.ldap}externalAddress(r){const e=this.config.external_members_branch;if(!e)return null;const[t,...s]=n(r);if(!t||i(s.join(","))!==i(e))return null;const a=t.indexOf("=");if(a<0||"mail"!==t.slice(0,a).trim().toLowerCase())return null;const l=t.slice(a+1).trim();return/(^|[^\\])(\\\\)*\+/.test(l)?null:o(l)}async managedDomains(){return[...new Set([...this.configuredDomains(),...await t(this.server)])]}configuredDomains(){const r=this.config.mail_domain;return Array.isArray(r)?r.map(r=>String(r).toLowerCase()):[]}async getConfigApiData(){let r;try{r=await this.managedDomains()}catch(e){this.logger.warn(`${this.name}: the directory's mail domains could not be read: ${String(e)}`),r=this.configuredDomains()}return{enabled:!0,branch:this.config.external_members_branch,rdnAttribute:"mail",managedDomains:r}}}export{ExternalUsersInGroups as default,mailDomainOf};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{movedToTrash as c,asyncHandler as d,isDummyMemberDn as m,escapeDnValue as p,validateDnValue as u,launchHooksChained as l,launchHooks as f,escapeLdapFilter as g,organizationLink as w,isAuthzRefusal as b,substringSearchFilter as y}from"../../lib/utils.js";import{NotFoundError as $,BadRequestError as v,HttpError as _,ConflictError as A}from"../../lib/errors.js";import{assertClientMaySet as G,modifiedAttributeNames as C,toDirectoryEntry as q,invalidValueMessage as M,missingRequiredAttribute as E,toDirectoryChanges as j,checkDnValues as k,matchesPattern as O}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})},ldaprenamedone:async([e,t])=>{c(this.config.trash_base,e,t)&&await this.deleteMemberFromAll(e).catch(e=>{this.logger.error("Failed to process a move to the trash in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new v("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=y(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new v("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=g(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,d(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,d(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,d(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new v("cn is required");const a=i(e,t,"member");if(!a)return;if((Array.isArray(a.member)?a.member:[a.member]).some(e=>"string"!=typeof e))throw new v("member must be a string or string array");await s(t,this.addMember.bind(this),r,a.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,d(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new v("cn and member are required");await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"targetOrgDn");if(o){if("string"!=typeof o.targetOrgDn)throw new v("targetOrgDn must be a string");await s(t,this.moveGroup.bind(this),a,o.targetOrgDn,e)}})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,d(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new v("cn is required");const o=i(e,t,"newCn");if(o){if("string"!=typeof o.newCn)throw new v("newCn must be a string");await s(t,this.renameGroup.bind(this),a,o.newCn,e)}}))}isDummyMember(e){return m(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);try{const r=/,/.test(i)?i:`${this.cn}=${p(i)},${this.base}`,s=await this.ldap.search({paged:!1,scope:"base"},r,e);if(0===s.searchEntries.length)throw new $("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(s.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new $("Group not found");throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;G(this.schema,Object.keys(a),this.cn);const o=a[this.cn],n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===this.schema?.attributes[e]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new v("cn is required");await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=this.fixDn(decodeURIComponent(e.params.cn));if(!o)throw new v("cn is required");G(this.schema,C(a));const n=Object.fromEntries(Object.entries(a).filter(([e,t])=>!(!0===this.schema?.attributes[e]?.fixed)));await s(r,this.modifyGroup.bind(this),o,n,e)}async addGroup(e,t=[],r={},i){let s;new RegExp(`^${this.cn}=`).test(e)?(s=e,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`),"$1")):(u(e,this.cn),s=`${this.cn}=${p(e)},${this.base}`),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r});const a=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||a.includes("twakeGroup")||a.includes("twakeStaticGroup")||a.push("twakeGroup");let o,n={objectClass:a,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};t.length&&n.member.push(...t),q(this.schema,n),[s,n]=await l(this.registeredHooks.ldapgroupadd,[s,n]);try{o=await this.ldap.add(s,n,i)}catch(e){if(e instanceof _)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return f(this.registeredHooks.ldapgroupadddone,[s,n]),o}async modifyGroup(e,t,r){let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;const s=this.opNumber();if([i,t]=await l(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new v("Use dedicated API to add members");if(t.add[this.cn])throw new v(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new v("Use dedicated API to delete members");if(t.delete[this.cn])throw new v(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new v("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new v(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new v("Use dedicated API to replace members");if(t.replace[this.cn])throw new v(`Use dedicated API to change ${this.cn} attribute`)}await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return f(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||u(e,this.cn),/,/.test(t)||u(t,this.cn);let i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`,s=/,/.test(t)?t:`${this.cn}=${p(t)},${this.base}`;[i,s]=await l(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return f(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;r=await l(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return f(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;return Array.isArray(t)||(t=[t]),[e,t]=await l(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof _)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;if([e,t]=await l(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new v("Cannot delete dummy member from group");return await this.removeMember(i,t,r).catch(e=>{if(16===o(e))throw new $(`${t} is not a member of ${i}`);if(e instanceof _)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async removeMember(e,t,r){return await this.ldap.modify(e,{delete:{member:t}},r).catch(i=>{if(65!==o(i))throw i;const s=this.config.group_dummy_user;if(!s)throw new A(`Cannot remove ${t}, the last member of ${e}: a group needs at least one member`);return this.ldap.modify(e,{add:{member:s},delete:{member:t}},r)})}async deleteMemberFromAll(e){const t=await this.ldap.search({filter:`member=${g(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.removeMember(t.dn,e).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`;let n;try{n=await this.ldap.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new $(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new $(`Group ${a} not found`);const h=n.searchEntries[0],c=w(h[i]);if(!c)throw new v(`Group ${a} does not have ${i} attribute and cannot be moved`);if(c===t)throw new v("Group is already in the target organization");let d;try{d=await this.ldap.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new $(`Target organization ${t} not found`);if(34===r)throw new v(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===d.searchEntries.length)throw new $(`Target organization ${t} not found`);const m=d.searchEntries[0][s];if(!m)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:m}},r),this.logger.info(`Group ${a} moved from ${c} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await l(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=g(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}fixDn(e){return!!e&&(/,/.test(e)?e:`${this.cn}=${p(e)},${this.base}`)}async validateMembers(e,t,r){[e,t]=await l(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(b(t))throw t;if(i)return;const r=o(t);if(32===r)throw new v(`Member ${e} not found`);if(34===r)throw new v(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new v(`Member ${e} not found`)})))}catch(e){if(e instanceof _||b(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return q(void 0,t),!0;[e,t]=await l(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));const r=E(this.schema,t,this.server.loadedPlugins);if(r)throw new v(`Attribute "${r}" is required`);return q(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return j(void 0,t),!0;if([e,t]=await l(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new v(M(e,this.schema.attributes[e]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return j(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=this.schema.attributes[e];if(!r){if(this.schema.strict)throw new v(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new v(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new v(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new v(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await k(e,r,t,async e=>(await this.ldap.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!O(r,t))throw new v(M(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
1
+ import e from"../../abstract/plugin.js";import{assertModifyRequest as t}from"../../lib/ldapActions.js";import{wantJson as r,jsonBody as i,tryMethod as s,ok as a}from"../../lib/expressFormatedResponses.js";import{extractLdapCode as o}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as n,schemaUrl as h}from"../../lib/schemaFile.js";import{movedToTrash as d,asyncHandler as c,isDummyMemberDn as p,validateDnValue as u,escapeDnValue as m,launchHooksChained as l,launchHooks as f,escapeLdapFilter as g,organizationLink as w,isDnSyntax as b,normalizeDn as y,isDnInBranch as $,getRdn as v,isAuthzRefusal as G,substringSearchFilter as _}from"../../lib/utils.js";import{NotFoundError as A,BadRequestError as D,HttpError as q,ConflictError as C}from"../../lib/errors.js";import{assertClientMaySet as j,schemaAttribute as k,modifiedAttributeNames as E,toDirectoryEntry as M,invalidValueMessage as O,missingRequiredAttribute as x,toDirectoryChanges as N,checkDnValues as z,matchesPattern as F}from"../../config/schema.js";class LdapGroups extends e{constructor(e){if(super(e),this.name="ldapGroups",this.roles=["api","configurable"],this.hooks={ldapdeletedone:async e=>{const t=Array.isArray(e)?e:[e];this.logger.debug(`User deletion detected, removing from groups: ${t.join(", ")}`),await Promise.all(t.map(e=>this.deleteMemberFromAll(e))).catch(e=>{this.logger.error("Failed to process user deletion in groups:",e)})},ldaprenamedone:async([e,t])=>{d(this.config.trash_base,e,t)&&await this.deleteMemberFromAll(e).catch(e=>{this.logger.error("Failed to process a move to the trash in groups:",e)})}},this.ldap=e.ldap,this.base=this.config.ldap_group_base,this.cn=this.config.ldap_groups_main_attribute,this.base||(this.base=this.config.ldap_base,this.logger.warn(`LDAP group base is not defined, using "${this.base}"`)),!this.base)throw new Error("LDAP base is not defined, please set --ldap-group-base");if(this.config.ldap_group_base&&e.ldap.declareObjectClasses(this.base,this.config.group_class||[]),this.config.group_schema){const e=this.config.group_schema;n(e,{config:this.config}).then(e=>{this.schema=this.adaptSchema(e),this.server.ldap.knowAttributeNames(Object.keys(this.schema.attributes)),this.logger.debug("Group schema loaded")}).catch(t=>this.logger.error(`Failed to load group schema from ${e}: ${t}`))}}adaptSchema(e){return e}api(e){e.get(`${this.config.api_prefix}/v1/ldap/groups`,c(async(e,t)=>{if(!r(e,t))return;const i={};if(e.query.match){if("string"!=typeof e.query.match)throw new D("Invalid match query");if("string"==typeof e.query.attribute&&e.query.attribute)i.filter=_(e.query.match,e.query.attribute);else if(/=/.test(e.query.match)){if(!/^[\w*=()&|, .@'-]+$/.test(e.query.match))throw new D("Invalid match query: contains forbidden characters");i.filter=e.query.match}else{const t=g(e.query.match);i.filter=`(${this.cn}=${t})`}}e.query.attributes&&"string"==typeof e.query.attributes&&(i.attributes=e.query.attributes.split(","));const s=await this.listGroups({...i,req:e});return a(t,this.hideNeverReturn(s))})),e.get(`${this.config.api_prefix}/v1/ldap/groups/:cn`,c(async(e,t)=>this.apiGet(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups`,c(async(e,t)=>this.apiAdd(e,t,this.cn))),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn`,c(async(e,t)=>this.apiDelete(e,t))),e.put(`${this.config.api_prefix}/v1/ldap/groups/:cn`,c(async(e,t)=>this.apiModify(e,t))),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/members`,c(async(e,t)=>{const r=decodeURIComponent(e.params.cn);if(!r)throw new D("cn is required");const a=this.routeGroupDn(r),o=i(e,t,"member");if(!o)return;const n=Array.isArray(o.member)?o.member:[o.member];if(n.some(e=>"string"!=typeof e))throw new D("member must be a string or string array");await this.judgedGroup(a,()=>this.ldap.authorize("ldapmodifyrequest",[a,{add:{member:n}},this.opNumber(),e])),await s(t,this.addMember.bind(this),r,o.member,e)})),e.delete(`${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`,c(async(e,t)=>{if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),a=decodeURIComponent(e.params.member);if(!i||!a)throw new D("cn and member are required");const o=this.routeGroupDn(i);await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{delete:{member:[a]}},this.opNumber(),e])),await s(t,this.deleteMember.bind(this),i,a,e)})),this.config.ldap_organization_link_attribute&&this.config.ldap_organization_path_attribute&&e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/move`,c(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new D("cn is required");const o=this.routeGroupDn(a),n=i(e,t,"targetOrgDn");if(!n)return;if("string"!=typeof n.targetOrgDn)throw new D("targetOrgDn must be a string");const h=this.config.ldap_organization_link_attribute;await this.judgedGroup(o,()=>this.ldap.authorize("ldapmodifyrequest",[o,{replace:{[h]:n.targetOrgDn}},this.opNumber(),e])),await s(t,this.moveGroup.bind(this),a,n.targetOrgDn,e)})),e.post(`${this.config.api_prefix}/v1/ldap/groups/:cn/rename`,c(async(e,t)=>{if(!r(e,t))return;const a=decodeURIComponent(e.params.cn);if(!a)throw new D("cn is required");const o=this.routeGroupDn(a),n=i(e,t,"newCn");if(!n)return;if("string"!=typeof n.newCn)throw new D("newCn must be a string");const h=/,/.test(n.newCn)?this.routeGroupDn(n.newCn):this.toGroupDn(n.newCn);await this.judgedGroup(o,()=>this.ldap.authorize("ldaprenamerequest",[o,h,e])),await s(t,this.renameGroup.bind(this),a,n.newCn,e)}))}isDummyMember(e){return p(e,this.config.group_dummy_user)}withoutDummyMember(e){const t=e.member;if(void 0===t)return e;const r=Array.isArray(t)?t:[t];return{...e,member:r.filter(e=>!this.isDummyMember(e))}}async apiGet(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn),s=this.routeGroupDn(i);try{const r=await this.ldap.search({paged:!1,scope:"base",filter:this.groupFilter()},s,e);if(0===r.searchEntries.length)throw new A("Group not found");t.json(this.hideNeverReturn(this.withoutDummyMember(r.searchEntries[0])))}catch(e){if(e.code&&32===e.code)throw new A("Group not found");if(34===o(e))throw new D(`${i} is not a valid DN`);throw e}}async apiAdd(e,t,...r){const a=i(e,t,...r);if(!a)return;await this.server.ldap.canonicalAttributes(a),j(this.schema,Object.keys(a),this.cn);const o=a[this.cn];"string"==typeof o&&(/,/.test(o)||new RegExp(`^${this.cn}=`,"i").test(o))&&this.routeGroupDn(/,/.test(o)?o:`${o},${this.base}`);const n=a.member?a.member:[],h=Object.fromEntries(Object.entries(a).filter(([e,t])=>e!==this.cn&&"member"!==e&&void 0!==t&&!(!0===k(this.schema,e)?.[1]?.fixed)));await s(t,this.addGroup.bind(this),o,n,h,e)}async apiDelete(e,t){if(!r(e,t))return;const i=decodeURIComponent(e.params.cn);if(!i)throw new D("cn is required");const a=this.routeGroupDn(i);await this.judgedGroup(a,()=>this.ldap.authorize("ldapdeleterequest",[a,e])),await s(t,this.deleteGroup.bind(this),i,e)}async apiModify(e,r){const a=i(e,r);if(!a)return;t(a);const o=decodeURIComponent(e.params.cn);if(!o)throw new D("cn is required");const n=this.routeGroupDn(o);await this.server.ldap.canonicalChanges(a),j(this.schema,E(a));const fixed=e=>!0===k(this.schema,e)?.[1]?.fixed,keep=e=>e&&Object.fromEntries(Object.entries(e).filter(([e])=>!fixed(e))),h={...a.add?{add:keep(a.add)}:{},...a.replace?{replace:keep(a.replace)}:{},...a.delete?{delete:Array.isArray(a.delete)?a.delete.filter(e=>!fixed(e)):keep(a.delete)}:{}};await this.judgedGroup(n,()=>this.ldap.authorize("ldapmodifyrequest",[n,h,this.opNumber(),e])),await s(r,this.modifyGroup.bind(this),n,h,e)}async addGroup(e,t=[],r={},i){let s;await this.server.ldap.canonicalAttributes(r),new RegExp(`^${this.cn}=`,"i").test(e)?(s=/,/.test(e)?e:`${e},${this.base}`,e=e.replace(new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`,"i"),"$1")):(u(e,this.cn),s=`${this.cn}=${m(e)},${this.base}`);let a,o=this.candidateGroup(e,t,r);await this.ldap.authorize("ldapaddrequest",[s,{...o},i]),await this.validateMembers(s,t,i),await this.validateNewGroup(s,{objectClass:this.config.group_class,cn:e,member:t,...r}),M(this.schema,o),[s,o]=await l(this.registeredHooks.ldapgroupadd,[s,o]);try{a=await this.ldap.add(s,o,i)}catch(e){if(e instanceof q)throw e;throw new Error(`Failed to add group ${s}: ${e}`)}return f(this.registeredHooks.ldapgroupadddone,[s,o]),a}candidateGroup(e,t,r){const i=[...this.config.group_class];!(r.mail||r.twakeMailboxType||r.twakeDepartmentLink||r.twakeDepartmentPath)||i.includes("twakeGroup")||i.includes("twakeStaticGroup")||i.push("twakeGroup");const s={objectClass:i,...this.config.group_default_attributes,[this.cn]:e,member:[this.config.group_dummy_user],...r};return t.length&&s.member.push(...t),s}async modifyGroup(e,t,r){await this.server.ldap.canonicalChanges(t);let i=this.toGroupDn(e);const s=this.opNumber();if([i,t]=await l(this.registeredHooks.ldapgroupmodify,[i,t,s]),t.add){if(t.add.member)throw new D("Use dedicated API to add members");if(t.add[this.cn])throw new D(`${this.cn} attribute is unique, cannot add`)}if(t.delete){if(t.delete instanceof Object){if(t.delete.member)throw new D("Use dedicated API to delete members");if(t.delete[this.cn])throw new D(`Cannot delete ${this.cn} attribute`)}if(Array.isArray(t.delete)){if(t.delete.includes("member"))throw new D("Use dedicated API to delete members");if(t.delete.includes(this.cn))throw new D(`Cannot delete ${this.cn} attribute`)}}if(t.replace){if(t.replace.member)throw new D("Use dedicated API to replace members");if(t.replace[this.cn])throw new D(`Use dedicated API to change ${this.cn} attribute`)}await this.ldap.authorize("ldapmodifyrequest",[i,t,s,r]),await this.validateChanges(i,t);const a=await this.ldap.modify(i,t,r);return f(this.registeredHooks.ldapgroupmodifydone,[i,t,s]),a}async renameGroup(e,t,r){/,/.test(e)||u(e,this.cn),/,/.test(t)||u(t,this.cn);let i=this.toGroupDn(e),s=this.toGroupDn(t);[i,s]=await l(this.registeredHooks.ldapgrouprename,[i,s]);const a=await this.ldap.rename(i,s,r);return f(this.registeredHooks.ldapgrouprenamedone,[i,s]),a}async deleteGroup(e,t){let r=this.toGroupDn(e);r=await l(this.registeredHooks.ldapgroupdelete,r);const i=await this.ldap.delete(r,t);return f(this.registeredHooks.ldapgroupdeletedone,r),i}async addMember(e,t,r){const i=this.toGroupDn(e);return Array.isArray(t)||(t=[t]),[e,t]=await l(this.registeredHooks.ldapgroupaddmember,[e,t]),await this.ldap.authorize("ldapmodifyrequest",[i,{add:{member:t}},this.opNumber(),r]),await this.validateMembers(i,t,r),await this.ldap.modify(i,{add:{member:t}},r).catch(e=>{if(e instanceof q)throw e;throw new Error(`Failed to add member(s) to ${i}: ${e}`)})}async deleteMember(e,t,r){const i=this.toGroupDn(e);if([e,t]=await l(this.registeredHooks.ldapgroupdeletemember,[e,t]),this.isDummyMember(t))throw new D("Cannot delete dummy member from group");return await this.removeMember(i,t,r).catch(e=>{if(16===o(e))throw new A(`${t} is not a member of ${i}`);if(e instanceof q)throw e;throw new Error(`Failed to delete member ${t} from ${i}: ${e}`)})}async removeMember(e,t,r){return await this.ldap.modify(e,{delete:{member:t}},r).catch(i=>{if(65!==o(i))throw i;const s=this.config.group_dummy_user;if(!s)throw new C(`Cannot remove ${t}, the last member of ${e}: a group needs at least one member`);return this.ldap.modify(e,{add:{member:s},delete:{member:t}},r)})}async deleteMemberFromAll(e){const t=await this.ldap.system.search({filter:`member=${g(e)}`,paged:!1,attributes:[this.cn]},this.base).catch(e=>{throw new Error(`Failed to search groups from ${this.base}: ${e}`)});await Promise.all(t.searchEntries.map(t=>this.removeMember(t.dn,e).catch(r=>{const i=o(r);16!==i&&32!==i&&this.logger.error(`Failed to remove ${e} from group ${t.dn}:`,r)})))}async moveGroup(e,t,r){const i=this.config.ldap_organization_link_attribute,s=this.config.ldap_organization_path_attribute,a=this.toGroupDn(e);let n;await this.ldap.authorize("ldapmodifyrequest",[a,{replace:{[i]:t}},this.opNumber(),r]);try{n=await this.ldap.system.search({paged:!1,scope:"base"},a)}catch(e){if(32===o(e))throw new A(`Group ${a} not found`);throw e}if(0===n.searchEntries.length)throw new A(`Group ${a} not found`);const h=n.searchEntries[0],d=w(h[i]);if(!d)throw new D(`Group ${a} does not have ${i} attribute and cannot be moved`);if(d===t)throw new D("Group is already in the target organization");let c;try{c=await this.ldap.system.search({paged:!1,scope:"base",attributes:[s,"ou","o"]},t)}catch(e){const r=o(e);if(32===r)throw new A(`Target organization ${t} not found`);if(34===r)throw new D(`${t} is not a valid DN`);throw new Error(`Target organization ${t} not found: ${e instanceof Error&&e.message?e.message:String(e)}`)}if(0===c.searchEntries.length)throw new A(`Target organization ${t} not found`);const p=c.searchEntries[0][s];if(!p)throw new Error(`Target organization ${t} does not have ${s} attribute`);return await this.ldap.modify(a,{replace:{[i]:t,[s]:p}},r),this.logger.info(`Group ${a} moved from ${d} to ${t}`),{success:!0}}async listGroups({filter:e,attributes:t,req:r}={}){const i=await this.ldap.search({filter:e||"(objectClass=*)",attributes:t||[this.cn,"member"],paged:!0},this.base,r).catch(e=>{throw new Error(`Failed to list groups from ${this.base}: ${e}`)});let s={};for await(const e of i)for(const t of e.searchEntries){const e=t[this.cn];if(Array.isArray(e)&&0===e.length)continue;e&&(s[e]=this.withoutDummyMember(t))}return s=await l(this.registeredHooks._ldapgrouplist,s),s}async searchGroupsByName(e,t=!1,r=[this.cn,"member"],i){const s=g(e),a=t?`(${this.cn}=*${s}*)`:`(${this.cn}=${s})`;return await this.listGroups({filter:a,attributes:r,req:i})}toGroupDn(e){if(!/,/.test(e))return`${this.cn}=${m(e)},${this.base}`;if(!b(e))throw new D(`${e} is not a valid DN`);return e}routeGroupDn(e){const t=this.toGroupDn(e),r=this.base,i=y(`${this.cn}=x`).split("=")[0];if(!$(t,r)||y(t)===y(r)||!y(v(t)).split(/(?<!\\)\+/).some(e=>e.startsWith(`${i}=`)))throw new A("Group not found");return t}groupFilter(){return`(|${[...new Set([...this.config.group_class??[],"groupOfNames","groupOfUniqueNames","groupOfURLs","posixGroup"].filter(e=>e&&"top"!==e.toLowerCase()))].map(e=>`(objectClass=${g(e)})`).join("")})`}async judgedGroup(e,t){let r;await t();try{r=await this.ldap.system.search({paged:!1,scope:"base",filter:this.groupFilter(),attributes:["1.1"]},e)}catch(t){const r=o(t);if(32===r)throw new A("Group not found");if(34===r)throw new D(`${e} is not a valid DN`);throw t}if(!r.searchEntries?.length)throw new A("Group not found")}async validateMembers(e,t,r){[e,t]=await l(this.server.hooks.ldapgroupvalidatemembers,[e,t]);const i=Boolean(this.config.groups_allow_unexistent_members);if((!i||r)&&t&&t.length)try{await Promise.all(t.map(e=>this.server.ldap.queryLimit(async()=>{let t;try{t=await this.ldap.search({paged:!1,scope:"base"},e,r)}catch(t){if(G(t))throw t;if(i)return;const r=o(t);if(32===r)throw new D(`Member ${e} not found`);if(34===r)throw new D(`Member ${e} is not a valid DN`);throw new Error(`Member ${e} not found: ${t}`)}if(r&&!t.searchEntries?.length)throw new D(`Member ${e} not found`)})))}catch(e){if(e instanceof q||G(e))throw e;throw new Error(`Failed to find member(s): ${e}`)}}async validateNewGroup(e,t){if(!this.schema)return M(void 0,t),!0;[e,t]=await l(this.server.hooks.ldapgroupvalidatenew,[e,t]);for(const[e,r]of Object.entries(t))if(!await this._validateOneChange(e,r))throw new D(O(e,k(this.schema,e)?.[1]));const r=x(this.schema,t,this.server.loadedPlugins);if(r)throw new D(`Attribute "${r}" is required`);return M(this.schema,t),!0}async validateChanges(e,t){if(!this.schema)return N(void 0,t),!0;if([e,t]=await l(this.server.hooks.ldapgroupvalidatechanges,[e,t]),t.add)for(const[e,r]of Object.entries(t.add))if(!await this._validateOneChange(e,r))throw new D(O(e,k(this.schema,e)?.[1]));if(t.replace)for(const[e,r]of Object.entries(t.replace))if(!await this._validateOneChange(e,r))throw new D(O(e,k(this.schema,e)?.[1]));if(t.delete&&t.delete instanceof Object)for(const e of Array.isArray(t.delete)?t.delete:Object.keys(t.delete))await this._validateOneChange(e,null);return N(this.schema,t),!0}async _validateOneChange(e,t){if(!this.schema)return!0;const r=k(this.schema,e)?.[1];if(!r){if(this.schema.strict)throw new D(`Attribute "${e}" is not allowed`);return!0}if(null==t||Array.isArray(t)&&0===t.length){if(r.required)throw new D(`Attribute "${e}" is required`);return!0}if("array"===r.type){if(!Array.isArray(t))throw new D(`Attribute "${e}" must be an array`);if(!r.items)throw new Error(`Schema error: no item for array ${e}`);if("array"===r.items.type)throw new Error(`Schema error: array of array not supported for ${e}`);if(r.items.test&&"pointer"!==r.items.type)for(const i of t)if(typeof i!==r.items.type)throw new D(`Attribute "${e}" must be of type ${r.items.type}`)}else if("pointer"!==r.type&&typeof t!==r.type)return!1;if(await z(e,r,t,async e=>(await this.ldap.system.search({paged:!1,scope:"base",attributes:["dn"]},e)).searchEntries.length>0),!F(r,t))throw new D(O(e,r));return!0}getConfigApiData(){const e=this.config.api_prefix||"/api",t=this.server.loadedPlugins.static&&this.config.group_schema?h(this.config,this.config.group_schema):void 0;return{enabled:!0,base:this.base||"",mainAttribute:this.cn||"cn",objectClass:this.config.group_class||["top","groupOfNames"],schema:this.schema,schemaUrl:t,endpoints:{list:`${e}/v1/ldap/groups`,get:`${e}/v1/ldap/groups/:id`,create:`${e}/v1/ldap/groups`,update:`${e}/v1/ldap/groups/:id`,delete:`${e}/v1/ldap/groups/:id`,addMember:`${e}/v1/ldap/groups/:id/members`,removeMember:`${e}/v1/ldap/groups/:id/members/:memberId`,rename:`${e}/v1/ldap/groups/:id/rename`,move:`${e}/v1/ldap/groups/:id/move`}}}}export{LdapGroups as default};
@@ -1 +1 @@
1
- import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,l=o?t[o]:void 0,h=normalize(d),u=normalize(l);h.length===u.length&&h.every((e,t)=>e===u[t])||(a[o||s]=[h.length?d:null,u.length?l:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);const s=null===r;for(const[a,o]of Object.entries(i))if((!s||"onLdapMailChange"===o)&&this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===o||"onLdapForwardChange"===o||"onLdapAliasChange"===o)this.notifyAttributeChangeWithMail(this.config[a],o,e,n,r);else if("onLdapMailChange"===o)this.notifyAttributeChange(this.config[a],o,e,n);else if("onLdapDriveQuotaChange"===o){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const o=this.reconstructDisplayName(a),d=this.reconstructDisplayName(r);s||o===d||t(this.server.hooks.onLdapDisplayNameChange,e,o,d)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",l=a[d];let h;if(l?.[1])h=Array.isArray(l[1])?String(l[1][0]):String(l[1]);else{if(l)return void this.logger.debug(`${i} lost its mail, skipping ${n} notification`);if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];h=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,h,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,h,e,a)}}reconstructDisplayName(e){const getValue=t=>{if(!e)return null;const n=Object.keys(e).find(e=>e.toLowerCase()===t.toLowerCase()),i=void 0===n?void 0:e[n];return i?Array.isArray(i)?i.length>0?String(i[0]):null:String(i):null},t=getValue(this.config.display_name_attribute||"displayName");if(t)return t;const n=getValue("cn");if(n)return n;const i=getValue("givenName"),a=getValue("sn");if(i||a){const e=[];return i&&e.push(i),a&&e.push(a),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
1
+ import e from"../../abstract/plugin.js";import{launchHooks as t}from"../../lib/utils.js";import{currentOperation as n}from"../../lib/operation.js";const normalize=e=>(void 0===e?[]:Array.isArray(e)?e:[e]).map(e=>Buffer.isBuffer(e)?e.toString("base64"):String(e)).sort();function diffEntries(e,t){const index=e=>new Map(Object.keys(e||{}).filter(e=>"dn"!==e).map(e=>[e.toLowerCase(),e])),n=index(e),i=index(t),a={};for(const r of new Set([...n.keys(),...i.keys()])){const s=n.get(r),o=i.get(r),d=s?e[s]:void 0,l=o?t[o]:void 0,h=normalize(d),u=normalize(l);h.length===u.length&&h.every((e,t)=>e===u[t])||(a[o||s]=[h.length?d:null,u.length?l:null])}return a}const i={mail_attribute:"onLdapMailChange",quota_attribute:"onLdapQuotaChange",alias_attribute:"onLdapAliasChange",forward_attribute:"onLdapForwardChange",display_name_attribute:"onLdapDisplayNameChange",drive_quota_attribute:"onLdapDriveQuotaChange"};class OnLdapChange extends e{constructor(){super(...arguments),this.name="onLdapChange",this.roles=["consistency"],this.stack={},this.pendingDeletions=new Map,this.pendingRenames=new Map,this.hooks={ldapadddone:async([e,t],n)=>{const i=await this.read(e)||{dn:e,...t};this.publish(e,null,i,n)},ldapmodifyrequest:async([e,t,n,i])=>{const a=await this.read(e);return a?this.stack[n]=a:this.logger.warn(`Could not read ${e} before modification`),[e,t,n,i]},ldapmodifydone:async([e,t,n],i)=>{const a=this.stack[n];if(delete this.stack[n],!a)return void this.logger.warn(`Received a ldapmodifydone for an unknown operation (${n})`);if(0===Object.keys(t).length)return;const r=await this.read(e);r?this.publish(e,a,r,i):this.logger.warn(`Could not read ${e} after modification`)},ldapmodifyend:e=>{delete this.stack[e]},ldaprenamerequest:async([e,t,i])=>{const a=n(),r=void 0===a?void 0:await this.read(e);return void 0!==a&&r&&this.pendingRenames.set(a,{dn:e,entry:r}),[e,t,i]},ldaprenamedone:async([e,t],i)=>{const a=n(),r=void 0===a?void 0:this.pendingRenames.get(a);if(!r||r.dn!==e)return void this.logger.debug(`${e}: no snapshot: a move made outside a request`);this.pendingRenames.delete(a);const s=await this.read(t);s?this.publish(t,r.entry,s,i):this.logger.warn(`Could not read ${t} after the rename of ${e}`)},ldaprenameend:e=>{this.pendingRenames.delete(e)},ldapdeleterequest:async([e,t])=>{const i=n();if(void 0===i)return[e,t];for(const t of Array.isArray(e)?e:[e]){const e=await this.read(t);if(!e)continue;let n=this.pendingDeletions.get(i);n||this.pendingDeletions.set(i,n=new Map),n.set(t,e)}return[e,t]},ldapdeletedone:(e,t)=>{const i=n(),a=void 0===i?void 0:this.pendingDeletions.get(i);for(const n of Array.isArray(e)?e:[e]){const e=a?.get(n);e&&(a.delete(n),this.publish(n,e,null,t))}},ldapdeleteend:e=>{this.pendingDeletions.delete(e)}}}async read(e){const t=new Set(Object.values(this.server.loadedPlugins).flatMap(e=>e.followedOperationalAttributes||[]));try{const n=(await this.server.ldap.system.search({paged:!1,scope:"base",...t.size?{attributes:["*",...t]}:{}},e)).searchEntries[0];if(!n)return;return Object.fromEntries(Object.entries(n).filter(([,e])=>!Array.isArray(e)||e.length>0))}catch(e){return}}publish(e,n,i,a={}){const r=diffEntries(n,i);0===Object.keys(r).length&&n?.dn===i?.dn||(t(this.server.hooks.onLdapEntryChange,e,n,i,a),this.notify(e,r,n,i))}notify(e,n,a,r){t(this.server.hooks.onLdapChange,e,n);const s=null===r;for(const[a,o]of Object.entries(i))if((!s||"onLdapMailChange"===o)&&this.config[a]&&n[this.config[a]])if("onLdapQuotaChange"===o||"onLdapForwardChange"===o||"onLdapAliasChange"===o)this.notifyAttributeChangeWithMail(this.config[a],o,e,n,r);else if("onLdapMailChange"===o)this.notifyAttributeChange(this.config[a],o,e,n);else if("onLdapDriveQuotaChange"===o){const[i,r]=n[this.config[a]]||[],s=i?Number(i):null,o=r?Number(r):null;s!==o&&t(this.server.hooks.onLdapDriveQuotaChange,e,s,o)}const o=this.reconstructDisplayName(a),d=this.reconstructDisplayName(r);s||o===d||t(this.server.hooks.onLdapDisplayNameChange,e,o,d)}notifyAttributeChange(e,n,i,a,r=!1){const[s,o]=a[e]||[];void 0===s&&void 0===o||(r&&(Array.isArray(s)||Array.isArray(o))?this.logger.error(`Attribute ${e} change detected but one of the values is an array, cannot handle that`):s!==o&&t(this.server.hooks[n],i,s,o))}notifyAttributeChangeWithMail(e,n,i,a,r){const[s,o]=a[e]||[];if(void 0===s&&void 0===o)return;const d=this.config.mail_attribute||"mail",l=a[d];let h;if(l?.[1])h=Array.isArray(l[1])?String(l[1][0]):String(l[1]);else{if(l)return void this.logger.debug(`${i} lost its mail, skipping ${n} notification`);if(!r?.[d])return void this.logger.warn(`Could not find mail for ${i}, skipping ${n} notification`);{const e=r[d];h=Array.isArray(e)?String(e[0]):String(e)}}if("onLdapQuotaChange"===n){const e=s?Number(s):0,a=o?Number(o):0;e!==a&&t(this.server.hooks[n],i,h,e,a)}else if("onLdapForwardChange"===n||"onLdapAliasChange"===n){const e=s?Array.isArray(s)?s:[s]:[],a=o?Array.isArray(o)?o:[o]:[];(e.length>0||a.length>0)&&t(this.server.hooks[n],i,h,e,a)}}reconstructDisplayName(e){const getValue=t=>{if(!e)return null;const n=Object.keys(e).find(e=>e.toLowerCase()===t.toLowerCase()),i=void 0===n?void 0:e[n];return i?Array.isArray(i)?i.length>0?String(i[0]):null:String(i):null},t=getValue(this.config.display_name_attribute||"displayName");if(t)return t;const n=getValue("cn");if(n)return n;const i=getValue("givenName"),a=getValue("sn");if(i||a){const e=[];return i&&e.push(i),a&&e.push(a),e.join(" ")}return null}}export{OnLdapChange as default,diffEntries};
@@ -1 +1 @@
1
- import t from"../../abstract/plugin.js";import{assertModifyRequest as i}from"../../lib/ldapActions.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{asyncHandler as r,validateDnValue as o,escapeDnValue as h,getRdn as c,isDnInBranch as d,rdnValue as l,normalizeDn as g,getParentDn as p,escapeLdapFilter as f,launchHooksChained as u,isChildOf as w,substringSearchFilter as m}from"../../lib/utils.js";import{BadRequestError as z,NotFoundError as y,ConflictError as b,HttpError as $}from"../../lib/errors.js";import{extractLdapCode as _}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as v,schemaUrl as O}from"../../lib/schemaFile.js";import{assertClientMaySet as A,modifiedAttributeNames as C,toDirectoryEntry as E,missingRequiredAttribute as L,invalidValueMessage as k,toDirectoryChanges as j,checkDnValues as S,matchesPattern as q}from"../../config/schema.js";class LdapOrganizations extends t{constructor(t){if(super(t),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.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 n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};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 z("An organization link cannot be deleted");if(e)throw new z("An organization path cannot be deleted")}}if(i.replace&&(i.replace[this.linkAttr]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}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)/^ou=/.test(t)&&await this.isEmptyOrganization(t);return[t,i]},ldaprenamerequest:([t,i,a])=>[t,i,a]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema){const t=this.config.organization_schema;v(t,{config:this.config}).then(t=>{this.schema=t,this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,r(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new z('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,r(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new z('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,r(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,r(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;A(this.schema,Object.keys(a),"ou"),o(a.ou,"ou");const s=a.parentDn||this.config.ldap_top_organization,r=`ou=${h(a.ou)},${s}`,c={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),r,c,t)}async apiModify(t,a){const s=e(t,a);if(!s)return;i(s);const r=decodeURIComponent(t.params.dn);if(!r)throw new z("dn is required");A(this.schema,C(s)),await n(a,this.modifyOrganization.bind(this),r,s,t)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new z("dn is required");await n(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!s(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new z("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new z("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),r,o,t)}async checkDeptLink(t){if(t[this.linkAttr]){const i=t[this.linkAttr],a=Array.isArray(i)?i[0]:i,e=await this.server.ldap.search({paged:!1,scope:"base"},a);if(0===e.searchEntries.length)throw new y(`Organization ${a} does not exist`);if(!new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test(e.searchEntries[0].dn))throw new z(`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,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(c(i))&&d(i,s)?l(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let u=e;if((this.isOu(t)||void 0!==r)&&h){const t=s?l(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?g(s):"";if(!i||!t||!d(i,s)||g(i)===t||g(p(i))===t)return;throw new z(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new z(`Organization path must end with its own name, preceded by separator "${n}"`);u=e.slice(0,e.length-n.length-h.length)}const w=await this.server.ldap.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${f(u)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new z(`Invalid organization path ${e}: no organization holds "${u}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${f(t)})`})).searchEntries.length>0)throw new b(`Organization ${t} is not empty`)}isOu(t){if(!t.objectClass)return!1;const i=[].concat(t.objectClass).map(t=>String(t).toLowerCase());return this.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new z("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new y("Top organization not found");const[,a]=await u(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}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=_(i);if(32===a)throw new y(`Organization ${t} not found`);if(34===a)throw new z(`${t} is not a valid DN`);throw i}if(1!==a.searchEntries.length)throw new y(`Organization ${t} not found`);return a.searchEntries[0]}async getOrganisationSubnodes(t,i){const a=[],e=this.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=p(s);let o=`(${this.config.ldap_organization_link_attribute}=${f(t)})`;return n&&(o=`(&${o}(objectClass=${f(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}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),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=_(i);if(32===a)return this.server.logger.debug(`No sub-OUs for ${t}: the node holds none`),[];if(34===a)throw new z(`${t} is not a valid DN`);if(4!==a)throw i}const n=(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 ${n.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.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(4!==_(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);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 ${s.length}`),r<=a?s:[...s,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return await this.validateNewOrganization(t,i),await this.server.ldap.add(t,i,a)}async modifyOrganization(t,i,a){return await this.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return E(void 0,i),!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));const a=L(this.schema,i,this.server.loadedPlugins);if(a)throw new z(`Attribute "${a}" is required`);return E(this.schema,i),!0}invalidValueMessage(t){return k(t,this.schema?.attributes[t])}async validateChanges(t,i){if(!this.schema)return j(void 0,i),!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new z(this.invalidValueMessage(t));return j(this.schema,i),!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=this.schema.attributes[t];if(!a){if(this.schema.strict)throw new z(`Attribute "${t}" is not allowed`);return!0}if(null==i||Array.isArray(i)&&0===i.length){if(a.required)throw new z(`Attribute "${t}" is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new z(`Attribute "${t}" must be a single value`);return await S(t,a,i,t=>this.entryExists(t)),q(a,i)}async entryExists(t){try{return(await this.server.ldap.search({paged:!1,scope:"base",attributes:["dn"]},t)).searchEntries.length>0}catch{return!1}}async moveOrganization(t,i,a){let e;try{e=await this.server.ldap.search({paged:!1,scope:"base"},i)}catch(t){throw new y(`Target organization ${i} not found: ${t instanceof Error?t.message:String(t)}`)}if(!e.searchEntries||0===e.searchEntries.length)throw new y(`Target organization ${i} not found`);if(!this.isOu(e.searchEntries[0]))throw new z(`Target ${i} is not an organizational unit`);const n=`${c(t)},${i}`;let s;try{s=await this.server.ldap.search({paged:!1,scope:"base"},t)}catch(t){throw new y(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.searchEntries.length)throw new y(`Source organization ${t} not found`);if(n===t)throw new z("Cannot move organization to its current location");if(i.toLowerCase()===t.toLowerCase()||w(i,t))throw new z("Cannot move organization into itself or its descendant");try{await this.server.ldap.rename(t,n,a),this.logger.info(`Moved organization from ${t} to ${n}`)}catch(t){if(t instanceof $)throw t;throw new Error(`Failed to move organization: ${t}`)}return{newDn:n}}async deleteOrganization(t,i){try{return await this.server.ldap.delete(t,i)}catch(i){if(32===_(i))throw new y(`Organization ${t} not found`);throw i}}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${m(i,"ou,description")})`,n,a));const s=this.config.ldap_top_organization,r=p(s),o=f(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${m(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${r}`),e.push(...await this.linkedEntities(r,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",s=`(&(objectClass=organizationalUnit)${m(i,n)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:s},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 n=_(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(34===n)throw new z(`${t} is not a valid DN`);if(4!==n)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:s,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.server.loadedPlugins.static&&this.config.organization_schema?O(this.config,this.config.organization_schema):void 0;return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,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`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};
1
+ import t from"../../abstract/plugin.js";import{assertModifyRequest as i}from"../../lib/ldapActions.js";import{tryMethodData as a,jsonBody as e,tryMethod as n,wantJson as s}from"../../lib/expressFormatedResponses.js";import{asyncHandler as r,validateDnValue as o,escapeDnValue as h,getRdn as c,isDnInBranch as d,rdnValue as l,normalizeDn as g,getParentDn as p,escapeLdapFilter as u,launchHooksChained as f,isAuthzRefusal as w,isChildOf as m,substringSearchFilter as z}from"../../lib/utils.js";import{BadRequestError as y,NotFoundError as b,ConflictError as $,HttpError as v}from"../../lib/errors.js";import{extractLdapCode as _}from"../../lib/ldapCodes.js";import{loadSchemaFileAsync as O,schemaUrl as A}from"../../lib/schemaFile.js";import{assertClientMaySet as C,modifiedAttributeNames as E,toDirectoryEntry as L,missingRequiredAttribute as k,invalidValueMessage as j,schemaAttribute as S,toDirectoryChanges as q,checkDnValues as x,matchesPattern as D}from"../../config/schema.js";class LdapOrganizations extends t{constructor(t){if(super(t),this.name="ldapOrganizations",this.roles=["api","consistency","configurable"],this.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 n,s={},r={};const checkIsOu=async()=>{if(void 0!==n)return n;if(i.replace?.objectClass)n=this.isOu({objectClass:i.replace.objectClass});else if(i.add?.objectClass)n=this.isOu({objectClass:i.add.objectClass});else{const i=await this.server.ldap.system.search({paged:!1,scope:"base"},t);n=i.searchEntries.length>0&&this.isOu(i.searchEntries[0])}return n};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]&&(s={...i.replace}),i.replace[this.pathAttr]&&(r={...i.replace})),i.add&&(i.add[this.linkAttr]&&(s={...s,...i.add}),i.add[this.pathAttr]&&(r={...r,...i.add})),Object.keys(s).length>0){await checkIsOu()||await this.checkDeptLink(s)}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]},this.sizeLimitWarned=new Set,!this.config.ldap_top_organization)throw new Error("Missing --ldap-top-organization");if(this.pathAttr=this.config.ldap_organization_path_attribute,this.linkAttr=this.config.ldap_organization_link_attribute,t.ldap.declareObjectClasses(this.config.ldap_top_organization,this.config.ldap_organization_class||[],!0),this.config.organization_schema){const t=this.config.organization_schema;O(t,{config:this.config}).then(t=>{this.schema=t,this.server.ldap.knowAttributeNames(Object.keys(t.attributes)),this.logger.debug("Organization schema loaded")}).catch(i=>this.logger.error(`Failed to load organization schema from ${t}: ${i}`))}}api(t){t.get(`${this.config.api_prefix}/v1/ldap/organizations/top`,async(t,i)=>{await a(i,async()=>this.hideNeverReturn(await this.getOrganisationTop(t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationByDn(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`,async(t,i)=>{const e=decodeURIComponent(t.params.dn);await a(i,async()=>this.hideNeverReturn(await this.getOrganisationSubnodes(e,t)))}),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`,r(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new y('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationSubnodes(e,n,t)))})),t.get(`${this.config.api_prefix}/v1/ldap/organizations/:dn/search`,r(async(t,i)=>{const e=decodeURIComponent(t.params.dn),n=t.query.q;if(!n)throw new y('query parameter "q" is required');await a(i,async()=>this.hideNeverReturn(await this.searchOrganisationTree(e,n,t)))})),t.post(`${this.config.api_prefix}/v1/ldap/organizations`,r(async(t,i)=>this.apiAdd(t,i))),t.put(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiModify(t,i))),t.delete(`${this.config.api_prefix}/v1/ldap/organizations/:dn`,r(async(t,i)=>this.apiDelete(t,i))),t.post(`${this.config.api_prefix}/v1/ldap/organizations/:dn/move`,r(async(t,i)=>this.apiMove(t,i)))}async apiAdd(t,i){const a=e(t,i,"ou");if(!a)return;C(this.schema,Object.keys(a),"ou"),o(a.ou,"ou");const s=a.parentDn||this.config.ldap_top_organization,r=`ou=${h(a.ou)},${s}`,c={objectClass:this.config.ldap_organization_class,ou:a.ou,...Object.fromEntries(Object.entries(a).filter(([t])=>"ou"!==t&&"parentDn"!==t))};await n(i,this.addOrganization.bind(this),r,c,t)}async apiModify(t,a){const s=e(t,a);if(!s)return;i(s);const r=decodeURIComponent(t.params.dn);if(!r)throw new y("dn is required");C(this.schema,E(s)),await n(a,this.modifyOrganization.bind(this),r,s,t)}async apiDelete(t,i){if(!s(t,i))return;const a=decodeURIComponent(t.params.dn);if(!a)throw new y("dn is required");await n(i,this.deleteOrganization.bind(this),a,t)}async apiMove(t,i){if(!s(t,i))return;const n=e(t,i,"targetOrgDn");if(!n)return;const r=decodeURIComponent(t.params.dn);if(!r)throw new y("dn is required");const{targetOrgDn:o}=n;if(!o||"string"!=typeof o)throw new y("Missing or invalid targetOrgDn in request body");await a(i,this.moveOrganization.bind(this),r,o,t)}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.config.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,n=this.config.ldap_organization_path_separator||" / ",s=this.config.ldap_top_organization||"",r=i&&s&&/^ou=/i.test(c(i))&&d(i,s)?l(i):void 0,o=t.ou,h=(Array.isArray(o)?o[0]:o)||r;let f=e;if((this.isOu(t)||void 0!==r)&&h){const t=s?l(s):"";if(t&&e.startsWith(h+n)&&e.endsWith(n+t))return;if(e===h){const t=s?g(s):"";if(!i||!t||!d(i,s)||g(i)===t||g(p(i))===t)return;throw new y(`Organization path "${e}" names no parent, but ${i} is not directly under ${s}`)}if(!e.endsWith(n+h))throw new y(`Organization path must end with its own name, preceded by separator "${n}"`);f=e.slice(0,e.length-n.length-h.length)}const w=await this.server.ldap.system.search({paged:!1,scope:"sub",filter:`(${this.pathAttr}=${u(f)})`,attributes:["dn"]},this.config.ldap_top_organization);if(!w.searchEntries||0===w.searchEntries.length)throw new y(`Invalid organization path ${e}: no organization holds "${f}"`)}}async isEmptyOrganization(t){if((await this.server.ldap.system.search({paged:!1,filter:`(${this.config.ldap_organization_link_attribute}=${u(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.config.ldap_organization_class.filter(t=>"top"!==t.toLowerCase()).some(t=>i.includes(t.toLowerCase()))}async getOrganisationTop(t){if(!this.config.ldap_top_organization)throw new y("No top organization configured");const i=await this.server.ldap.search({paged:!1,scope:"base"},this.config.ldap_top_organization,t);if(1!==i.searchEntries.length)throw new b("Top organization not found");const[,a]=await f(this.registeredHooks.getOrganisationTop,[t,i.searchEntries[0]]);return a}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=_(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.config.ldap_organization_max_subnodes||50,n=i?.query?.objectClass;n&&"organizationalUnit"!==n||a.push(...await this.childOrganizations(t,"(objectClass=organizationalUnit)",e,i));const s=this.config.ldap_top_organization,r=p(s);let o=`(${this.config.ldap_organization_link_attribute}=${u(t)})`;return n&&(o=`(&${o}(objectClass=${u(n)}))`),this.server.logger.debug(`Searching for linked entities with filter: ${o} in ${r}`),a.push(...await this.linkedEntities(r,o,e,t,i)),a}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),n=[];for await(const t of a)n.push(...t.searchEntries);return this.server.logger.debug(`Found ${n.length} sub-OUs for ${t}`),n}catch(i){const a=_(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 n=(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 ${n.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.`),[...n,LdapOrganizations.moreIndicator(`more-organizations-${t}`,"... more organizations than the directory will list",n.length)]}async linkedEntities(t,i,a,e,n){const s=[];let r=0;try{const e=await this.server.ldap.search({paged:!0,filter:i},t,n);for await(const t of e){r+=t.searchEntries.length;const i=a-s.length;i>0&&s.push(...t.searchEntries.slice(0,i))}}catch(s){if(t!==e&&(w(s)||403===s?.statusCode))return this.linkedEntities(e,i,a,e,n);if(4!==_(s))throw s;const r=await this.server.ldap.search({paged:!1,filter:i,sizeLimit:a+1},t,n);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 ${s.length}`),r<=a?s:[...s,LdapOrganizations.moreIndicator(`more-${e}`,`... ${r-a} more ${r-a===1?"element":"elements"}`,a,r)]}async addOrganization(t,i,a){return await this.server.ldap.authorize("ldapaddrequest",[t,i,a]),await this.validateNewOrganization(t,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.validateChanges(t,i),await this.server.ldap.modify(t,i,a)}async validateNewOrganization(t,i){if(!this.schema)return L(void 0,i),!0;for(const[t,a]of Object.entries(i))if(!await this._validateOneChange(t,a))throw new y(this.invalidValueMessage(t));const a=k(this.schema,i,this.server.loadedPlugins);if(a)throw new y(`Attribute "${a}" is required`);return L(this.schema,i),!0}invalidValueMessage(t){return j(t,S(this.schema,t)?.[1])}async validateChanges(t,i){if(!this.schema)return q(void 0,i),!0;if(i.add)for(const[t,a]of Object.entries(i.add))if(!await this._validateOneChange(t,a))throw new y(this.invalidValueMessage(t));if(i.replace)for(const[t,a]of Object.entries(i.replace))if(!await this._validateOneChange(t,a))throw new y(this.invalidValueMessage(t));return q(this.schema,i),!0}async _validateOneChange(t,i){if(!this.schema)return!0;const a=S(this.schema,t)?.[1];if(!a){if(this.schema.strict)throw new y(`Attribute "${t}" is not allowed`);return!0}if(null==i||Array.isArray(i)&&0===i.length){if(a.required)throw new y(`Attribute "${t}" is required`);return!0}if("string"===a.type&&Array.isArray(i))throw new y(`Attribute "${t}" must be a single value`);return await x(t,a,i,t=>this.entryExists(t)),D(a,i)}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){const e=`${c(t)},${i}`;let n,s;await this.server.ldap.authorize("ldaprenamerequest",[t,e,a]);try{n=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(!n.searchEntries||0===n.searchEntries.length)throw new b(`Target organization ${i} not found`);if(!this.isOu(n.searchEntries[0]))throw new y(`Target ${i} is not an organizational unit`);try{s=await this.server.ldap.system.search({paged:!1,scope:"base"},t)}catch(t){throw new b(`Source organization not found: ${t}`)}if(!s.searchEntries||0===s.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()||m(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)throw t;throw new Error(`Failed to move organization: ${t}`)}return{newDn:e}}async deleteOrganization(t,i){try{return await this.server.ldap.delete(t,i)}catch(i){if(32===_(i))throw new b(`Organization ${t} not found`);throw i}}async searchOrganisationSubnodes(t,i,a){const e=[],n=this.config.ldap_organization_max_subnodes||50;e.push(...await this.childOrganizations(t,`(&(objectClass=organizationalUnit)${z(i,"ou,description")})`,n,a));const s=this.config.ldap_top_organization,r=p(s),o=u(t),h=this.config.display_name_attribute||"displayName",c=`(&(${this.config.ldap_organization_link_attribute}=${o})${z(i,`uid,cn,mail,sn,givenName,${h}`)})`;return this.server.logger.debug(`Searching for linked entities with filter: ${c} in ${r}`),e.push(...await this.linkedEntities(r,c,n,t,a)),e}async searchOrganisationTree(t,i,a){const e=this.config.ldap_organization_max_subnodes||50,n=this.pathAttr?`ou,description,${this.pathAttr}`:"ou,description",s=`(&(objectClass=organizationalUnit)${z(i,n)})`,r=[];let o=0;try{const i=await this.server.ldap.search({paged:!0,scope:"sub",filter:s},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 n=_(i);if(32===n)return this.server.logger.debug(`No organizations under ${t}: no such node`),[];if(34===n)throw new y(`${t} is not a valid DN`);if(4!==n)throw i;const r=(await this.server.ldap.search({paged:!1,scope:"sub",filter:s,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.server.loadedPlugins.static&&this.config.organization_schema?A(this.config,this.config.organization_schema):void 0;return{enabled:!0,topOrganization:this.config.ldap_top_organization||"",organizationClass:this.config.ldap_organization_class||["top","organizationalUnit"],linkAttribute:this.linkAttr||"",pathAttribute:this.pathAttr||"",pathSeparator:this.config.ldap_organization_path_separator||" / ",maxSubnodes:this.config.ldap_organization_max_subnodes||50,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`}}}}LdapOrganizations.SIZE_LIMIT_WARNED_MAX=500;export{LdapOrganizations as default};